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

* feat: add rich filters types

* feat: add rich filters constants

* feat: add rich filters utils

* feat: add rich filters store in shared state package

* feat: add rich filters UI components

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

* chore: minor improvements to rich filters

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

View file

@ -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));

View file

@ -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";

View file

@ -0,0 +1,156 @@
// plane imports
import {
FILTER_FIELD_TYPE,
TFilterValue,
TFilterProperty,
TFilterConfig,
TSupportedOperators,
TBaseFilterFieldConfig,
} from "@plane/types";
// local imports
import {
createFilterFieldConfig,
DEFAULT_DATE_FILTER_TYPE_CONFIG,
DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG,
DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG,
DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG,
IFilterIconConfig,
} from "./shared";
/**
* Helper to create a type-safe filter config
* @param config - The filter config to create
* @returns The created filter config
*/
export const createFilterConfig = <P extends TFilterProperty, V extends TFilterValue>(
config: TFilterConfig<P, V>
): TFilterConfig<P, V> => config;
// ------------ Selection filters ------------
/**
* Options transformation interface for selection filters
*/
export interface TOptionTransforms<TItem, TValue extends TFilterValue = string, TIconData = undefined> {
items: TItem[];
getId: (item: TItem) => string;
getLabel: (item: TItem) => string;
getValue: (item: TItem) => TValue;
getIconData?: (item: TItem) => TIconData;
}
/**
* Single-select filter configuration
*/
export type TSingleSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
defaultValue?: TValue;
};
/**
* Helper to get the single select config
* @param transforms - How to transform items into options
* @param config - Single-select specific configuration
* @param iconConfig - Icon configuration for options
* @returns The single select config
*/
export const getSingleSelectConfig = <
TItem,
TValue extends TFilterValue = string,
TIconData extends string | number | boolean | object | undefined = undefined,
>(
transforms: TOptionTransforms<TItem, TValue, TIconData>,
config?: TSingleSelectConfig<TValue>,
iconConfig?: IFilterIconConfig<TIconData>
) =>
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.SINGLE_SELECT, TValue>({
type: FILTER_FIELD_TYPE.SINGLE_SELECT,
...DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG,
...config,
getOptions: () =>
transforms.items.map((item) => ({
id: transforms.getId(item),
label: transforms.getLabel(item),
value: transforms.getValue(item),
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
})),
});
/**
* Multi-select filter configuration
*/
export type TMultiSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
defaultValue?: TValue[];
singleValueOperator: TSupportedOperators;
};
/**
* Helper to get the multi select config
* @param transforms - How to transform items into options
* @param config - Multi-select specific configuration
* @param iconConfig - Icon configuration for options
* @returns The multi select config
*/
export const getMultiSelectConfig = <
TItem,
TValue extends TFilterValue = string,
TIconData extends string | number | boolean | object | undefined = undefined,
>(
transforms: TOptionTransforms<TItem, TValue, TIconData>,
config: TMultiSelectConfig<TValue>,
iconConfig?: IFilterIconConfig<TIconData>
) =>
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.MULTI_SELECT, TValue>({
type: FILTER_FIELD_TYPE.MULTI_SELECT,
...DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG,
...config,
operatorLabel: config?.operatorLabel,
getOptions: () =>
transforms.items.map((item) => ({
id: transforms.getId(item),
label: transforms.getLabel(item),
value: transforms.getValue(item),
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
})),
});
// ------------ Date filters ------------
/**
* Date filter configuration
*/
export type TDateConfig = TBaseFilterFieldConfig & {
min?: Date;
max?: Date;
};
/**
* Date range filter configuration
*/
export type TDateRangeConfig = TBaseFilterFieldConfig & {
min?: Date;
max?: Date;
};
/**
* Helper to get the date picker config
* @param config - Date-specific configuration
* @returns The date picker config
*/
export const getDatePickerConfig = (config?: TDateConfig) =>
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE, Date>({
type: FILTER_FIELD_TYPE.DATE,
...DEFAULT_DATE_FILTER_TYPE_CONFIG,
...config,
});
/**
* Helper to get the date range picker config
* @param config - Date range-specific configuration
* @returns The date range picker config
*/
export const getDateRangePickerConfig = (config?: TDateRangeConfig) =>
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE_RANGE, Date>({
type: FILTER_FIELD_TYPE.DATE_RANGE,
...DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG,
...config,
});

View file

@ -0,0 +1,2 @@
export * from "./core";
export * from "./shared";

View file

