diff --git a/apps/web/core/components/dropdowns/date-range.tsx b/apps/web/core/components/dropdowns/date-range.tsx index 3cb07d970..a34abcc98 100644 --- a/apps/web/core/components/dropdowns/date-range.tsx +++ b/apps/web/core/components/dropdowns/date-range.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; @@ -59,6 +60,8 @@ type Props = { renderPlaceholder?: boolean; customTooltipContent?: React.ReactNode; customTooltipHeading?: string; + defaultOpen?: boolean; + renderInPortal?: boolean; }; export const DateRangeDropdown: React.FC = observer((props) => { @@ -93,9 +96,11 @@ export const DateRangeDropdown: React.FC = observer((props) => { renderPlaceholder = true, customTooltipContent, customTooltipHeading, + defaultOpen = false, + renderInPortal = false, } = props; // states - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); const [dateRange, setDateRange] = useState(value); // hooks const { data } = useUserProfile(); @@ -193,7 +198,9 @@ export const DateRangeDropdown: React.FC = observer((props) => { renderPlaceholder && ( <> {placeholder.from} - + {placeholder.from && placeholder.to && ( + + )} {placeholder.to} ) @@ -247,6 +254,34 @@ export const DateRangeDropdown: React.FC = observer((props) => { ); + const comboOptions = ( + +
+ { + onSelect?.(val); + }} + mode="range" + disabled={disabledDays} + showOutsideDays + fixedWeeks + weekStartsOn={startOfWeek} + initialFocus + /> +
+
+ ); + + const Options = renderInPortal ? createPortal(comboOptions, document.body) : comboOptions; + return ( = observer((props) => { disabled={disabled} renderByDefault={renderByDefault} > - {isOpen && ( - -
- { - onSelect?.(val); - }} - mode="range" - disabled={disabledDays} - showOutsideDays - fixedWeeks - weekStartsOn={startOfWeek} - initialFocus - /> -
-
- )} + {isOpen && Options}
); }); diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index 87d783d9a..b24713e84 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; import { createPortal } from "react-dom"; @@ -21,6 +23,7 @@ import { TDropdownProps } from "./types"; type Props = TDropdownProps & { clearIconClassName?: string; + defaultOpen?: boolean; optionsClassName?: string; icon?: React.ReactNode; isClearable?: boolean; @@ -41,6 +44,7 @@ export const DateDropdown: React.FC = observer((props) => { buttonVariant, className = "", clearIconClassName = "", + defaultOpen = false, optionsClassName = "", closeOnSelect = true, disabled = false, @@ -60,7 +64,7 @@ export const DateDropdown: React.FC = observer((props) => { renderByDefault = true, } = props; // states - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); // refs const dropdownRef = useRef(null); // hooks diff --git a/apps/web/core/components/rich-filters/add-filters-button.tsx b/apps/web/core/components/rich-filters/add-filters-button.tsx new file mode 100644 index 000000000..512d16fee --- /dev/null +++ b/apps/web/core/components/rich-filters/add-filters-button.tsx @@ -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

= { + buttonConfig?: { + label?: string; + variant?: TButtonVariant; + className?: string; + defaultOpen?: boolean; + iconConfig?: { + shouldShowIcon: boolean; + iconComponent?: React.ReactNode; + }; + isDisabled?: boolean; + }; + filter: IFilterInstance; + onFilterSelect?: (id: string) => void; +}; + +export const AddFilterButton = observer( +

(props: TAddFilterButtonProps) => { + 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: ( +

+ {config.icon && ( + + )} + {config.label} +
+ ), + 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:
All filters applied
, + 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 ( +
+ + {iconConfig.shouldShowIcon && + (iconConfig.iconComponent || )} + {label} +
+ } + /> + + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-item.tsx b/apps/web/core/components/rich-filters/filter-item.tsx new file mode 100644 index 000000000..9576c1c33 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-item.tsx @@ -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

{ + condition: TFilterConditionNodeForDisplay; + filter: IFilterInstance; + isDisabled?: boolean; + showTransition?: boolean; +} + +export const FilterItem = observer( +

(props: FilterItemProps) => { + const { condition, filter, isDisabled = false, showTransition = true } = props; + // refs + const itemRef = useRef(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) => { + filter.updateConditionValue(condition.id, values); + }; + + const handleRemoveFilter = () => { + filter.removeCondition(condition.id); + }; + + if (!filterConfig || !filterConfig.isEnabled) return null; + return ( +

+ {/* Property section */} +
+ {filterConfig.icon && ( +
+ +
+ )} + {filterConfig.label} +
+ + {/* Operator section */} + + {filterConfig.getLabelForOperator(selectedOperatorOption)} +
+ } + /> + + {/* Value section */} + {selectedOperatorFieldConfig && ( + + )} + + {/* Remove button */} + {!isDisabled && ( + + )} + + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/date/range.tsx b/apps/web/core/components/rich-filters/filter-value-input/date/range.tsx new file mode 100644 index 000000000..df60d4d5e --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/date/range.tsx @@ -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

= { + config: TDateRangeFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (value: string[]) => void; +}; + +export const DateRangeFilterValueInput = observer( +

(props: TDateRangeFilterValueInputProps

) => { + 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 ( + + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/date/single.tsx b/apps/web/core/components/rich-filters/filter-value-input/date/single.tsx new file mode 100644 index 000000000..c03256c03 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/date/single.tsx @@ -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

= { + config: TDateFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (value: string | null | undefined) => void; +}; + +export const SingleDateFilterValueInput = observer( +

(props: TSingleDateFilterValueInputProps

) => { + const { config, condition, isDisabled, onChange } = props; + // derived values + const conditionValue = typeof condition.value === "string" ? condition.value : null; + + return ( + { + 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} + /> + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/root.tsx b/apps/web/core/components/rich-filters/filter-value-input/root.tsx new file mode 100644 index 000000000..ce5777e95 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/root.tsx @@ -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

= { + condition: TFilterConditionNodeForDisplay; + filterFieldConfig: TSupportedFilterFieldConfigs; + isDisabled?: boolean; + onChange: (values: SingleOrArray) => void; +}; + +// TODO: Prevent type assertion +export const FilterValueInput = observer( +

(props: TFilterValueInputProps) => { + const { condition, filterFieldConfig, isDisabled = false, onChange } = props; + + // Single select input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.SINGLE_SELECT) { + return ( + + config={filterFieldConfig as TSingleSelectFilterFieldConfig} + condition={condition as TFilterConditionNodeForDisplay} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Multi select input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT) { + return ( + + config={filterFieldConfig as TMultiSelectFilterFieldConfig} + condition={condition as TFilterConditionNode} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Date filter input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE) { + return ( + + config={filterFieldConfig as TDateFilterFieldConfig} + condition={condition as TFilterConditionNodeForDisplay} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Date range filter input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE_RANGE) { + return ( + + config={filterFieldConfig as TDateRangeFilterFieldConfig} + condition={condition as TFilterConditionNodeForDisplay} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Fallback + return ( +

+ Filter type not supported +
+ ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/multi.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/multi.tsx new file mode 100644 index 000000000..1302cd50b --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/multi.tsx @@ -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

= { + config: TMultiSelectFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (values: SingleOrArray) => void; +}; + +export const MultiSelectFilterValueInput = observer( +

(props: TMultiSelectFilterValueInputProps

) => { + const { config, condition, isDisabled, onChange } = props; + // states + const [options, setOptions] = useState[]>([]); + const [loading, setLoading] = useState(false); + // derived values + const formattedOptions = useMemo(() => getFormattedOptions(options), [options]); + + useEffect(() => { + loadOptions({ config, setOptions, setLoading }); + }, [config]); + + const handleSelectChange = (values: string[]) => { + onChange(values); + }; + + return ( + selectedValue={condition.value} options={options} />} + defaultOpen={getFilterValueLength(condition.value) === 0} + /> + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/selected-options-display.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/selected-options-display.tsx new file mode 100644 index 000000000..a2c35893a --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/selected-options-display.tsx @@ -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 = { + selectedValue: SingleOrArray; + options: IFilterOption[]; + displayCount?: number; + emptyValue?: string; + fallbackText?: string; +}; + +export const SelectedOptionsDisplay = (props: TSelectedOptionsDisplayProps) => { + 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[]; + + // When no value is selected, display the empty value + if (selectedArray.length === 0) { + return {emptyValue}; + } + + // When no options are found but we have a fallback text + if (options.length === 0) { + return {fallbackText ?? `${selectedArray.length} option(s) selected`}; + } + + return ( +

+ {selectedOptions.slice(0, displayCount).map((option, index) => ( + +
+ {option?.icon && {option.icon}} + {option?.label} +
+ {index < Math.min(displayCount, selectedOptions.length) - 1 && ( + , + )} +
+ ))} + {remainingCount > 0 && ( + + +{remainingCount} more + + )} +
+ ); +}; diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/shared.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/shared.tsx new file mode 100644 index 000000000..23fae6196 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/shared.tsx @@ -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 = { + config: TSupportedFilterFieldConfigs; + setOptions: (options: IFilterOption[]) => void; + setLoading?: (loading: boolean) => void; +}; + +export const loadOptions = async (props: TLoadOptionsProps) => { + 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 = (options: IFilterOption[]) => + options.map((option) => ({ + value: option.value, + content: ( +
+ {option.icon && ( + {option.icon} + )} + {option.label} +
+ ), + 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, +}); diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/single.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/single.tsx new file mode 100644 index 000000000..ab475e8ba --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/single.tsx @@ -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

= { + config: TSingleSelectFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (value: string | null) => void; +}; + +export const SingleSelectFilterValueInput = observer( +

(props: TSingleSelectFilterValueInputProps

) => { + const { config, condition, onChange, isDisabled } = props; + // states + const [options, setOptions] = useState[]>([]); + const [loading, setLoading] = useState(false); + // derived values + const formattedOptions = useMemo(() => getFormattedOptions(options), [options]); + + useEffect(() => { + loadOptions({ config, setOptions, setLoading }); + }, [config]); + + const handleSelectChange = (value: string) => { + if (value === condition.value) { + onChange(null); + } else { + onChange(value); + } + }; + + return ( + selectedValue={condition.value} options={options} displayCount={1} /> + } + defaultOpen={!condition.value} + /> + ); + } +); diff --git a/apps/web/core/components/rich-filters/filters-row.tsx b/apps/web/core/components/rich-filters/filters-row.tsx new file mode 100644 index 000000000..973cefe38 --- /dev/null +++ b/apps/web/core/components/rich-filters/filters-row.tsx @@ -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 = { + buttonConfig?: TAddFilterButtonProps["buttonConfig"]; + disabledAllOperations?: boolean; + filter: IFilterInstance; + variant?: "default" | "header"; + visible?: boolean; + maxVisibleConditions?: number; + trackerElements?: { + clearFilter?: string; + saveView?: string; + updateView?: string; + }; +}; + +export const FiltersRow = observer( + (props: TFiltersRowProps) => { + 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 = ( + <> + { + if (variant === "header") { + setShowAllConditions(true); + } + }} + /> + {visibleConditions.map((condition) => ( + + ))} + {variant === "header" && hiddenConditionsCount > 0 && ( + + )} + {variant === "header" && + showAllConditions && + maxVisibleConditions && + filter.allConditionsForDisplay.length > maxVisibleConditions && ( + + )} + + ); + + const rightContent = !disabledAllOperations && ( + <> + + + + + + + + + + + ); + + if (variant === "default") { + return ( +

+ {leftContent} + {rightContent} +
+ ); + } + + return ( +
+
+
{leftContent}
+
{rightContent}
+
+
+ ); + } +); + +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) => ( + + {props.children} + +)); diff --git a/apps/web/core/components/rich-filters/shared.ts b/apps/web/core/components/rich-filters/shared.ts new file mode 100644 index 000000000..7361c81f2 --- /dev/null +++ b/apps/web/core/components/rich-filters/shared.ts @@ -0,0 +1 @@ +export const COMMON_FILTER_ITEM_BORDER_CLASSNAME = "border-r border-custom-border-200"; diff --git a/apps/web/package.json b/apps/web/package.json index 58c84cbe7..4a7b96814 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@plane/i18n": "workspace:*", "@plane/propel": "workspace:*", "@plane/services": "workspace:*", + "@plane/shared-state": "workspace:*", "@plane/types": "workspace:*", "@plane/ui": "workspace:*", "@plane/utils": "workspace:*", diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 045538f3a..7be7ac63e 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,38 +1,39 @@ export * from "./ai"; +export * from "./analytics"; export * from "./auth"; export * from "./chart"; +export * from "./cycle"; +export * from "./dashboard"; +export * from "./emoji"; export * from "./endpoints"; +export * from "./estimates"; +export * from "./event-tracker"; export * from "./file"; export * from "./filter"; export * from "./graph"; +export * from "./icon"; export * from "./instance"; +export * from "./intake"; export * from "./issue"; +export * from "./label"; export * from "./metadata"; +export * from "./module"; 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 "./stickies"; +export * from "./subscription"; export * from "./swr"; 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 "./intake"; -export * from "./profile"; +export * from "./user"; +export * from "./views"; export * from "./workspace-drafts"; -export * from "./label"; -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"; +export * from "./workspace"; diff --git a/packages/constants/src/rich-filters/index.ts b/packages/constants/src/rich-filters/index.ts new file mode 100644 index 000000000..cf6b76514 --- /dev/null +++ b/packages/constants/src/rich-filters/index.ts @@ -0,0 +1,2 @@ +export * from "./operator-labels"; +export * from "./option"; diff --git a/packages/constants/src/rich-filters/operator-labels/core.ts b/packages/constants/src/rich-filters/operator-labels/core.ts new file mode 100644 index 000000000..39f902ae3 --- /dev/null +++ b/packages/constants/src/rich-filters/operator-labels/core.ts @@ -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 = { + [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 = { + [CORE_EQUALITY_OPERATOR.EXACT]: "is", + [CORE_COMPARISON_OPERATOR.RANGE]: "between", +} as const; diff --git a/packages/constants/src/rich-filters/operator-labels/extended.ts b/packages/constants/src/rich-filters/operator-labels/extended.ts new file mode 100644 index 000000000..349baeb81 --- /dev/null +++ b/packages/constants/src/rich-filters/operator-labels/extended.ts @@ -0,0 +1,21 @@ +import { TExtendedSupportedOperators } from "@plane/types"; + +/** + * Extended operator labels + */ +export const EXTENDED_OPERATOR_LABELS_MAP: Record = {} as const; + +/** + * Extended date-specific operator labels + */ +export const EXTENDED_DATE_OPERATOR_LABELS_MAP: Record = {} as const; + +/** + * Negated operator labels for all operators + */ +export const NEGATED_OPERATOR_LABELS_MAP: Record = {} as const; + +/** + * Negated date operator labels for all date operators + */ +export const NEGATED_DATE_OPERATOR_LABELS_MAP: Record = {} as const; diff --git a/packages/constants/src/rich-filters/operator-labels/index.ts b/packages/constants/src/rich-filters/operator-labels/index.ts new file mode 100644 index 000000000..8098b17e0 --- /dev/null +++ b/packages/constants/src/rich-filters/operator-labels/index.ts @@ -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 = { + ...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 = { + ...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"; diff --git a/packages/constants/src/rich-filters/option.ts b/packages/constants/src/rich-filters/option.ts new file mode 100644 index 000000000..123585787 --- /dev/null +++ b/packages/constants/src/rich-filters/option.ts @@ -0,0 +1,63 @@ +import { TExternalFilter } from "@plane/types"; + +/** + * Filter config options. + */ +export type TConfigOptions = Record; + +/** + * Default filter config options. + */ +export const DEFAULT_FILTER_CONFIG_OPTIONS: TConfigOptions = {}; + +/** + * Clear filter config. + */ +export type TClearFilterOptions = { + label?: string; + onFilterClear: () => void | Promise; + isDisabled?: boolean; +}; + +/** + * Save view config. + */ +export type TSaveViewOptions = { + label?: string; + onViewSave: (expression: E) => void | Promise; + isDisabled?: boolean; +}; + +/** + * Update view config. + */ +export type TUpdateViewOptions = { + label?: string; + hasAdditionalChanges?: boolean; + onViewUpdate: (expression: E) => void | Promise; + isDisabled?: boolean; +}; + +/** + * Filter expression options. + */ +export type TExpressionOptions = { + clearFilterOptions?: TClearFilterOptions; + saveViewOptions?: TSaveViewOptions; + updateViewOptions?: TUpdateViewOptions; +}; + +/** + * Default filter expression options. + */ +export const DEFAULT_FILTER_EXPRESSION_OPTIONS: TExpressionOptions = {}; + +/** + * Filter options. + * - expression: Filter expression options. + * - config: Filter config options. + */ +export type TFilterOptions = { + expression: Partial>; + config: Partial; +}; diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index 496f2272a..02d5632f7 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -15,13 +15,21 @@ "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist" }, "dependencies": { + "@plane/constants": "workspace:*", + "@plane/types": "workspace:*", + "@plane/utils": "workspace:*", + "lodash": "catalog:", "mobx": "catalog:", + "mobx-utils": "catalog:", + "uuid": "catalog:", "zod": "^3.22.2" }, "devDependencies": { "@plane/eslint-config": "workspace:*", "@plane/typescript-config": "workspace:*", "@types/node": "^22.5.4", + "@types/lodash": "catalog:", + "@types/uuid": "catalog:", "typescript": "catalog:" } } diff --git a/packages/shared-state/src/index.ts b/packages/shared-state/src/index.ts index e69de29bb..faaf31f83 100644 --- a/packages/shared-state/src/index.ts +++ b/packages/shared-state/src/index.ts @@ -0,0 +1,2 @@ +export * from "./store"; +export * from "./utils"; diff --git a/packages/shared-state/src/store/index.ts b/packages/shared-state/src/store/index.ts new file mode 100644 index 000000000..253180f0e --- /dev/null +++ b/packages/shared-state/src/store/index.ts @@ -0,0 +1 @@ +export * from "./rich-filters"; diff --git a/packages/shared-state/src/store/rich-filters/adapter.ts b/packages/shared-state/src/store/rich-filters/adapter.ts new file mode 100644 index 000000000..1dc74a67e --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/adapter.ts @@ -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 + implements IFilterAdapter +{ + /** + * 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 | 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 | null): E; +} diff --git a/packages/shared-state/src/store/rich-filters/config-manager.ts b/packages/shared-state/src/store/rich-filters/config-manager.ts new file mode 100644 index 000000000..88b493723 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/config-manager.ts @@ -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

{ + // observables + filterConfigs: Map>; // filter property -> config + configOptions: TConfigOptions; + // computed + allAvailableConfigs: IFilterConfig[]; + // computed functions + getConfigByProperty: (property: P) => IFilterConfig | undefined; + // helpers + register: >(config: C) => void; + registerAll: (configs: TFilterConfig[]) => void; + updateConfigByProperty: (property: P, configUpdates: Partial>) => void; +} + +/** + * Parameters for initializing the FilterConfigManager. + * - options: Optional configuration options to override defaults + */ +export type TConfigManagerParams = { + options?: Partial; +}; + +/** + * 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

+ implements IFilterConfigManager

+{ + // observables + filterConfigs: IFilterConfigManager

["filterConfigs"]; + configOptions: IFilterConfigManager

["configOptions"]; + // parent filter instance + _filterInstance: IFilterInstance; + + /** + * 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, params: TConfigManagerParams) { + this.filterConfigs = new Map>(); + 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

["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

["getConfigByProperty"] = computedFn( + (property) => this.filterConfigs.get(property) as IFilterConfig + ); + + // ------------ 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

["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

["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

["updateConfigByProperty"] = action((property, configUpdates) => { + const prevConfig = this.filterConfigs.get(property); + prevConfig?.mutate(configUpdates); + }); + + // ------------ private computed ------------ + + private get _allConfigs(): IFilterConfig[] { + return Array.from(this.filterConfigs.values()); + } + + /** + * Returns all enabled filterConfigs. + * @returns All enabled filterConfigs. + */ + private get _allEnabledConfigs(): IFilterConfig[] { + 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 { + return DEFAULT_FILTER_CONFIG_OPTIONS ? { ...DEFAULT_FILTER_CONFIG_OPTIONS, ...options } : options || {}; + } +} diff --git a/packages/shared-state/src/store/rich-filters/config.ts b/packages/shared-state/src/store/rich-filters/config.ts new file mode 100644 index 000000000..a590631d4 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/config.ts @@ -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

+ extends TFilterConfig { + // computed + allSupportedOperators: TSupportedOperators[]; + allSupportedOperatorConfigs: TOperatorSpecificConfigs[keyof TOperatorSpecificConfigs][]; + firstOperator: TSupportedOperators | undefined; + // computed functions + getOperatorConfig: ( + operator: TAllAvailableOperatorsForDisplay + ) => TOperatorSpecificConfigs[keyof TOperatorSpecificConfigs] | undefined; + getLabelForOperator: (operator: TAllAvailableOperatorsForDisplay | undefined) => string; + getDisplayOperatorByValue: ( + operator: T, + value: V + ) => T; + getAllDisplayOperatorOptionsByValue: (value: V) => TOperatorOptionForDisplay[]; + // actions + mutate: (updates: Partial>) => void; +} + +export class FilterConfig

+ implements IFilterConfig +{ + // observables + id: IFilterConfig["id"]; + label: IFilterConfig["label"]; + icon?: IFilterConfig["icon"]; + isEnabled: IFilterConfig["isEnabled"]; + supportedOperatorConfigsMap: IFilterConfig["supportedOperatorConfigsMap"]; + allowMultipleFilters: IFilterConfig["allowMultipleFilters"]; + + /** + * Creates a new FilterConfig instance. + * @param params - The parameters for the filter config. + */ + constructor(params: TFilterConfig) { + 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["allSupportedOperators"] { + return Array.from(this.supportedOperatorConfigsMap.keys()); + } + + /** + * Returns all supported operator configs. + * @returns All supported operator configs. + */ + get allSupportedOperatorConfigs(): IFilterConfig["allSupportedOperatorConfigs"] { + return Array.from(this.supportedOperatorConfigsMap.values()); + } + + /** + * Returns the first operator. + * @returns The first operator. + */ + get firstOperator(): IFilterConfig["firstOperator"] { + return this.allSupportedOperators[0]; + } + + // ------------ computed functions ------------ + + /** + * Returns the operator config. + * @param operator - The operator. + * @returns The operator config. + */ + getOperatorConfig: IFilterConfig["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["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["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["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["mutate"] = action((updates) => { + runInAction(() => { + for (const key in updates) { + if (updates.hasOwnProperty(key)) { + const configKey = key as keyof TFilterConfig; + set(this, configKey, updates[configKey]); + } + } + }); + }); + + // ------------ private helpers ------------ + + private _getAdditionalOperatorOptions = ( + _operator: TSupportedOperators, + _value: V + ): TOperatorOptionForDisplay | undefined => undefined; +} diff --git a/packages/shared-state/src/store/rich-filters/filter-helpers.ts b/packages/shared-state/src/store/rich-filters/filter-helpers.ts new file mode 100644 index 000000000..68ca76fc7 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/filter-helpers.ts @@ -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

{ + // initialization + initializeExpression: (initialExpression?: E) => TFilterExpression

| null; + initializeExpressionOptions: (expressionOptions?: Partial>) => TExpressionOptions; + // condition operations + addConditionToExpression: ( + expression: TFilterExpression

| null, + groupOperator: TLogicalOperator, + condition: TFilterConditionPayload, + isNegation: boolean + ) => TFilterExpression

| null; + // group operations + restructureExpressionForOperatorChange: ( + expression: TFilterExpression

| null, + conditionId: string, + newOperator: TSupportedOperators, + isNegation: boolean, + shouldResetValue: boolean + ) => TFilterExpression

| 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

+ implements IFilterInstanceHelper +{ + private adapter: IFilterAdapter; + + /** + * Creates a new FilterInstanceHelper instance. + * + * @param adapter - The filter adapter for converting between internal and external formats + */ + constructor(adapter: IFilterAdapter) { + 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["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["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["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["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, + _isNegation: boolean + ): TFilterExpression

=> { + 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

| null, + groupOperator: TLogicalOperator, + conditionToAdd: TFilterExpression

+ ): TFilterExpression

| null { + switch (groupOperator) { + case LOGICAL_OPERATOR.AND: + return addAndCondition(expression, conditionToAdd); + default: + console.warn(`Unsupported logical operator: ${groupOperator}`); + return expression; + } + } +} diff --git a/packages/shared-state/src/store/rich-filters/filter.ts b/packages/shared-state/src/store/rich-filters/filter.ts new file mode 100644 index 000000000..e840845f8 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/filter.ts @@ -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

{ + // observables + id: string; + initialFilterExpression: TFilterExpression

| null; + expression: TFilterExpression

| null; + adapter: IFilterAdapter; + configManager: IFilterConfigManager

; + onExpressionChange?: (expression: E) => void; + // computed + hasActiveFilters: boolean; + hasChanges: boolean; + allConditions: TFilterConditionNode[]; + allConditionsForDisplay: TFilterConditionNodeForDisplay[]; + // computed option helpers + clearFilterOptions: TClearFilterOptions | undefined; + saveViewOptions: TSaveViewOptions | undefined; + updateViewOptions: TUpdateViewOptions | 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[]; + findFirstConditionByPropertyAndOperator: ( + property: P, + operator: TAllAvailableOperatorsForDisplay + ) => TFilterConditionNodeForDisplay | undefined; + addCondition: ( + groupOperator: TLogicalOperator, + condition: TFilterConditionPayload, + isNegation: boolean + ) => void; + updateConditionOperator: (conditionId: string, operator: TSupportedOperators, isNegation: boolean) => void; + updateConditionValue: (conditionId: string, value: SingleOrArray) => void; + removeCondition: (conditionId: string) => void; + // config actions + clearFilters: () => Promise; + saveView: () => Promise; + updateView: () => Promise; + // expression options actions + updateExpressionOptions: (newOptions: Partial>) => void; +} + +export type TFilterParams

= { + adapter: IFilterAdapter; + options?: Partial>; + initialExpression?: E; + onExpressionChange?: (expression: E) => void; +}; + +export class FilterInstance

implements IFilterInstance { + // observables + id: string; + initialFilterExpression: TFilterExpression

| null; + expression: TFilterExpression

| null; + expressionOptions: TExpressionOptions; + adapter: IFilterAdapter; + configManager: IFilterConfigManager

; + onExpressionChange?: (expression: E) => void; + + // helper instance + private helper: IFilterInstanceHelper; + + constructor(params: TFilterParams) { + this.id = uuidv4(); + this.adapter = params.adapter; + this.helper = new FilterInstanceHelper(this.adapter); + this.configManager = new FilterConfigManager(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["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["hasChanges"] { + return !deepCompareFilterExpressions(this.initialFilterExpression, this.expression); + } + + /** + * Returns all conditions from the filter expression. + * @returns An array of filter conditions. + */ + get allConditions(): IFilterInstance["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["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["clearFilterOptions"] { + return this.expressionOptions.clearFilterOptions; + } + + /** + * Returns the save view options. + * @returns The save view options. + */ + get saveViewOptions(): IFilterInstance["saveViewOptions"] { + return this.expressionOptions.saveViewOptions; + } + + /** + * Returns the update view options. + * @returns The update view options. + */ + get updateViewOptions(): IFilterInstance["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["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["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["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["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["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["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["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["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["updateConditionValue"] = action( + (conditionId: string, value: SingleOrArray) => { + // 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["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["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["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["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["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()); + } +} diff --git a/packages/shared-state/src/store/rich-filters/index.ts b/packages/shared-state/src/store/rich-filters/index.ts new file mode 100644 index 000000000..eb3564ddc --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/index.ts @@ -0,0 +1,2 @@ +export * from "./adapter"; +export * from "./filter"; diff --git a/packages/shared-state/src/utils/index.ts b/packages/shared-state/src/utils/index.ts new file mode 100644 index 000000000..42270deb7 --- /dev/null +++ b/packages/shared-state/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./rich-filter.helper"; diff --git a/packages/shared-state/src/utils/rich-filter.helper.ts b/packages/shared-state/src/utils/rich-filter.helper.ts new file mode 100644 index 000000000..3d1533fcc --- /dev/null +++ b/packages/shared-state/src/utils/rich-filter.helper.ts @@ -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 +): E | undefined => { + const { conditions, adapter } = params; + let tempExpression: E | undefined = undefined; + const tempFilterInstance = new FilterInstance({ + 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; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2a63141b1..ea6ee4080 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,49 +1,49 @@ -export * from "./users"; -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 "./activity"; export * from "./ai"; -export * from "./estimate"; -export * from "./importer"; -export * from "./inbox"; export * from "./analytics"; export * from "./api_token"; export * from "./auth"; export * from "./calendar"; -export * from "./instance"; -export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable -export * from "./reaction"; -export * from "./view-props"; -export * from "./waitlist"; -export * from "./webhook"; -export * from "./workspace-views"; +export * from "./charts"; +export * from "./command-palette"; export * from "./common"; +export * from "./cycle"; +export * from "./dashboard"; +export * from "./de-dupe"; +export * from "./description_version"; export * from "./editor"; -export * from "./pragmatic"; -export * from "./publish"; -export * from "./search"; -export * from "./workspace-notifications"; +export * from "./enums"; +export * from "./epics"; +export * from "./estimate"; export * from "./favorite"; 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 "./stickies"; -export * from "./utils"; -export * from "./payment"; +export * from "./importer"; +export * from "./inbox"; +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 "./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"; diff --git a/packages/types/src/rich-filters/adapter.ts b/packages/types/src/rich-filters/adapter.ts new file mode 100644 index 000000000..8641ec33f --- /dev/null +++ b/packages/types/src/rich-filters/adapter.ts @@ -0,0 +1,23 @@ +// local imports +import { TFilterExpression, TFilterProperty } from "./expression"; + +/** + * External filter format + */ +export type TExternalFilter = Record | 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

{ + /** + * Converts external format to internal filter tree. + */ + toInternal(externalFilter: E): TFilterExpression

| null; + /** + * Converts internal filter tree to external format. + */ + toExternal(internalFilter: TFilterExpression

| null): E; +} diff --git a/packages/types/src/rich-filters/builder.ts b/packages/types/src/rich-filters/builder.ts new file mode 100644 index 000000000..1dc0847fa --- /dev/null +++ b/packages/types/src/rich-filters/builder.ts @@ -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

= { + property: P; + operator: TAllAvailableOperatorsForDisplay; + value: SingleOrArray; +}; + +/** + * 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[]; + adapter: IFilterAdapter; +}; diff --git a/packages/types/src/rich-filters/config/filter-config.ts b/packages/types/src/rich-filters/config/filter-config.ts new file mode 100644 index 000000000..9ab7aeed2 --- /dev/null +++ b/packages/types/src/rich-filters/config/filter-config.ts @@ -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

= { + id: P; + label: string; + icon?: React.FC>; + isEnabled: boolean; + allowMultipleFilters?: boolean; + supportedOperatorConfigsMap: TOperatorConfigMap; +}; diff --git a/packages/types/src/rich-filters/config/index.ts b/packages/types/src/rich-filters/config/index.ts new file mode 100644 index 000000000..cd3f61fa5 --- /dev/null +++ b/packages/types/src/rich-filters/config/index.ts @@ -0,0 +1 @@ +export * from "./filter-config"; diff --git a/packages/types/src/rich-filters/derived/core.ts b/packages/types/src/rich-filters/derived/core.ts new file mode 100644 index 000000000..23c960c90 --- /dev/null +++ b/packages/types/src/rich-filters/derived/core.ts @@ -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 = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TDateFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support range date filter types. + */ +export type TCoreSupportedRangeDateFilterOperators = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TDateRangeFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support date filter types. + */ +export type TCoreSupportedDateFilterOperators = + | TCoreSupportedSingleDateFilterOperators + | TCoreSupportedRangeDateFilterOperators; + +export type TCoreAllAvailableDateFilterOperatorsForDisplay = + TCoreSupportedDateFilterOperators; + +// -------- SELECT FILTER OPERATORS -------- + +/** + * Union type representing all core operators that support single select filter types. + */ +export type TCoreSupportedSingleSelectFilterOperators = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TSingleSelectFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support multi select filter types. + */ +export type TCoreSupportedMultiSelectFilterOperators = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TMultiSelectFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support any select filter types. + */ +export type TCoreSupportedSelectFilterOperators = + | TCoreSupportedSingleSelectFilterOperators + | TCoreSupportedMultiSelectFilterOperators; + +export type TCoreAllAvailableSelectFilterOperatorsForDisplay = + TCoreSupportedSelectFilterOperators; diff --git a/packages/types/src/rich-filters/derived/extended.ts b/packages/types/src/rich-filters/derived/extended.ts new file mode 100644 index 000000000..118e5b87b --- /dev/null +++ b/packages/types/src/rich-filters/derived/extended.ts @@ -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; diff --git a/packages/types/src/rich-filters/derived/index.ts b/packages/types/src/rich-filters/derived/index.ts new file mode 100644 index 000000000..cc31c946c --- /dev/null +++ b/packages/types/src/rich-filters/derived/index.ts @@ -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 = + | TCoreSupportedDateFilterOperators + | TExtendedSupportedDateFilterOperators; + +export type TAllAvailableDateFilterOperatorsForDisplay = + | TCoreAllAvailableDateFilterOperatorsForDisplay + | TExtendedAllAvailableDateFilterOperatorsForDisplay; + +/** + * All supported select filter operators. + */ +export type TSupportedSelectFilterOperators = + | TCoreSupportedSelectFilterOperators + | TExtendedSupportedSelectFilterOperators; + +export type TAllAvailableSelectFilterOperatorsForDisplay = + | TCoreAllAvailableSelectFilterOperatorsForDisplay + | TExtendedAllAvailableSelectFilterOperatorsForDisplay; + +// -------- RE-EXPORTS -------- + +export * from "./shared"; +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/rich-filters/derived/shared.ts b/packages/types/src/rich-filters/derived/shared.ts new file mode 100644 index 000000000..cce5ebaf0 --- /dev/null +++ b/packages/types/src/rich-filters/derived/shared.ts @@ -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; diff --git a/packages/types/src/rich-filters/expression.ts b/packages/types/src/rich-filters/expression.ts new file mode 100644 index 000000000..10d115e84 --- /dev/null +++ b/packages/types/src/rich-filters/expression.ts @@ -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

= TBaseFilterNode & { + type: typeof FILTER_NODE_TYPE.CONDITION; + property: P; + operator: TSupportedOperators; + value: SingleOrArray; +}; + +/** + * Filter condition node for display purposes. + */ +export type TFilterConditionNodeForDisplay

= Omit< + TFilterConditionNode, + "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

= TBaseFilterNode & { + type: typeof FILTER_NODE_TYPE.GROUP; + logicalOperator: typeof LOGICAL_OPERATOR.AND; + children: TFilterExpression

[]; +}; + +/** + * Union type for all group node types - AND, OR, and NOT groups. + * @template P - Property key type + */ +export type TFilterGroupNode

= TFilterAndGroupNode

; + +/** + * 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

= + | TFilterConditionNode + | TFilterGroupNode

; + +/** + * Payload for creating/updating condition nodes - excludes base node properties. + * @template P - Property key type + * @template V - Value type + */ +export type TFilterConditionPayload

= Omit< + TFilterConditionNode, + keyof TBaseFilterNode +>; + +/** + * Payload for creating/updating AND group nodes - excludes base node properties. + * @template P - Property key type + */ +export type TFilterAndGroupPayload

= Omit, keyof TBaseFilterNode>; + +/** + * Union payload type for creating/updating any group node - excludes base node properties. + * @template P - Property key type + */ +export type TFilterGroupPayload

= TFilterAndGroupPayload

; diff --git a/packages/types/src/rich-filters/field-types/core.ts b/packages/types/src/rich-filters/field-types/core.ts new file mode 100644 index 000000000..504979f21 --- /dev/null +++ b/packages/types/src/rich-filters/field-types/core.ts @@ -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 = 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 = 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 = TBaseFilterFieldConfig & { + type: typeof CORE_FILTER_FIELD_TYPE.SINGLE_SELECT; + defaultValue?: V; + getOptions: IFilterOption[] | (() => IFilterOption[] | Promise[]>); +}; + +/** + * 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 = TBaseFilterFieldConfig & { + type: typeof CORE_FILTER_FIELD_TYPE.MULTI_SELECT; + defaultValue?: V[]; + getOptions: IFilterOption[] | (() => IFilterOption[] | Promise[]>); + singleValueOperator: TSupportedOperators; +}; + +// -------- UNION TYPES -------- + +/** + * All core filter configurations + */ +export type TCoreFilterFieldConfigs = + | TDateFilterFieldConfig + | TDateRangeFilterFieldConfig + | TSingleSelectFilterFieldConfig + | TMultiSelectFilterFieldConfig; diff --git a/packages/types/src/rich-filters/field-types/extended.ts b/packages/types/src/rich-filters/field-types/extended.ts new file mode 100644 index 000000000..80922fbd0 --- /dev/null +++ b/packages/types/src/rich-filters/field-types/extended.ts @@ -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; diff --git a/packages/types/src/rich-filters/field-types/index.ts b/packages/types/src/rich-filters/field-types/index.ts new file mode 100644 index 000000000..946c38cda --- /dev/null +++ b/packages/types/src/rich-filters/field-types/index.ts @@ -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 = + | TCoreFilterFieldConfigs + | TExtendedFilterFieldConfigs; + +// -------- RE-EXPORTS -------- + +export * from "./shared"; +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/rich-filters/field-types/shared.ts b/packages/types/src/rich-filters/field-types/shared.ts new file mode 100644 index 000000000..0163e9743 --- /dev/null +++ b/packages/types/src/rich-filters/field-types/shared.ts @@ -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 { + id: string; + label: string; + value: V; + icon?: React.ReactNode; + iconClassName?: string; + disabled?: boolean; + description?: string; +} diff --git a/packages/types/src/rich-filters/index.ts b/packages/types/src/rich-filters/index.ts new file mode 100644 index 000000000..e242d63e1 --- /dev/null +++ b/packages/types/src/rich-filters/index.ts @@ -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"; diff --git a/packages/types/src/rich-filters/operator-configs/core.ts b/packages/types/src/rich-filters/operator-configs/core.ts new file mode 100644 index 000000000..994361159 --- /dev/null +++ b/packages/types/src/rich-filters/operator-configs/core.ts @@ -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 = + | TSingleSelectFilterFieldConfig + | TDateFilterFieldConfig; + +// ----------------------------- IN Operator ----------------------------- +export type TCoreInOperatorConfigs = TMultiSelectFilterFieldConfig; + +// ----------------------------- RANGE Operator ----------------------------- +export type TCoreRangeOperatorConfigs = TDateRangeFilterFieldConfig; + +// ----------------------------- Core Operator Specific Configs ----------------------------- +export type TCoreOperatorSpecificConfigs = { + [CORE_EQUALITY_OPERATOR.EXACT]: TCoreExactOperatorConfigs; + [CORE_COLLECTION_OPERATOR.IN]: TCoreInOperatorConfigs; + [CORE_COMPARISON_OPERATOR.RANGE]: TCoreRangeOperatorConfigs; +}; diff --git a/packages/types/src/rich-filters/operator-configs/extended.ts b/packages/types/src/rich-filters/operator-configs/extended.ts new file mode 100644 index 000000000..19f2870c1 --- /dev/null +++ b/packages/types/src/rich-filters/operator-configs/extended.ts @@ -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; diff --git a/packages/types/src/rich-filters/operator-configs/index.ts b/packages/types/src/rich-filters/operator-configs/index.ts new file mode 100644 index 000000000..4b863c306 --- /dev/null +++ b/packages/types/src/rich-filters/operator-configs/index.ts @@ -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 = + | TCoreExactOperatorConfigs + | TExtendedExactOperatorConfigs; + +/** + * IN operator - combines core and extended configurations + */ +export type TInOperatorConfigs = TCoreInOperatorConfigs | TExtendedInOperatorConfigs; + +/** + * RANGE operator - combines core and extended configurations + */ +export type TRangeOperatorConfigs = + | TCoreRangeOperatorConfigs + | TExtendedRangeOperatorConfigs; + +// ----------------------------- 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 = { + [EQUALITY_OPERATOR.EXACT]: TExactOperatorConfigs; + [COLLECTION_OPERATOR.IN]: TInOperatorConfigs; + [COMPARISON_OPERATOR.RANGE]: TRangeOperatorConfigs; +} & TExtendedOperatorSpecificConfigs; + +/** + * Operator filter configuration mapping - for different operators. + * Provides type-safe mapping of operators to their specific supported configurations. + */ +export type TOperatorConfigMap = Map< + keyof TOperatorSpecificConfigs, + TOperatorSpecificConfigs[keyof TOperatorSpecificConfigs] +>; + +// -------- RE-EXPORTS -------- + +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/rich-filters/operators/core.ts b/packages/types/src/rich-filters/operators/core.ts new file mode 100644 index 000000000..91c9adc28 --- /dev/null +++ b/packages/types/src/rich-filters/operators/core.ts @@ -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; diff --git a/packages/types/src/rich-filters/operators/extended.ts b/packages/types/src/rich-filters/operators/extended.ts new file mode 100644 index 000000000..56870326c --- /dev/null +++ b/packages/types/src/rich-filters/operators/extended.ts @@ -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; diff --git a/packages/types/src/rich-filters/operators/index.ts b/packages/types/src/rich-filters/operators/index.ts new file mode 100644 index 000000000..458eff497 --- /dev/null +++ b/packages/types/src/rich-filters/operators/index.ts @@ -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"; diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts index d7f7067b1..81cc8a581 100644 --- a/packages/types/src/utils.ts +++ b/packages/types/src/utils.ts @@ -5,3 +5,5 @@ export type PartialDeep = { export type CompleteOrEmpty = T | Record; export type MakeOptional = Omit & Partial>; + +export type SingleOrArray = T extends null | undefined ? T : T | T[]; diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index 712f2120f..8fdd0f664 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -162,6 +162,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { "max-h-48": maxHeight === "md", "max-h-36": maxHeight === "rg", "max-h-28": maxHeight === "sm", + "max-h-full": maxHeight === "full", })} > {filteredOptions ? ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 230b517f5..8a3fc0e3b 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -24,7 +24,7 @@ export interface IDropdownProps { disabled?: boolean; input?: boolean; label?: string | React.ReactNode; - maxHeight?: "sm" | "rg" | "md" | "lg"; + maxHeight?: "sm" | "rg" | "md" | "lg" | "full"; noChevron?: boolean; chevronClassName?: string; onOpen?: () => void; diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index fe306a617..685273dba 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -535,18 +535,25 @@ export const formatDateRange = ( // Duration Helpers /** * @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 * @example formatDuration(3665) // "1 hr 1 min 5 sec" * @example formatDuration(125) // "2 min 5 sec" * @example formatDuration(45) // "45 sec" + * @example formatDuration(0.1223094) // "122.31 ms" */ export const formatDuration = (seconds: number | undefined | null): string => { // 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"; } + // 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 const totalSeconds = Math.round(seconds); @@ -559,7 +566,7 @@ export const formatDuration = (seconds: number | undefined | null): string => { const parts: string[] = []; if (hours > 0) { - parts.push(`${hours} hr${hours !== 1 ? "" : ""}`); // Always use "hr" for consistency + parts.push(`${hours} hr`); } if (minutes > 0) { @@ -572,3 +579,11 @@ export const formatDuration = (seconds: number | undefined | null): string => { 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)); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 760f82333..d411a69d2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -19,8 +19,9 @@ export * from "./module"; export * from "./notification"; export * from "./page"; export * from "./permission"; -export * from "./project"; export * from "./project-views"; +export * from "./project"; +export * from "./rich-filters"; export * from "./router"; export * from "./string"; export * from "./subscription"; diff --git a/packages/utils/src/rich-filters/factories/configs/core.ts b/packages/utils/src/rich-filters/factories/configs/core.ts new file mode 100644 index 000000000..a3450d2c0 --- /dev/null +++ b/packages/utils/src/rich-filters/factories/configs/core.ts @@ -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 =

( + config: TFilterConfig +): TFilterConfig => config; + +// ------------ Selection filters ------------ + +/** + * Options transformation interface for selection filters + */ +export interface TOptionTransforms { + 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 = 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, + config?: TSingleSelectConfig, + iconConfig?: IFilterIconConfig +) => + createFilterFieldConfig({ + 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 = 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, + config: TMultiSelectConfig, + iconConfig?: IFilterIconConfig +) => + createFilterFieldConfig({ + 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({ + 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({ + type: FILTER_FIELD_TYPE.DATE_RANGE, + ...DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG, + ...config, + }); diff --git a/packages/utils/src/rich-filters/factories/configs/index.ts b/packages/utils/src/rich-filters/factories/configs/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/factories/configs/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/factories/configs/shared.ts b/packages/utils/src/rich-filters/factories/configs/shared.ts new file mode 100644 index 000000000..8647ea5d6 --- /dev/null +++ b/packages/utils/src/rich-filters/factories/configs/shared.ts @@ -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

= (params: T) => TFilterConfig

; + +/** + * 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 = ( + config: T extends typeof FILTER_FIELD_TYPE.SINGLE_SELECT + ? TSingleSelectFilterFieldConfig + : T extends typeof FILTER_FIELD_TYPE.MULTI_SELECT + ? TMultiSelectFilterFieldConfig + : T extends typeof FILTER_FIELD_TYPE.DATE + ? TDateFilterFieldConfig + : T extends typeof FILTER_FIELD_TYPE.DATE_RANGE + ? TDateRangeFilterFieldConfig + : never +): TSupportedFilterFieldConfigs => config as TSupportedFilterFieldConfigs; + +/** + * 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 { + filterIcon?: React.FC>; + getOptionIcon?: (value: T) => React.ReactNode; +} + +/** + * Date filter config params + */ +export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig; + +// ------------ 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, +}; diff --git a/packages/utils/src/rich-filters/factories/index.ts b/packages/utils/src/rich-filters/factories/index.ts new file mode 100644 index 000000000..9518a7e35 --- /dev/null +++ b/packages/utils/src/rich-filters/factories/index.ts @@ -0,0 +1,3 @@ +export * from "./configs/core"; +export * from "./configs/shared"; +export * from "./nodes/core"; diff --git a/packages/utils/src/rich-filters/factories/nodes/core.ts b/packages/utils/src/rich-filters/factories/nodes/core.ts new file mode 100644 index 000000000..962ddddcd --- /dev/null +++ b/packages/utils/src/rich-filters/factories/nodes/core.ts @@ -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 =

( + condition: TFilterConditionPayload +): TFilterConditionNode => ({ + 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 =

( + nodes: TFilterExpression

[] +): TFilterAndGroupNode

=> ({ + id: uuidv4(), + type: FILTER_NODE_TYPE.GROUP, + logicalOperator: LOGICAL_OPERATOR.AND, + children: nodes, +}); diff --git a/packages/utils/src/rich-filters/index.ts b/packages/utils/src/rich-filters/index.ts new file mode 100644 index 000000000..ac68890e2 --- /dev/null +++ b/packages/utils/src/rich-filters/index.ts @@ -0,0 +1,6 @@ +export * from "./factories"; +export * from "./operations"; +export * from "./operators"; +export * from "./types"; +export * from "./validators"; +export * from "./values"; diff --git a/packages/utils/src/rich-filters/operations/comparison.ts b/packages/utils/src/rich-filters/operations/comparison.ts new file mode 100644 index 000000000..63d5ecb2e --- /dev/null +++ b/packages/utils/src/rich-filters/operations/comparison.ts @@ -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 =

(condition: TFilterConditionNode) => ({ + // 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 =

( + children: TFilterExpression

[], + baseComparable: Record +): Record => { + 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 =

( + group: TFilterGroupNode

+): Record => { + 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 =

( + expression: TFilterExpression

| null +): Record | 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 =

( + expression: TFilterExpression

| null +): TFilterExpression

| null => { + const result = transformExpressionTree

(expression, (node: TFilterExpression

) => { + // 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 =

( + expression1: TFilterExpression

| null, + expression2: TFilterExpression

| 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); +}; diff --git a/packages/utils/src/rich-filters/operations/index.ts b/packages/utils/src/rich-filters/operations/index.ts new file mode 100644 index 000000000..d362a4d5e --- /dev/null +++ b/packages/utils/src/rich-filters/operations/index.ts @@ -0,0 +1,4 @@ +export * from "./comparison"; +export * from "./manipulation/core"; +export * from "./transformation/core"; +export * from "./traversal/core"; diff --git a/packages/utils/src/rich-filters/operations/manipulation/core.ts b/packages/utils/src/rich-filters/operations/manipulation/core.ts new file mode 100644 index 000000000..22f6d662c --- /dev/null +++ b/packages/utils/src/rich-filters/operations/manipulation/core.ts @@ -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 =

( + expression: TFilterExpression

| null, + condition: TFilterExpression

+): TFilterExpression

=> { + // 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 =

( + expression: TFilterExpression

, + targetId: string, + replacement: TFilterExpression

+): TFilterExpression

=> { + const result = transformExpressionTree(expression, (node: TFilterExpression

) => { + // 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 =

( + expression: TFilterExpression

, + targetId: string, + updates: Partial> +) => { + // Helper function to recursively update nodes + const updateNode = (node: TFilterExpression

): void => { + if (node.id === targetId) { + if (!isConditionNode(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 =

( + group: TFilterGroupNode

, + preserveNotGroups = true +) => { + if (shouldUnwrapGroup(group, preserveNotGroups)) { + const children = getGroupChildren(group); + return children[0]; + } + return group; +}; diff --git a/packages/utils/src/rich-filters/operations/transformation/core.ts b/packages/utils/src/rich-filters/operations/transformation/core.ts new file mode 100644 index 000000000..d35168cf6 --- /dev/null +++ b/packages/utils/src/rich-filters/operations/transformation/core.ts @@ -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

= { + expression: TFilterExpression

| null; + shouldNotify?: boolean; +}; + +/** + * Transform function type for tree processing + */ +export type TTreeTransformFn

= (expression: TFilterExpression

) => TTreeTransformResult

; + +/** + * 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 =

( + groupExpression: TFilterGroupNode

| null, + shouldNotify: boolean +): TTreeTransformResult

=> ({ + 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 =

( + group: TFilterGroupNode

, + transformFn: TTreeTransformFn

+): TTreeTransformResult

=> { + const children = getGroupChildren(group); + const transformedChildren: TFilterExpression

[] = []; + 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

= { + ...group, + children: transformedChildren, + } as TFilterGroupNode

; + + 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 =

( + expression: TFilterExpression

| null, + transformFn: TTreeTransformFn

+): TTreeTransformResult

=> { + // 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 =

( + expression: TFilterExpression

, + targetId: string +): { expression: TFilterExpression

| 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 =

( + expression: TFilterExpression

| null +): TFilterExpression

| 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; +}; diff --git a/packages/utils/src/rich-filters/operations/transformation/shared.ts b/packages/utils/src/rich-filters/operations/transformation/shared.ts new file mode 100644 index 000000000..28da3990c --- /dev/null +++ b/packages/utils/src/rich-filters/operations/transformation/shared.ts @@ -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 =

( + group: TFilterGroupNode

, + transformFn: TTreeTransformFn

+): TTreeTransformResult

=> + processGroupNode(group, { + onAndGroup: (andGroup) => transformGroupWithChildren(andGroup, transformFn), + }); diff --git a/packages/utils/src/rich-filters/operations/traversal/core.ts b/packages/utils/src/rich-filters/operations/traversal/core.ts new file mode 100644 index 000000000..72b42100d --- /dev/null +++ b/packages/utils/src/rich-filters/operations/traversal/core.ts @@ -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

= ( + expression: TFilterExpression

, + parent?: TFilterGroupNode

, + 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 =

( + expression: TFilterExpression

| null, + visitor: TreeVisitorFn, + mode: TreeTraversalMode = TreeTraversalMode.ALL, + parent?: TFilterGroupNode

, + 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 =

( + expression: TFilterExpression

, + targetId: string +): TFilterExpression

| 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 =

( + expression: TFilterExpression

, + targetId: string, + currentPath: TFilterGroupNode

[] = [] +): TFilterGroupNode

[] | 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 =

( + expression: TFilterExpression

, + targetId: string +): TFilterGroupNode

| 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 =

( + expression: TFilterExpression

+): TFilterConditionNode[] => + traverseExpressionTree( + expression, + (node) => (isConditionNode(node) ? node : null), + TreeTraversalMode.CONDITIONS + ) as TFilterConditionNode[]; + +/** + * 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 =

( + expression: TFilterExpression

+): TFilterConditionNodeForDisplay[] => { + // 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 =

( + expression: TFilterExpression

, + property: P, + operator: TAllAvailableOperatorsForDisplay +): TFilterConditionNodeForDisplay[] => { + const conditions = extractConditionsWithDisplayOperators(expression); + return conditions.filter((condition) => condition.property === property && condition.operator === operator); +}; diff --git a/packages/utils/src/rich-filters/operations/traversal/shared.ts b/packages/utils/src/rich-filters/operations/traversal/shared.ts new file mode 100644 index 000000000..b3fcc3e99 --- /dev/null +++ b/packages/utils/src/rich-filters/operations/traversal/shared.ts @@ -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 =

( + operator: TSupportedOperators, + _expression: TFilterExpression

, + _conditionId: string +): TAllAvailableOperatorsForDisplay => + // Otherwise, return the operator as-is + operator; diff --git a/packages/utils/src/rich-filters/operators/core.ts b/packages/utils/src/rich-filters/operators/core.ts new file mode 100644 index 000000000..8d7384b98 --- /dev/null +++ b/packages/utils/src/rich-filters/operators/core.ts @@ -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 = ( + operator: TAllAvailableOperatorsForDisplay +): operator is TAllAvailableDateFilterOperatorsForDisplay => + Object.keys(DATE_OPERATOR_LABELS_MAP).includes(operator); diff --git a/packages/utils/src/rich-filters/operators/index.ts b/packages/utils/src/rich-filters/operators/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/operators/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/operators/shared.ts b/packages/utils/src/rich-filters/operators/shared.ts new file mode 100644 index 000000000..6923ddfcf --- /dev/null +++ b/packages/utils/src/rich-filters/operators/shared.ts @@ -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, + }; +}; diff --git a/packages/utils/src/rich-filters/types/core.ts b/packages/utils/src/rich-filters/types/core.ts new file mode 100644 index 000000000..696fe3796 --- /dev/null +++ b/packages/utils/src/rich-filters/types/core.ts @@ -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 =

( + node: TFilterExpression

+): node is TFilterConditionNode => 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 =

(node: TFilterExpression

): node is TFilterGroupNode

=> + 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 =

( + group: TFilterGroupNode

+): group is TFilterAndGroupNode

=> 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 =

( + group: TFilterGroupNode

+): group is TFilterAndGroupNode

=> { + 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 =

(group: TFilterAndGroupNode

): TFilterExpression

[] => + 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; diff --git a/packages/utils/src/rich-filters/types/index.ts b/packages/utils/src/rich-filters/types/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/types/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/types/shared.ts b/packages/utils/src/rich-filters/types/shared.ts new file mode 100644 index 000000000..a116fe8de --- /dev/null +++ b/packages/utils/src/rich-filters/types/shared.ts @@ -0,0 +1,35 @@ +// plane imports +import { TFilterAndGroupNode, TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types"; +// local imports +import { getAndGroupChildren, isAndGroupNode } from "./core"; + +type TProcessGroupNodeHandlers

= { + onAndGroup: (group: TFilterAndGroupNode

) => 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 =

( + group: TFilterGroupNode

, + handlers: TProcessGroupNodeHandlers +): 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 =

(group: TFilterGroupNode

): TFilterExpression

[] => + processGroupNode(group, { + onAndGroup: (andGroup) => getAndGroupChildren(andGroup), + }); diff --git a/packages/utils/src/rich-filters/validators/core.ts b/packages/utils/src/rich-filters/validators/core.ts new file mode 100644 index 000000000..9a9268bd8 --- /dev/null +++ b/packages/utils/src/rich-filters/validators/core.ts @@ -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): 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 =

( + expression: TFilterExpression

| 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; +}; diff --git a/packages/utils/src/rich-filters/validators/index.ts b/packages/utils/src/rich-filters/validators/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/validators/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/validators/shared.ts b/packages/utils/src/rich-filters/validators/shared.ts new file mode 100644 index 000000000..1df8e69ae --- /dev/null +++ b/packages/utils/src/rich-filters/validators/shared.ts @@ -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 =

(group: TFilterGroupNode

, _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; +}; diff --git a/packages/utils/src/rich-filters/values/core.ts b/packages/utils/src/rich-filters/values/core.ts new file mode 100644 index 000000000..a2a6b111f --- /dev/null +++ b/packages/utils/src/rich-filters/values/core.ts @@ -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 = (value: SingleOrArray): NonNullable[] => { + if (value === null || value === undefined) { + return []; + } + + return Array.isArray(value) ? (value as NonNullable[]) : ([value] as NonNullable[]); +}; + +/** + * Gets the length of a filter value + */ +export const getFilterValueLength = (value: SingleOrArray): number => { + if (value === null || value === undefined) { + return 0; + } + + return Array.isArray(value) ? value.length : 1; +}; diff --git a/packages/utils/src/rich-filters/values/index.ts b/packages/utils/src/rich-filters/values/index.ts new file mode 100644 index 000000000..8d119dee8 --- /dev/null +++ b/packages/utils/src/rich-filters/values/index.ts @@ -0,0 +1 @@ +export * from "./core"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e128b79ed..f782fccba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: '@types/react-dom': specifier: 18.3.1 version: 18.3.1 + '@types/uuid': + specifier: 9.0.8 + version: 9.0.8 axios: specifier: 1.12.0 version: 1.12.0 @@ -481,6 +484,9 @@ importers: '@plane/services': specifier: workspace:* version: link:../../packages/services + '@plane/shared-state': + specifier: workspace:* + version: link:../../packages/shared-state '@plane/types': specifier: workspace:* version: link:../../packages/types @@ -1095,9 +1101,27 @@ importers: packages/shared-state: 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: specifier: 'catalog:' version: 6.12.0 + mobx-utils: + specifier: 'catalog:' + version: 6.0.8(mobx@6.12.0) + uuid: + specifier: 'catalog:' + version: 10.0.0 zod: specifier: ^3.22.2 version: 3.25.76 @@ -1108,9 +1132,15 @@ importers: '@plane/typescript-config': specifier: workspace:* version: link:../typescript-config + '@types/lodash': + specifier: 'catalog:' + version: 4.17.20 '@types/node': specifier: ^22.5.4 version: 22.18.0 + '@types/uuid': + specifier: 'catalog:' + version: 9.0.8 typescript: specifier: 5.8.3 version: 5.8.3 @@ -1584,14 +1614,14 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/core@1.4.5': + resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.0.4': + resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -8401,9 +8431,9 @@ snapshots: '@date-fns/tz@1.4.1': {} - '@emnapi/core@1.5.0': + '@emnapi/core@1.4.5': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.0.4 tslib: 2.8.1 optional: true @@ -8412,7 +8442,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.0.4': dependencies: tslib: 2.8.1 optional: true @@ -8982,14 +9012,14 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.5.0 + '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.0 optional: true '@napi-rs/wasm-runtime@1.0.3': dependencies: - '@emnapi/core': 1.5.0 + '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.0 optional: true @@ -11137,7 +11167,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.5.3 + tslib: 2.8.1 camelcase-css@2.0.1: {} @@ -11148,7 +11178,7 @@ snapshots: capital-case@1.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 upper-case-first: 2.0.2 case-sensitive-paths-webpack-plugin@2.4.0: {} @@ -11190,7 +11220,7 @@ snapshots: path-case: 3.0.4 sentence-case: 3.0.4 snake-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 character-entities@2.0.2: {} @@ -11331,7 +11361,7 @@ snapshots: constant-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 upper-case: 2.0.2 constants-browserify@1.0.0: {} @@ -11659,7 +11689,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 dotenv@16.0.3: {} @@ -12509,7 +12539,7 @@ snapshots: header-case@2.0.4: dependencies: capital-case: 1.0.4 - tslib: 2.5.3 + tslib: 2.8.1 helmet@7.2.0: {} @@ -13049,7 +13079,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.5.3 + tslib: 2.8.1 lowlight@2.9.0: dependencies: @@ -13421,7 +13451,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.5.3 + tslib: 2.8.1 node-abort-controller@3.1.1: {} @@ -13604,7 +13634,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 parent-module@1.0.1: dependencies: @@ -13628,14 +13658,14 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 path-browserify@1.0.1: {} path-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 path-exists@4.0.0: {} @@ -14542,7 +14572,7 @@ snapshots: sentence-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 upper-case-first: 2.0.2 serialize-javascript@6.0.2: @@ -14663,7 +14693,7 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 sonic-boom@2.8.0: dependencies: @@ -15301,11 +15331,11 @@ snapshots: upper-case-first@2.0.2: dependencies: - tslib: 2.5.3 + tslib: 2.8.1 upper-case@2.0.2: dependencies: - tslib: 2.5.3 + tslib: 2.8.1 uri-js@4.4.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 168601be6..bda86ac05 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,6 +19,7 @@ catalog: react-dom: 18.3.1 "@types/react": 18.3.11 "@types/react-dom": 18.3.1 + "@types/uuid": 9.0.8 typescript: 5.8.3 tsdown: 0.14.2 uuid: 10.0.0