@ -0,0 +1,76 @@
import {
FILTER_FIELD_TYPE,
TBaseFilterFieldConfig,
TDateFilterFieldConfig,
TDateRangeFilterFieldConfig,
TFilterConfig,
TFilterProperty,
TFilterFieldType,
TFilterValue,
TMultiSelectFilterFieldConfig,
TSingleSelectFilterFieldConfig,
TSupportedFilterFieldConfigs,
} from "@plane/types";
/**
* Factory function signature for creating filter configurations.
*/
export type TCreateFilterConfig<P extends TFilterProperty, T> = (params: T) => TFilterConfig<P>;
/**
* Helper to create a type-safe filter field config
* @param config - The filter field config to create
* @returns The created filter field config
*/
export const createFilterFieldConfig = <T extends TFilterFieldType, V extends TFilterValue>(
config: T extends typeof FILTER_FIELD_TYPE.SINGLE_SELECT
? TSingleSelectFilterFieldConfig<V>
: T extends typeof FILTER_FIELD_TYPE.MULTI_SELECT
? TMultiSelectFilterFieldConfig<V>
: T extends typeof FILTER_FIELD_TYPE.DATE
? TDateFilterFieldConfig<V>
: T extends typeof FILTER_FIELD_TYPE.DATE_RANGE
? TDateRangeFilterFieldConfig<V>
: never
): TSupportedFilterFieldConfigs<V> => config as TSupportedFilterFieldConfigs<V>;
/**
* Base parameters for filter type config factory functions.
* - operator: The operator to use for the filter.
*/
export type TCreateFilterConfigParams = TBaseFilterFieldConfig & {
isEnabled: boolean;
};
/**
* Icon configuration for filters and their options.
* - filterIcon: Optional icon for the filter
* - getOptionIcon: Function to get icon for specific option values
*/
export interface IFilterIconConfig<T extends string | number | boolean | object | undefined = undefined> {
filterIcon?: React.FC<React.SVGAttributes<SVGElement>>;
getOptionIcon?: (value: T) => React.ReactNode;
}
/**
* Date filter config params
*/
export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig<Date>;
// ------------ Default filter type configs ------------
export const DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG = {
allowNegative: false,
};
export const DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG = {
allowNegative: false,
};
export const DEFAULT_DATE_FILTER_TYPE_CONFIG = {
allowNegative: false,
};
export const DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG = {
allowNegative: false,
};

View file

@ -0,0 +1,3 @@
export * from "./configs/core";
export * from "./configs/shared";
export * from "./nodes/core";

View file

@ -0,0 +1,39 @@
import { v4 as uuidv4 } from "uuid";
// plane imports
import {
FILTER_NODE_TYPE,
LOGICAL_OPERATOR,
TFilterAndGroupNode,
TFilterConditionNode,
TFilterConditionPayload,
TFilterExpression,
TFilterProperty,
TFilterValue,
} from "@plane/types";
/**
* Creates a condition node with a unique ID.
* @param condition - The condition to create
* @returns The created condition node
*/
export const createConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
condition: TFilterConditionPayload<P, V>
): TFilterConditionNode<P, V> => ({
id: uuidv4(),
type: FILTER_NODE_TYPE.CONDITION,
...condition,
});
/**
* Creates an AND group node with a unique ID.
* @param nodes - The nodes to add to the group
* @returns The created AND group node
*/
export const createAndGroupNode = <P extends TFilterProperty>(
nodes: TFilterExpression<P>[]
): TFilterAndGroupNode<P> => ({
id: uuidv4(),
type: FILTER_NODE_TYPE.GROUP,
logicalOperator: LOGICAL_OPERATOR.AND,
children: nodes,
});

View file

@ -0,0 +1,6 @@
export * from "./factories";
export * from "./operations";
export * from "./operators";
export * from "./types";
export * from "./validators";
export * from "./values";

View file

@ -0,0 +1,170 @@
import compact from "lodash/compact";
import isEqual from "lodash/isEqual";
import sortBy from "lodash/sortBy";
// plane imports
import {
FILTER_NODE_TYPE,
TFilterConditionNode,
TFilterExpression,
TFilterGroupNode,
TFilterProperty,
TFilterValue,
} from "@plane/types";
// local imports
import { isConditionNode, isGroupNode } from "../types/core";
import { processGroupNode } from "../types/shared";
import { hasValidValue } from "../validators/core";
import { transformExpressionTree } from "./transformation/core";
/**
* Creates a comparable representation of a condition for deep comparison.
* This uses property, operator, and value instead of ID for comparison.
* IDs are completely excluded to avoid UUID comparison issues.
* @param condition - The condition to create a comparable representation for
* @returns A comparable object without ID
*/
const createConditionComparable = <P extends TFilterProperty>(condition: TFilterConditionNode<P, TFilterValue>) => ({
// Explicitly exclude: id (random UUID should not be compared)
type: condition.type,
property: condition.property,
operator: condition.operator,
value: Array.isArray(condition.value) ? condition.value : [condition.value],
});
/**
* Helper function to create comparable children for AND/OR groups.
* This eliminates code duplication between AND and OR group processing.
*/
const createComparableChildren = <P extends TFilterProperty>(
children: TFilterExpression<P>[],
baseComparable: Record<string, unknown>
): Record<string, unknown> => {
const childrenComparable = compact(children.map((child) => createExpressionComparable(child)));
// Sort children by a consistent key for comparison to ensure order doesn't affect equality
const sortedChildren = sortBy(childrenComparable, (child) => {
if (child?.type === FILTER_NODE_TYPE.CONDITION) {
return `condition_${child.property}_${child.operator}_${JSON.stringify(child.value)}`;
}
// For nested groups, sort by logical operator and recursive structure
if (child?.type === FILTER_NODE_TYPE.GROUP) {
const childrenCount = child.child ? 1 : Array.isArray(child.children) ? child.children.length : 0;
return `group_${child.logicalOperator}_${childrenCount}_${JSON.stringify(child)}`;
}
return "unknown";
});
return {
...baseComparable,
children: sortedChildren,
};
};
/**
* Creates a comparable representation of a group for deep comparison.
* This recursively creates comparable representations for all children.
* IDs are completely excluded to avoid UUID comparison issues.
* Uses processGroupNode for consistent group type handling.
* @param group - The group to create a comparable representation for
* @returns A comparable object without ID
*/
export const createGroupComparable = <P extends TFilterProperty>(
group: TFilterGroupNode<P>
): Record<string, unknown> => {
const baseComparable = {
// Explicitly exclude: id (random UUID should not be compared)
type: group.type,
logicalOperator: group.logicalOperator,
};
return processGroupNode(group, {
onAndGroup: (andGroup) => createComparableChildren(andGroup.children, baseComparable),
});
};
/**
* Creates a comparable representation of any filter expression.
* Recursively handles deep nesting of groups within groups.
* Completely excludes IDs from comparison to avoid UUID issues.
* @param expression - The expression to create a comparable representation for
* @returns A comparable object without IDs or null if the expression is empty
*/
export const createExpressionComparable = <P extends TFilterProperty>(
expression: TFilterExpression<P> | null
): Record<string, unknown> | null => {
if (!expression) return null;
// Handle condition nodes - exclude ID completely
if (isConditionNode(expression)) {
return createConditionComparable(expression);
}
// Handle group nodes - exclude ID completely and support deep nesting
if (isGroupNode(expression)) {
return createGroupComparable(expression);
}
// Should never reach here with proper typing, but return null for safety
return null;
};
/**
* Normalizes a filter expression by removing empty conditions and groups.
* This helps compare expressions by focusing only on meaningful content.
* Uses the transformExpressionTree utility for consistent tree processing.
* @param expression - The filter expression to normalize
* @returns The normalized expression or null if the entire expression is empty
*/
export const normalizeFilterExpression = <P extends TFilterProperty>(
expression: TFilterExpression<P> | null
): TFilterExpression<P> | null => {
const result = transformExpressionTree<P>(expression, (node: TFilterExpression<P>) => {
// Only transform condition nodes - check if they have valid values
if (isConditionNode(node)) {
return {
expression: hasValidValue(node.value) ? node : null,
shouldNotify: false,
};
}
// For group nodes, let the generic transformer handle the recursion
return { expression: node, shouldNotify: false };
});
return result.expression;
};
/**
* Performs a deep comparison of two filter expressions based on their meaningful content.
* This comparison completely ignores IDs (UUIDs) and focuses on property, operator, value, and tree structure.
* Empty conditions and groups are normalized before comparison.
* Supports deep nesting of groups within groups recursively.
* @param expression1 - The first expression to compare
* @param expression2 - The second expression to compare
* @returns True if the expressions are meaningfully equal, false otherwise
*/
export const deepCompareFilterExpressions = <P extends TFilterProperty>(
expression1: TFilterExpression<P> | null,
expression2: TFilterExpression<P> | null
): boolean => {
// Normalize both expressions to remove empty conditions and groups
const normalized1 = normalizeFilterExpression(expression1);
const normalized2 = normalizeFilterExpression(expression2);
// If both are null after normalization, they're equal
if (!normalized1 && !normalized2) {
return true;
}
// If one is null and the other isn't, they're different
if (!normalized1 || !normalized2) {
return false;
}
// Create comparable representations (IDs completely excluded)
const comparable1 = createExpressionComparable(normalized1);
const comparable2 = createExpressionComparable(normalized2);
// Deep compare using lodash isEqual for reliable object comparison
// This handles deep nesting recursively and ignores UUID differences
return isEqual(comparable1, comparable2);
};

View file

@ -0,0 +1,4 @@
export * from "./comparison";
export * from "./manipulation/core";
export * from "./transformation/core";
export * from "./traversal/core";

View file

@ -0,0 +1,124 @@
// plane imports
import {
TFilterConditionPayload,
TFilterExpression,
TFilterGroupNode,
TFilterProperty,
TFilterValue,
} from "@plane/types";
// local imports
import { createAndGroupNode } from "../../factories/nodes/core";
import { getGroupChildren } from "../../types";
import { isAndGroupNode, isConditionNode, isGroupNode } from "../../types/core";
import { shouldUnwrapGroup } from "../../validators/shared";
import { transformExpressionTree } from "../transformation/core";
/**
* Adds an AND condition to the filter expression.
* @param expression - The current filter expression
* @param condition - The condition to add
* @returns The updated filter expression
*/
export const addAndCondition = <P extends TFilterProperty>(
expression: TFilterExpression<P> | null,
condition: TFilterExpression<P>
): TFilterExpression<P> => {
// if no expression, set the new condition
if (!expression) {
return condition;
}
// if the expression is a condition, convert it to an AND group
if (isConditionNode(expression)) {
return createAndGroupNode([expression, condition]);
}
// if the expression is a group, and the group is an AND group, add the new condition to the group
if (isGroupNode(expression) && isAndGroupNode(expression)) {
expression.children.push(condition);
return expression;
}
// if the expression is a group, but not an AND group, create a new AND group and add the new condition to it
if (isGroupNode(expression) && !isAndGroupNode(expression)) {
return createAndGroupNode([expression, condition]);
}
// Throw error for unexpected expression type
console.error("Invalid expression type", expression);
return expression;
};
/**
* Replaces a node in the expression tree with another node.
* Uses transformExpressionTree for consistent tree processing and better maintainability.
* @param expression - The expression tree to search in
* @param targetId - The ID of the node to replace
* @param replacement - The node to replace with
* @returns The updated expression tree
*/
export const replaceNodeInExpression = <P extends TFilterProperty>(
expression: TFilterExpression<P>,
targetId: string,
replacement: TFilterExpression<P>
): TFilterExpression<P> => {
const result = transformExpressionTree(expression, (node: TFilterExpression<P>) => {
// If this is the node we want to replace, return the replacement
if (node.id === targetId) {
return {
expression: replacement,
shouldNotify: false,
};
}
// For all other nodes, let the generic transformer handle the recursion
return { expression: node, shouldNotify: false };
});
// Since we're doing a replacement, the result should never be null
return result.expression || expression;
};
/**
* Updates a node in the filter expression.
* Uses recursive tree traversal with proper type handling.
* @param expression - The filter expression to update
* @param targetId - The id of the node to update
* @param updates - The updates to apply to the node
*/
export const updateNodeInExpression = <P extends TFilterProperty>(
expression: TFilterExpression<P>,
targetId: string,
updates: Partial<TFilterConditionPayload<P, TFilterValue>>
) => {
// Helper function to recursively update nodes
const updateNode = (node: TFilterExpression<P>): void => {
if (node.id === targetId) {
if (!isConditionNode<P, TFilterValue>(node)) {
console.warn("updateNodeInExpression: targetId matched a group; ignoring updates");
return;
}
Object.assign(node, updates);
return;
}
if (isGroupNode(node)) {
const children = getGroupChildren(node);
children.forEach((child) => updateNode(child));
}
};
updateNode(expression);
};
/**
* Unwraps a group if it meets the unwrapping criteria, otherwise returns the group.
* @param group - The group node to potentially unwrap
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
* @returns The unwrapped child or the original group
*/
export const unwrapGroupIfNeeded = <P extends TFilterProperty>(
group: TFilterGroupNode<P>,
preserveNotGroups = true
) => {
if (shouldUnwrapGroup(group, preserveNotGroups)) {
const children = getGroupChildren(group);
return children[0];
}
return group;
};

View file

@ -0,0 +1,178 @@
// plane imports
import { TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
// local imports
import { isConditionNode, isGroupNode } from "../../types/core";
import { getGroupChildren } from "../../types/shared";
import { hasValidValue } from "../../validators/core";
import { unwrapGroupIfNeeded } from "../manipulation/core";
import { transformGroup } from "./shared";
/**
* Generic tree transformation result type
*/
export type TTreeTransformResult<P extends TFilterProperty> = {
expression: TFilterExpression<P> | null;
shouldNotify?: boolean;
};
/**
* Transform function type for tree processing
*/
export type TTreeTransformFn<P extends TFilterProperty> = (expression: TFilterExpression<P>) => TTreeTransformResult<P>;
/**
* Generic recursive tree transformer that handles common tree manipulation logic.
* This function provides a reusable way to transform expression trees while maintaining
* tree integrity, handling group restructuring, and applying stabilization.
*
* @param expression - The expression to transform
* @param transformFn - Function that defines the transformation logic for each node
* @returns The transformation result with expression and metadata
*/
/**
* Helper function to create a consistent transformation result for group nodes.
* Centralizes the logic for wrapping group expressions and tracking notifications.
*/
const createGroupTransformResult = <P extends TFilterProperty>(
groupExpression: TFilterGroupNode<P> | null,
shouldNotify: boolean
): TTreeTransformResult<P> => ({
expression: groupExpression ? unwrapGroupIfNeeded(groupExpression, true) : null,
shouldNotify,
});
/**
* Transforms groups with children by processing all children.
* Handles child collection, null filtering, and empty group removal.
*/
export const transformGroupWithChildren = <P extends TFilterProperty>(
group: TFilterGroupNode<P>,
transformFn: TTreeTransformFn<P>
): TTreeTransformResult<P> => {
const children = getGroupChildren(group);
const transformedChildren: TFilterExpression<P>[] = [];
let shouldNotify = false;
// Transform all children and collect non-null results
for (const child of children) {
const childResult = transformExpressionTree(child, transformFn);
if (childResult.shouldNotify) {
shouldNotify = true;
}
if (childResult.expression !== null) {
transformedChildren.push(childResult.expression);
}
}
// If no children remain, remove the entire group
if (transformedChildren.length === 0) {
return { expression: null, shouldNotify };
}
// Create updated group with transformed children - type-safe without casting
const updatedGroup: TFilterGroupNode<P> = {
...group,
children: transformedChildren,
} as TFilterGroupNode<P>;
return createGroupTransformResult(updatedGroup, shouldNotify);
};
/**
* Generic recursive tree transformer that handles common tree manipulation logic.
* This function provides a reusable way to transform expression trees while maintaining
* tree integrity, handling group restructuring, and applying stabilization.
*
* @param expression - The expression to transform
* @param transformFn - Function that defines the transformation logic for each node
* @returns The transformation result with expression and metadata
*/
export const transformExpressionTree = <P extends TFilterProperty>(
expression: TFilterExpression<P> | null,
transformFn: TTreeTransformFn<P>
): TTreeTransformResult<P> => {
// Handle null expressions early
if (!expression) {
return { expression: null, shouldNotify: false };
}
// Apply the transformation function to the current node
const transformResult = transformFn(expression);
// If the transform function handled this node completely, return its result
if (transformResult.expression === null || transformResult.expression !== expression) {
return transformResult;
}
// Handle condition nodes (no children to transform)
if (isConditionNode(expression)) {
return { expression, shouldNotify: false };
}
// Handle group nodes by delegating to the extended transformGroup function
if (isGroupNode(expression)) {
return transformGroup(expression, transformFn);
}
throw new Error("Unknown expression type in transformExpressionTree");
};
/**
* Removes a node from the filter expression.
* @param expression - The filter expression to remove the node from
* @param targetId - The id of the node to remove
* @returns An object containing the updated filter expression and whether to notify about the change
*/
export const removeNodeFromExpression = <P extends TFilterProperty>(
expression: TFilterExpression<P>,
targetId: string
): { expression: TFilterExpression<P> | null; shouldNotify: boolean } => {
const result = transformExpressionTree(expression, (node) => {
// If this node matches the target ID, remove it
if (node.id === targetId) {
const shouldNotify = isConditionNode(node) ? hasValidValue(node.value) : true;
return {
expression: null,
shouldNotify,
};
}
// For all other nodes, let the generic transformer handle the recursion
return { expression: node, shouldNotify: false };
});
return {
expression: result.expression,
shouldNotify: result.shouldNotify || false,
};
};
/**
* Sanitizes and stabilizes a filter expression by removing invalid conditions and unnecessary groups.
* This function performs deep sanitization of the entire expression tree:
* 1. Removes condition nodes that don't have valid values
* 2. Removes empty groups (groups with no children after sanitization)
* 3. Unwraps single-child groups that don't need to be wrapped
* 4. Preserves tree integrity and logical operators
*
* @param expression - The filter expression to sanitize
* @returns The sanitized expression or null if no valid conditions remain
*/
export const sanitizeAndStabilizeExpression = <P extends TFilterProperty>(
expression: TFilterExpression<P> | null
): TFilterExpression<P> | null => {
const result = transformExpressionTree(expression, (node) => {
// Only transform condition nodes - check if they have valid values
if (isConditionNode(node)) {
return {
expression: hasValidValue(node.value) ? node : null,
shouldNotify: false,
};
}
// For group nodes, let the generic transformer handle the recursion
return { expression: node, shouldNotify: false };
});
return result.expression;
};

View file

@ -0,0 +1,18 @@
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
import { processGroupNode } from "../../types/shared";
import { transformGroupWithChildren, TTreeTransformFn, TTreeTransformResult } from "./core";
/**
* Transforms groups by processing children.
* Handles AND/OR groups with children and NOT groups with single child.
* @param group - The group to transform
* @param transformFn - The transformation function
* @returns The transformation result
*/
export const transformGroup = <P extends TFilterProperty>(
group: TFilterGroupNode<P>,
transformFn: TTreeTransformFn<P>
): TTreeTransformResult<P> =>
processGroupNode(group, {
onAndGroup: (andGroup) => transformGroupWithChildren(andGroup, transformFn),
});

View file

@ -0,0 +1,210 @@
// plane imports
import {
TAllAvailableOperatorsForDisplay,
TFilterConditionNode,
TFilterConditionNodeForDisplay,
TFilterExpression,
TFilterGroupNode,
TFilterProperty,
TFilterValue,
} from "@plane/types";
// local imports
import { isConditionNode, isGroupNode } from "../../types/core";
import { getGroupChildren } from "../../types/shared";
import { getDisplayOperator } from "./shared";
/**
* Generic tree visitor function type
*/
export type TreeVisitorFn<P extends TFilterProperty, T> = (
expression: TFilterExpression<P>,
parent?: TFilterGroupNode<P>,
depth?: number
) => T | null;
/**
* Tree traversal modes
*/
export enum TreeTraversalMode {
/** Visit all nodes depth-first */
ALL = "ALL",
/** Visit only condition nodes */
CONDITIONS = "CONDITIONS",
/** Visit only group nodes */
GROUPS = "GROUPS",
}
/**
* Generic tree traversal utility that visits nodes based on the specified mode.
* This eliminates code duplication in tree walking functions.
*
* @param expression - The expression to traverse
* @param visitor - Function to call for each visited node
* @param mode - Traversal mode to determine which nodes to visit
* @param parent - Parent node (used internally for recursion)
* @param depth - Current depth (used internally for recursion)
* @returns Array of results from the visitor function (nulls are filtered out)
*/
export const traverseExpressionTree = <P extends TFilterProperty, T>(
expression: TFilterExpression<P> | null,
visitor: TreeVisitorFn<P, T>,
mode: TreeTraversalMode = TreeTraversalMode.ALL,
parent?: TFilterGroupNode<P>,
depth: number = 0
): T[] => {
if (!expression) return [];
const results: T[] = [];
// Determine if we should visit this node based on the mode
const shouldVisit =
mode === TreeTraversalMode.ALL ||
(mode === TreeTraversalMode.CONDITIONS && isConditionNode(expression)) ||
(mode === TreeTraversalMode.GROUPS && isGroupNode(expression));
if (shouldVisit) {
const result = visitor(expression, parent, depth);
if (result !== null) {
results.push(result);
}
}
// Recursively traverse children for group nodes
if (isGroupNode(expression)) {
const children = getGroupChildren(expression);
for (const child of children) {
const childResults = traverseExpressionTree(child, visitor, mode, expression, depth + 1);
results.push(...childResults);
}
}
return results;
};
/**
* Finds a node by its ID in the filter expression tree.
* Uses the generic tree traversal utility for better maintainability.
* @param expression - The filter expression to search in
* @param targetId - The ID of the node to find
* @returns The found node or null if not found
*/
export const findNodeById = <P extends TFilterProperty>(
expression: TFilterExpression<P>,
targetId: string
): TFilterExpression<P> | null => {
const results = traverseExpressionTree(
expression,
(node) => (node.id === targetId ? node : null),
TreeTraversalMode.ALL
);
// Return the first match (there should only be one with unique IDs)
return results.length > 0 ? results[0] : null;
};
/**
* Finds the parent chain of a given node ID in the filter expression tree.
* @param expression - The filter expression to search in
* @param targetId - The ID of the node whose parent chain to find
* @param currentPath - Current path of parent nodes (used internally for recursion)
* @returns Array of parent nodes from immediate parent to root, or null if not found
*/
export const findParentChain = <P extends TFilterProperty>(
expression: TFilterExpression<P>,
targetId: string,
currentPath: TFilterGroupNode<P>[] = []
): TFilterGroupNode<P>[] | null => {
// if the expression is a group, search in the children
if (isGroupNode(expression)) {
const children = getGroupChildren(expression);
// check if any direct child has the target ID
for (const child of children) {
if (child.id === targetId) {
return [expression, ...currentPath];
}
}
// recursively search in child groups
for (const child of children) {
if (isGroupNode(child)) {
const chain = findParentChain(child, targetId, [expression, ...currentPath]);
if (chain) return chain;
}
}
}
return null;
};
/**
* Finds the immediate parent node of a given node ID.
* @param expression - The filter expression to find parent in
* @param targetId - The ID of the node whose parent to find
* @returns The immediate parent node or null if not found or if the target is the root
*/
export const findImmediateParent = <P extends TFilterProperty>(
expression: TFilterExpression<P>,
targetId: string
): TFilterGroupNode<P> | null => {
// if the expression is null, return null
if (!expression) return null;
// find the parent chain
const parentChain = findParentChain(expression, targetId);
// return the immediate parent if it exists
return parentChain && parentChain.length > 0 ? parentChain[0] : null;
};
/**
* Extracts all conditions from a filter expression.
* Uses the generic tree traversal utility for better maintainability and consistency.
* @param expression - The filter expression to extract conditions from
* @returns An array of filter conditions
*/
export const extractConditions = <P extends TFilterProperty>(
expression: TFilterExpression<P>
): TFilterConditionNode<P, TFilterValue>[] =>
traverseExpressionTree(
expression,
(node) => (isConditionNode(node) ? node : null),
TreeTraversalMode.CONDITIONS
) as TFilterConditionNode<P, TFilterValue>[];
/**
* Extracts all conditions from a filter expression, including their display operators.
* @param expression - The filter expression to extract conditions from
* @returns An array of filter conditions with their display operators
*/
export const extractConditionsWithDisplayOperators = <P extends TFilterProperty>(
expression: TFilterExpression<P>
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
// First extract all raw conditions
const rawConditions = extractConditions(expression);
// Transform operators using the extended helper
return rawConditions.map((condition) => {
const displayOperator = getDisplayOperator(condition.operator, expression, condition.id);
return {
...condition,
operator: displayOperator,
};
});
};
/**
* Finds all conditions by property and operator.
* @param expression - The filter expression to search in
* @param property - The property to find the conditions by
* @param operator - The operator to find the conditions by
* @returns An array of conditions that match the property and operator
*/
export const findConditionsByPropertyAndOperator = <P extends TFilterProperty>(
expression: TFilterExpression<P>,
property: P,
operator: TAllAvailableOperatorsForDisplay
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
const conditions = extractConditionsWithDisplayOperators(expression);
return conditions.filter((condition) => condition.property === property && condition.operator === operator);
};

View file

@ -0,0 +1,23 @@
// plane imports
import {
TAllAvailableOperatorsForDisplay,
TFilterExpression,
TFilterProperty,
TSupportedOperators,
} from "@plane/types";
/**
* Helper function to get the display operator for a condition.
* This checks for NOT group context and applies negation if needed.
* @param operator - The original operator
* @param expression - The filter expression
* @param conditionId - The ID of the condition
* @returns The display operator (possibly negated)
*/
export const getDisplayOperator = <P extends TFilterProperty>(
operator: TSupportedOperators,
_expression: TFilterExpression<P>,
_conditionId: string
): TAllAvailableOperatorsForDisplay =>
// Otherwise, return the operator as-is
operator;

View file

@ -0,0 +1,42 @@
import get from "lodash/get";
// plane imports
import { DATE_OPERATOR_LABELS_MAP, EMPTY_OPERATOR_LABEL, OPERATOR_LABELS_MAP } from "@plane/constants";
import {
TAllAvailableOperatorsForDisplay,
TFilterValue,
TAllAvailableDateFilterOperatorsForDisplay,
} from "@plane/types";
// -------- OPERATOR LABEL UTILITIES --------
/**
* Get the label for a filter operator
* @param operator - The operator to get the label for
* @returns The label for the operator
*/
export const getOperatorLabel = (operator: TAllAvailableOperatorsForDisplay | undefined): string => {
if (!operator) return EMPTY_OPERATOR_LABEL;
return get(OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
};
/**
* Get the label for a date filter operator
* @param operator - The operator to get the label for
* @returns The label for the operator
*/
export const getDateOperatorLabel = (operator: TAllAvailableDateFilterOperatorsForDisplay | undefined): string => {
if (!operator) return EMPTY_OPERATOR_LABEL;
return get(DATE_OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
};
// -------- OPERATOR TYPE GUARDS --------
/**
* Type guard to check if an operator supports date filter types.
* @param operator - The operator to check
* @returns True if the operator supports date filters
*/
export const isDateFilterOperator = <V extends TFilterValue = TFilterValue>(
operator: TAllAvailableOperatorsForDisplay
): operator is TAllAvailableDateFilterOperatorsForDisplay<V> =>
Object.keys(DATE_OPERATOR_LABELS_MAP).includes(operator);

View file

@ -0,0 +1,2 @@
export * from "./core";
export * from "./shared";

View file

@ -0,0 +1,24 @@
import { TAllAvailableOperatorsForDisplay, TSupportedOperators } from "@plane/types";
/**
* Result type for operator conversion
*/
export type TOperatorForPayload = {
operator: TSupportedOperators;
isNegation: boolean;
};
/**
* Converts a display operator to the format needed for supported by filter expression condition.
* @param displayOperator - The operator from the UI
* @returns Object with supported operator and negation flag
*/
export const getOperatorForPayload = (displayOperator: TAllAvailableOperatorsForDisplay): TOperatorForPayload => {
const isNegation = false;
const operator = displayOperator;
return {
operator,
isNegation,
};
};

View file

@ -0,0 +1,68 @@
import {
FILTER_FIELD_TYPE,
FILTER_NODE_TYPE,
LOGICAL_OPERATOR,
TFilterAndGroupNode,
TFilterConditionNode,
TFilterExpression,
TFilterFieldType,
TFilterGroupNode,
TFilterProperty,
TFilterValue,
} from "@plane/types";
/**
* Type guard to check if a node is a condition node.
* @param node - The node to check
* @returns True if the node is a condition node
*/
export const isConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
node: TFilterExpression<P>
): node is TFilterConditionNode<P, V> => node.type === FILTER_NODE_TYPE.CONDITION;
/**
* Type guard to check if a node is a group node.
* @param node - The node to check
* @returns True if the node is a group node
*/
export const isGroupNode = <P extends TFilterProperty>(node: TFilterExpression<P>): node is TFilterGroupNode<P> =>
node.type === FILTER_NODE_TYPE.GROUP;
/**
* Type guard to check if a group node is an AND group.
* @param group - The group node to check
* @returns True if the group is an AND group
*/
export const isAndGroupNode = <P extends TFilterProperty>(
group: TFilterGroupNode<P>
): group is TFilterAndGroupNode<P> => group.logicalOperator === LOGICAL_OPERATOR.AND;
/**
* Type guard to check if a group node has children property
* @param group - The group node to check
* @returns True if the group has children property
*/
export const hasChildrenProperty = <P extends TFilterProperty>(
group: TFilterGroupNode<P>
): group is TFilterAndGroupNode<P> => {
const groupWithChildren = group as { children?: unknown };
return "children" in group && Array.isArray(groupWithChildren.children);
};
/**
* Safely gets the children array from an AND group node.
* @param group - The AND group node
* @returns The children array
*/
export const getAndGroupChildren = <P extends TFilterProperty>(group: TFilterAndGroupNode<P>): TFilterExpression<P>[] =>
group.children;
/**
* Type guard to check if a filter type is a date filter type.
* @param type - The filter type to check
* @returns True if the filter type is a date filter type
*/
export const isDateFilterType = (
type: TFilterFieldType
): type is typeof FILTER_FIELD_TYPE.DATE | typeof FILTER_FIELD_TYPE.DATE_RANGE =>
type === FILTER_FIELD_TYPE.DATE || type === FILTER_FIELD_TYPE.DATE_RANGE;

View file

@ -0,0 +1,2 @@
export * from "./core";
export * from "./shared";

View file

@ -0,0 +1,35 @@
// plane imports
import { TFilterAndGroupNode, TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
// local imports
import { getAndGroupChildren, isAndGroupNode } from "./core";
type TProcessGroupNodeHandlers<P extends TFilterProperty, T> = {
onAndGroup: (group: TFilterAndGroupNode<P>) => T;
};
/**
* Generic helper to process group nodes with type-safe handlers.
* @param group - The group node to process
* @param handlers - Object with handlers for each group type
* @returns Result of the appropriate handler
*/
export const processGroupNode = <P extends TFilterProperty, T>(
group: TFilterGroupNode<P>,
handlers: TProcessGroupNodeHandlers<P, T>
): T => {
if (isAndGroupNode(group)) {
return handlers.onAndGroup(group);
}
throw new Error(`Invalid group node: unknown logical operator ${group}`);
};
/**
* Gets the children of a group node, handling AND/OR groups (children array) and NOT groups (single child).
* Uses processGroupNode for consistent group type handling.
* @param group - The group node to get children from
* @returns Array of child expressions
*/
export const getGroupChildren = <P extends TFilterProperty>(group: TFilterGroupNode<P>): TFilterExpression<P>[] =>
processGroupNode(group, {
onAndGroup: (andGroup) => getAndGroupChildren(andGroup),
});

View file

@ -0,0 +1,52 @@
// plane imports
import { SingleOrArray, TFilterExpression, TFilterProperty, TFilterValue } from "@plane/types";
// local imports
import { getGroupChildren } from "../types";
import { isConditionNode, isGroupNode } from "../types/core";
/**
* Determines whether to notify about a change based on the filter value.
* @param value - The filter value to check
* @returns True if we should notify, false otherwise
*/
export const hasValidValue = (value: SingleOrArray<TFilterValue>): boolean => {
if (value === null || value === undefined) {
return false;
}
// If it's an array, check if it's empty or contains only null/undefined values
if (Array.isArray(value)) {
if (value.length === 0) {
return false;
}
return value.some((v) => v !== null && v !== undefined);
}
return true;
};
/**
* Determines whether to notify about a change based on the entire filter expression.
* @param expression - The filter expression to check
* @returns True if we should notify, false otherwise
*/
export const shouldNotifyChangeForExpression = <P extends TFilterProperty>(
expression: TFilterExpression<P> | null
): boolean => {
if (!expression) {
return false;
}
// If it's a condition, check its value
if (isConditionNode(expression)) {
return hasValidValue(expression.value);
}
// If it's a group, check if any of its children have meaningful values
if (isGroupNode(expression)) {
const children = getGroupChildren(expression);
return children.some((child) => shouldNotifyChangeForExpression(child));
}
return false;
};

View file

@ -0,0 +1,2 @@
export * from "./core";
export * from "./shared";

View file

@ -0,0 +1,22 @@
// plane imports
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
// local imports
import { getGroupChildren } from "../types/shared";
/**
* Determines if a group should be unwrapped based on the number of children and group type.
* @param group - The group node to check
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
* @returns True if the group should be unwrapped, false otherwise
*/
export const shouldUnwrapGroup = <P extends TFilterProperty>(group: TFilterGroupNode<P>, _preserveNotGroups = true) => {
const children = getGroupChildren(group);
// Never unwrap groups with multiple children
if (children.length !== 1) {
return false;
}
// Unwrap AND/OR groups with single children, and NOT groups if preserveNotGroups is false
return true;
};

View file

@ -0,0 +1,24 @@
import type { SingleOrArray, TFilterValue } from "@plane/types";
/**
* Converts any value to a non-null array for UI components that expect arrays
* Returns empty array for null/undefined values
*/
export const toFilterArray = <V extends TFilterValue>(value: SingleOrArray<V>): NonNullable<V>[] => {
if (value === null || value === undefined) {
return [];
}
return Array.isArray(value) ? (value as NonNullable<V>[]) : ([value] as NonNullable<V>[]);
};
/**
* Gets the length of a filter value
*/
export const getFilterValueLength = <V extends TFilterValue>(value: SingleOrArray<V>): number => {
if (value === null || value === undefined) {
return 0;
}
return Array.isArray(value) ? value.length : 1;
};

View file

@ -0,0 +1 @@
export * from "./core";