[WEB-4885] feat: new filters architecture and UI components (#7802)
* feat: add rich filters types * feat: add rich filters constants * feat: add rich filters utils * feat: add rich filters store in shared state package * feat: add rich filters UI components * fix: make setLoading optional in loadOptions function for improved flexibility * chore: minor improvements to rich filters * fix: formatting
This commit is contained in:
parent
00e070b509
commit
d521eab22f
83 changed files with 4345 additions and 117 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
156
packages/utils/src/rich-filters/factories/configs/core.ts
Normal file
156
packages/utils/src/rich-filters/factories/configs/core.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// plane imports
|
||||
import {
|
||||
FILTER_FIELD_TYPE,
|
||||
TFilterValue,
|
||||
TFilterProperty,
|
||||
TFilterConfig,
|
||||
TSupportedOperators,
|
||||
TBaseFilterFieldConfig,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import {
|
||||
createFilterFieldConfig,
|
||||
DEFAULT_DATE_FILTER_TYPE_CONFIG,
|
||||
DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG,
|
||||
DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG,
|
||||
DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG,
|
||||
IFilterIconConfig,
|
||||
} from "./shared";
|
||||
|
||||
/**
|
||||
* Helper to create a type-safe filter config
|
||||
* @param config - The filter config to create
|
||||
* @returns The created filter config
|
||||
*/
|
||||
export const createFilterConfig = <P extends TFilterProperty, V extends TFilterValue>(
|
||||
config: TFilterConfig<P, V>
|
||||
): TFilterConfig<P, V> => config;
|
||||
|
||||
// ------------ Selection filters ------------
|
||||
|
||||
/**
|
||||
* Options transformation interface for selection filters
|
||||
*/
|
||||
export interface TOptionTransforms<TItem, TValue extends TFilterValue = string, TIconData = undefined> {
|
||||
items: TItem[];
|
||||
getId: (item: TItem) => string;
|
||||
getLabel: (item: TItem) => string;
|
||||
getValue: (item: TItem) => TValue;
|
||||
getIconData?: (item: TItem) => TIconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-select filter configuration
|
||||
*/
|
||||
export type TSingleSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
|
||||
defaultValue?: TValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the single select config
|
||||
* @param transforms - How to transform items into options
|
||||
* @param config - Single-select specific configuration
|
||||
* @param iconConfig - Icon configuration for options
|
||||
* @returns The single select config
|
||||
*/
|
||||
export const getSingleSelectConfig = <
|
||||
TItem,
|
||||
TValue extends TFilterValue = string,
|
||||
TIconData extends string | number | boolean | object | undefined = undefined,
|
||||
>(
|
||||
transforms: TOptionTransforms<TItem, TValue, TIconData>,
|
||||
config?: TSingleSelectConfig<TValue>,
|
||||
iconConfig?: IFilterIconConfig<TIconData>
|
||||
) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.SINGLE_SELECT, TValue>({
|
||||
type: FILTER_FIELD_TYPE.SINGLE_SELECT,
|
||||
...DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG,
|
||||
...config,
|
||||
getOptions: () =>
|
||||
transforms.items.map((item) => ({
|
||||
id: transforms.getId(item),
|
||||
label: transforms.getLabel(item),
|
||||
value: transforms.getValue(item),
|
||||
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Multi-select filter configuration
|
||||
*/
|
||||
export type TMultiSelectConfig<TValue extends TFilterValue = string> = TBaseFilterFieldConfig & {
|
||||
defaultValue?: TValue[];
|
||||
singleValueOperator: TSupportedOperators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the multi select config
|
||||
* @param transforms - How to transform items into options
|
||||
* @param config - Multi-select specific configuration
|
||||
* @param iconConfig - Icon configuration for options
|
||||
* @returns The multi select config
|
||||
*/
|
||||
export const getMultiSelectConfig = <
|
||||
TItem,
|
||||
TValue extends TFilterValue = string,
|
||||
TIconData extends string | number | boolean | object | undefined = undefined,
|
||||
>(
|
||||
transforms: TOptionTransforms<TItem, TValue, TIconData>,
|
||||
config: TMultiSelectConfig<TValue>,
|
||||
iconConfig?: IFilterIconConfig<TIconData>
|
||||
) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.MULTI_SELECT, TValue>({
|
||||
type: FILTER_FIELD_TYPE.MULTI_SELECT,
|
||||
...DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG,
|
||||
...config,
|
||||
operatorLabel: config?.operatorLabel,
|
||||
getOptions: () =>
|
||||
transforms.items.map((item) => ({
|
||||
id: transforms.getId(item),
|
||||
label: transforms.getLabel(item),
|
||||
value: transforms.getValue(item),
|
||||
icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData),
|
||||
})),
|
||||
});
|
||||
|
||||
// ------------ Date filters ------------
|
||||
|
||||
/**
|
||||
* Date filter configuration
|
||||
*/
|
||||
export type TDateConfig = TBaseFilterFieldConfig & {
|
||||
min?: Date;
|
||||
max?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Date range filter configuration
|
||||
*/
|
||||
export type TDateRangeConfig = TBaseFilterFieldConfig & {
|
||||
min?: Date;
|
||||
max?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the date picker config
|
||||
* @param config - Date-specific configuration
|
||||
* @returns The date picker config
|
||||
*/
|
||||
export const getDatePickerConfig = (config?: TDateConfig) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE, Date>({
|
||||
type: FILTER_FIELD_TYPE.DATE,
|
||||
...DEFAULT_DATE_FILTER_TYPE_CONFIG,
|
||||
...config,
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to get the date range picker config
|
||||
* @param config - Date range-specific configuration
|
||||
* @returns The date range picker config
|
||||
*/
|
||||
export const getDateRangePickerConfig = (config?: TDateRangeConfig) =>
|
||||
createFilterFieldConfig<typeof FILTER_FIELD_TYPE.DATE_RANGE, Date>({
|
||||
type: FILTER_FIELD_TYPE.DATE_RANGE,
|
||||
...DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG,
|
||||
...config,
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./core";
|
||||
export * from "./shared";
|
||||
76
packages/utils/src/rich-filters/factories/configs/shared.ts
Normal file
76
packages/utils/src/rich-filters/factories/configs/shared.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
FILTER_FIELD_TYPE,
|
||||
TBaseFilterFieldConfig,
|
||||
TDateFilterFieldConfig,
|
||||
TDateRangeFilterFieldConfig,
|
||||
TFilterConfig,
|
||||
TFilterProperty,
|
||||
TFilterFieldType,
|
||||
TFilterValue,
|
||||
TMultiSelectFilterFieldConfig,
|
||||
TSingleSelectFilterFieldConfig,
|
||||
TSupportedFilterFieldConfigs,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Factory function signature for creating filter configurations.
|
||||
*/
|
||||
export type TCreateFilterConfig<P extends TFilterProperty, T> = (params: T) => TFilterConfig<P>;
|
||||
|
||||
/**
|
||||
* Helper to create a type-safe filter field config
|
||||
* @param config - The filter field config to create
|
||||
* @returns The created filter field config
|
||||
*/
|
||||
export const createFilterFieldConfig = <T extends TFilterFieldType, V extends TFilterValue>(
|
||||
config: T extends typeof FILTER_FIELD_TYPE.SINGLE_SELECT
|
||||
? TSingleSelectFilterFieldConfig<V>
|
||||
: T extends typeof FILTER_FIELD_TYPE.MULTI_SELECT
|
||||
? TMultiSelectFilterFieldConfig<V>
|
||||
: T extends typeof FILTER_FIELD_TYPE.DATE
|
||||
? TDateFilterFieldConfig<V>
|
||||
: T extends typeof FILTER_FIELD_TYPE.DATE_RANGE
|
||||
? TDateRangeFilterFieldConfig<V>
|
||||
: never
|
||||
): TSupportedFilterFieldConfigs<V> => config as TSupportedFilterFieldConfigs<V>;
|
||||
|
||||
/**
|
||||
* Base parameters for filter type config factory functions.
|
||||
* - operator: The operator to use for the filter.
|
||||
*/
|
||||
export type TCreateFilterConfigParams = TBaseFilterFieldConfig & {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Icon configuration for filters and their options.
|
||||
* - filterIcon: Optional icon for the filter
|
||||
* - getOptionIcon: Function to get icon for specific option values
|
||||
*/
|
||||
export interface IFilterIconConfig<T extends string | number | boolean | object | undefined = undefined> {
|
||||
filterIcon?: React.FC<React.SVGAttributes<SVGElement>>;
|
||||
getOptionIcon?: (value: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date filter config params
|
||||
*/
|
||||
export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig<Date>;
|
||||
|
||||
// ------------ Default filter type configs ------------
|
||||
|
||||
export const DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG = {
|
||||
allowNegative: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG = {
|
||||
allowNegative: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_DATE_FILTER_TYPE_CONFIG = {
|
||||
allowNegative: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG = {
|
||||
allowNegative: false,
|
||||
};
|
||||
3
packages/utils/src/rich-filters/factories/index.ts
Normal file
3
packages/utils/src/rich-filters/factories/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./configs/core";
|
||||
export * from "./configs/shared";
|
||||
export * from "./nodes/core";
|
||||
39
packages/utils/src/rich-filters/factories/nodes/core.ts
Normal file
39
packages/utils/src/rich-filters/factories/nodes/core.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane imports
|
||||
import {
|
||||
FILTER_NODE_TYPE,
|
||||
LOGICAL_OPERATOR,
|
||||
TFilterAndGroupNode,
|
||||
TFilterConditionNode,
|
||||
TFilterConditionPayload,
|
||||
TFilterExpression,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Creates a condition node with a unique ID.
|
||||
* @param condition - The condition to create
|
||||
* @returns The created condition node
|
||||
*/
|
||||
export const createConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
|
||||
condition: TFilterConditionPayload<P, V>
|
||||
): TFilterConditionNode<P, V> => ({
|
||||
id: uuidv4(),
|
||||
type: FILTER_NODE_TYPE.CONDITION,
|
||||
...condition,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates an AND group node with a unique ID.
|
||||
* @param nodes - The nodes to add to the group
|
||||
* @returns The created AND group node
|
||||
*/
|
||||
export const createAndGroupNode = <P extends TFilterProperty>(
|
||||
nodes: TFilterExpression<P>[]
|
||||
): TFilterAndGroupNode<P> => ({
|
||||
id: uuidv4(),
|
||||
type: FILTER_NODE_TYPE.GROUP,
|
||||
logicalOperator: LOGICAL_OPERATOR.AND,
|
||||
children: nodes,
|
||||
});
|
||||
6
packages/utils/src/rich-filters/index.ts
Normal file
6
packages/utils/src/rich-filters/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from "./factories";
|
||||
export * from "./operations";
|
||||
export * from "./operators";
|
||||
export * from "./types";
|
||||
export * from "./validators";
|
||||
export * from "./values";
|
||||
170
packages/utils/src/rich-filters/operations/comparison.ts
Normal file
170
packages/utils/src/rich-filters/operations/comparison.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import compact from "lodash/compact";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import sortBy from "lodash/sortBy";
|
||||
// plane imports
|
||||
import {
|
||||
FILTER_NODE_TYPE,
|
||||
TFilterConditionNode,
|
||||
TFilterExpression,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { isConditionNode, isGroupNode } from "../types/core";
|
||||
import { processGroupNode } from "../types/shared";
|
||||
import { hasValidValue } from "../validators/core";
|
||||
import { transformExpressionTree } from "./transformation/core";
|
||||
|
||||
/**
|
||||
* Creates a comparable representation of a condition for deep comparison.
|
||||
* This uses property, operator, and value instead of ID for comparison.
|
||||
* IDs are completely excluded to avoid UUID comparison issues.
|
||||
* @param condition - The condition to create a comparable representation for
|
||||
* @returns A comparable object without ID
|
||||
*/
|
||||
const createConditionComparable = <P extends TFilterProperty>(condition: TFilterConditionNode<P, TFilterValue>) => ({
|
||||
// Explicitly exclude: id (random UUID should not be compared)
|
||||
type: condition.type,
|
||||
property: condition.property,
|
||||
operator: condition.operator,
|
||||
value: Array.isArray(condition.value) ? condition.value : [condition.value],
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create comparable children for AND/OR groups.
|
||||
* This eliminates code duplication between AND and OR group processing.
|
||||
*/
|
||||
const createComparableChildren = <P extends TFilterProperty>(
|
||||
children: TFilterExpression<P>[],
|
||||
baseComparable: Record<string, unknown>
|
||||
): Record<string, unknown> => {
|
||||
const childrenComparable = compact(children.map((child) => createExpressionComparable(child)));
|
||||
|
||||
// Sort children by a consistent key for comparison to ensure order doesn't affect equality
|
||||
const sortedChildren = sortBy(childrenComparable, (child) => {
|
||||
if (child?.type === FILTER_NODE_TYPE.CONDITION) {
|
||||
return `condition_${child.property}_${child.operator}_${JSON.stringify(child.value)}`;
|
||||
}
|
||||
// For nested groups, sort by logical operator and recursive structure
|
||||
if (child?.type === FILTER_NODE_TYPE.GROUP) {
|
||||
const childrenCount = child.child ? 1 : Array.isArray(child.children) ? child.children.length : 0;
|
||||
return `group_${child.logicalOperator}_${childrenCount}_${JSON.stringify(child)}`;
|
||||
}
|
||||
return "unknown";
|
||||
});
|
||||
|
||||
return {
|
||||
...baseComparable,
|
||||
children: sortedChildren,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a comparable representation of a group for deep comparison.
|
||||
* This recursively creates comparable representations for all children.
|
||||
* IDs are completely excluded to avoid UUID comparison issues.
|
||||
* Uses processGroupNode for consistent group type handling.
|
||||
* @param group - The group to create a comparable representation for
|
||||
* @returns A comparable object without ID
|
||||
*/
|
||||
export const createGroupComparable = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>
|
||||
): Record<string, unknown> => {
|
||||
const baseComparable = {
|
||||
// Explicitly exclude: id (random UUID should not be compared)
|
||||
type: group.type,
|
||||
logicalOperator: group.logicalOperator,
|
||||
};
|
||||
|
||||
return processGroupNode(group, {
|
||||
onAndGroup: (andGroup) => createComparableChildren(andGroup.children, baseComparable),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a comparable representation of any filter expression.
|
||||
* Recursively handles deep nesting of groups within groups.
|
||||
* Completely excludes IDs from comparison to avoid UUID issues.
|
||||
* @param expression - The expression to create a comparable representation for
|
||||
* @returns A comparable object without IDs or null if the expression is empty
|
||||
*/
|
||||
export const createExpressionComparable = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): Record<string, unknown> | null => {
|
||||
if (!expression) return null;
|
||||
|
||||
// Handle condition nodes - exclude ID completely
|
||||
if (isConditionNode(expression)) {
|
||||
return createConditionComparable(expression);
|
||||
}
|
||||
|
||||
// Handle group nodes - exclude ID completely and support deep nesting
|
||||
if (isGroupNode(expression)) {
|
||||
return createGroupComparable(expression);
|
||||
}
|
||||
|
||||
// Should never reach here with proper typing, but return null for safety
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a filter expression by removing empty conditions and groups.
|
||||
* This helps compare expressions by focusing only on meaningful content.
|
||||
* Uses the transformExpressionTree utility for consistent tree processing.
|
||||
* @param expression - The filter expression to normalize
|
||||
* @returns The normalized expression or null if the entire expression is empty
|
||||
*/
|
||||
export const normalizeFilterExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): TFilterExpression<P> | null => {
|
||||
const result = transformExpressionTree<P>(expression, (node: TFilterExpression<P>) => {
|
||||
// Only transform condition nodes - check if they have valid values
|
||||
if (isConditionNode(node)) {
|
||||
return {
|
||||
expression: hasValidValue(node.value) ? node : null,
|
||||
shouldNotify: false,
|
||||
};
|
||||
}
|
||||
// For group nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
return result.expression;
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs a deep comparison of two filter expressions based on their meaningful content.
|
||||
* This comparison completely ignores IDs (UUIDs) and focuses on property, operator, value, and tree structure.
|
||||
* Empty conditions and groups are normalized before comparison.
|
||||
* Supports deep nesting of groups within groups recursively.
|
||||
* @param expression1 - The first expression to compare
|
||||
* @param expression2 - The second expression to compare
|
||||
* @returns True if the expressions are meaningfully equal, false otherwise
|
||||
*/
|
||||
export const deepCompareFilterExpressions = <P extends TFilterProperty>(
|
||||
expression1: TFilterExpression<P> | null,
|
||||
expression2: TFilterExpression<P> | null
|
||||
): boolean => {
|
||||
// Normalize both expressions to remove empty conditions and groups
|
||||
const normalized1 = normalizeFilterExpression(expression1);
|
||||
const normalized2 = normalizeFilterExpression(expression2);
|
||||
|
||||
// If both are null after normalization, they're equal
|
||||
if (!normalized1 && !normalized2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If one is null and the other isn't, they're different
|
||||
if (!normalized1 || !normalized2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create comparable representations (IDs completely excluded)
|
||||
const comparable1 = createExpressionComparable(normalized1);
|
||||
const comparable2 = createExpressionComparable(normalized2);
|
||||
|
||||
// Deep compare using lodash isEqual for reliable object comparison
|
||||
// This handles deep nesting recursively and ignores UUID differences
|
||||
return isEqual(comparable1, comparable2);
|
||||
};
|
||||
4
packages/utils/src/rich-filters/operations/index.ts
Normal file
4
packages/utils/src/rich-filters/operations/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./comparison";
|
||||
export * from "./manipulation/core";
|
||||
export * from "./transformation/core";
|
||||
export * from "./traversal/core";
|
||||
124
packages/utils/src/rich-filters/operations/manipulation/core.ts
Normal file
124
packages/utils/src/rich-filters/operations/manipulation/core.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// plane imports
|
||||
import {
|
||||
TFilterConditionPayload,
|
||||
TFilterExpression,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { createAndGroupNode } from "../../factories/nodes/core";
|
||||
import { getGroupChildren } from "../../types";
|
||||
import { isAndGroupNode, isConditionNode, isGroupNode } from "../../types/core";
|
||||
import { shouldUnwrapGroup } from "../../validators/shared";
|
||||
import { transformExpressionTree } from "../transformation/core";
|
||||
|
||||
/**
|
||||
* Adds an AND condition to the filter expression.
|
||||
* @param expression - The current filter expression
|
||||
* @param condition - The condition to add
|
||||
* @returns The updated filter expression
|
||||
*/
|
||||
export const addAndCondition = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null,
|
||||
condition: TFilterExpression<P>
|
||||
): TFilterExpression<P> => {
|
||||
// if no expression, set the new condition
|
||||
if (!expression) {
|
||||
return condition;
|
||||
}
|
||||
// if the expression is a condition, convert it to an AND group
|
||||
if (isConditionNode(expression)) {
|
||||
return createAndGroupNode([expression, condition]);
|
||||
}
|
||||
// if the expression is a group, and the group is an AND group, add the new condition to the group
|
||||
if (isGroupNode(expression) && isAndGroupNode(expression)) {
|
||||
expression.children.push(condition);
|
||||
return expression;
|
||||
}
|
||||
// if the expression is a group, but not an AND group, create a new AND group and add the new condition to it
|
||||
if (isGroupNode(expression) && !isAndGroupNode(expression)) {
|
||||
return createAndGroupNode([expression, condition]);
|
||||
}
|
||||
// Throw error for unexpected expression type
|
||||
console.error("Invalid expression type", expression);
|
||||
return expression;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces a node in the expression tree with another node.
|
||||
* Uses transformExpressionTree for consistent tree processing and better maintainability.
|
||||
* @param expression - The expression tree to search in
|
||||
* @param targetId - The ID of the node to replace
|
||||
* @param replacement - The node to replace with
|
||||
* @returns The updated expression tree
|
||||
*/
|
||||
export const replaceNodeInExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string,
|
||||
replacement: TFilterExpression<P>
|
||||
): TFilterExpression<P> => {
|
||||
const result = transformExpressionTree(expression, (node: TFilterExpression<P>) => {
|
||||
// If this is the node we want to replace, return the replacement
|
||||
if (node.id === targetId) {
|
||||
return {
|
||||
expression: replacement,
|
||||
shouldNotify: false,
|
||||
};
|
||||
}
|
||||
// For all other nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
// Since we're doing a replacement, the result should never be null
|
||||
return result.expression || expression;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a node in the filter expression.
|
||||
* Uses recursive tree traversal with proper type handling.
|
||||
* @param expression - The filter expression to update
|
||||
* @param targetId - The id of the node to update
|
||||
* @param updates - The updates to apply to the node
|
||||
*/
|
||||
export const updateNodeInExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string,
|
||||
updates: Partial<TFilterConditionPayload<P, TFilterValue>>
|
||||
) => {
|
||||
// Helper function to recursively update nodes
|
||||
const updateNode = (node: TFilterExpression<P>): void => {
|
||||
if (node.id === targetId) {
|
||||
if (!isConditionNode<P, TFilterValue>(node)) {
|
||||
console.warn("updateNodeInExpression: targetId matched a group; ignoring updates");
|
||||
return;
|
||||
}
|
||||
Object.assign(node, updates);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupNode(node)) {
|
||||
const children = getGroupChildren(node);
|
||||
children.forEach((child) => updateNode(child));
|
||||
}
|
||||
};
|
||||
|
||||
updateNode(expression);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unwraps a group if it meets the unwrapping criteria, otherwise returns the group.
|
||||
* @param group - The group node to potentially unwrap
|
||||
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
|
||||
* @returns The unwrapped child or the original group
|
||||
*/
|
||||
export const unwrapGroupIfNeeded = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>,
|
||||
preserveNotGroups = true
|
||||
) => {
|
||||
if (shouldUnwrapGroup(group, preserveNotGroups)) {
|
||||
const children = getGroupChildren(group);
|
||||
return children[0];
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
// plane imports
|
||||
import { TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { isConditionNode, isGroupNode } from "../../types/core";
|
||||
import { getGroupChildren } from "../../types/shared";
|
||||
import { hasValidValue } from "../../validators/core";
|
||||
import { unwrapGroupIfNeeded } from "../manipulation/core";
|
||||
import { transformGroup } from "./shared";
|
||||
|
||||
/**
|
||||
* Generic tree transformation result type
|
||||
*/
|
||||
export type TTreeTransformResult<P extends TFilterProperty> = {
|
||||
expression: TFilterExpression<P> | null;
|
||||
shouldNotify?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform function type for tree processing
|
||||
*/
|
||||
export type TTreeTransformFn<P extends TFilterProperty> = (expression: TFilterExpression<P>) => TTreeTransformResult<P>;
|
||||
|
||||
/**
|
||||
* Generic recursive tree transformer that handles common tree manipulation logic.
|
||||
* This function provides a reusable way to transform expression trees while maintaining
|
||||
* tree integrity, handling group restructuring, and applying stabilization.
|
||||
*
|
||||
* @param expression - The expression to transform
|
||||
* @param transformFn - Function that defines the transformation logic for each node
|
||||
* @returns The transformation result with expression and metadata
|
||||
*/
|
||||
/**
|
||||
* Helper function to create a consistent transformation result for group nodes.
|
||||
* Centralizes the logic for wrapping group expressions and tracking notifications.
|
||||
*/
|
||||
const createGroupTransformResult = <P extends TFilterProperty>(
|
||||
groupExpression: TFilterGroupNode<P> | null,
|
||||
shouldNotify: boolean
|
||||
): TTreeTransformResult<P> => ({
|
||||
expression: groupExpression ? unwrapGroupIfNeeded(groupExpression, true) : null,
|
||||
shouldNotify,
|
||||
});
|
||||
|
||||
/**
|
||||
* Transforms groups with children by processing all children.
|
||||
* Handles child collection, null filtering, and empty group removal.
|
||||
*/
|
||||
export const transformGroupWithChildren = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>,
|
||||
transformFn: TTreeTransformFn<P>
|
||||
): TTreeTransformResult<P> => {
|
||||
const children = getGroupChildren(group);
|
||||
const transformedChildren: TFilterExpression<P>[] = [];
|
||||
let shouldNotify = false;
|
||||
|
||||
// Transform all children and collect non-null results
|
||||
for (const child of children) {
|
||||
const childResult = transformExpressionTree(child, transformFn);
|
||||
|
||||
if (childResult.shouldNotify) {
|
||||
shouldNotify = true;
|
||||
}
|
||||
|
||||
if (childResult.expression !== null) {
|
||||
transformedChildren.push(childResult.expression);
|
||||
}
|
||||
}
|
||||
|
||||
// If no children remain, remove the entire group
|
||||
if (transformedChildren.length === 0) {
|
||||
return { expression: null, shouldNotify };
|
||||
}
|
||||
|
||||
// Create updated group with transformed children - type-safe without casting
|
||||
const updatedGroup: TFilterGroupNode<P> = {
|
||||
...group,
|
||||
children: transformedChildren,
|
||||
} as TFilterGroupNode<P>;
|
||||
|
||||
return createGroupTransformResult(updatedGroup, shouldNotify);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic recursive tree transformer that handles common tree manipulation logic.
|
||||
* This function provides a reusable way to transform expression trees while maintaining
|
||||
* tree integrity, handling group restructuring, and applying stabilization.
|
||||
*
|
||||
* @param expression - The expression to transform
|
||||
* @param transformFn - Function that defines the transformation logic for each node
|
||||
* @returns The transformation result with expression and metadata
|
||||
*/
|
||||
export const transformExpressionTree = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null,
|
||||
transformFn: TTreeTransformFn<P>
|
||||
): TTreeTransformResult<P> => {
|
||||
// Handle null expressions early
|
||||
if (!expression) {
|
||||
return { expression: null, shouldNotify: false };
|
||||
}
|
||||
|
||||
// Apply the transformation function to the current node
|
||||
const transformResult = transformFn(expression);
|
||||
|
||||
// If the transform function handled this node completely, return its result
|
||||
if (transformResult.expression === null || transformResult.expression !== expression) {
|
||||
return transformResult;
|
||||
}
|
||||
|
||||
// Handle condition nodes (no children to transform)
|
||||
if (isConditionNode(expression)) {
|
||||
return { expression, shouldNotify: false };
|
||||
}
|
||||
|
||||
// Handle group nodes by delegating to the extended transformGroup function
|
||||
if (isGroupNode(expression)) {
|
||||
return transformGroup(expression, transformFn);
|
||||
}
|
||||
|
||||
throw new Error("Unknown expression type in transformExpressionTree");
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a node from the filter expression.
|
||||
* @param expression - The filter expression to remove the node from
|
||||
* @param targetId - The id of the node to remove
|
||||
* @returns An object containing the updated filter expression and whether to notify about the change
|
||||
*/
|
||||
export const removeNodeFromExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string
|
||||
): { expression: TFilterExpression<P> | null; shouldNotify: boolean } => {
|
||||
const result = transformExpressionTree(expression, (node) => {
|
||||
// If this node matches the target ID, remove it
|
||||
if (node.id === targetId) {
|
||||
const shouldNotify = isConditionNode(node) ? hasValidValue(node.value) : true;
|
||||
return {
|
||||
expression: null,
|
||||
shouldNotify,
|
||||
};
|
||||
}
|
||||
// For all other nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
return {
|
||||
expression: result.expression,
|
||||
shouldNotify: result.shouldNotify || false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes and stabilizes a filter expression by removing invalid conditions and unnecessary groups.
|
||||
* This function performs deep sanitization of the entire expression tree:
|
||||
* 1. Removes condition nodes that don't have valid values
|
||||
* 2. Removes empty groups (groups with no children after sanitization)
|
||||
* 3. Unwraps single-child groups that don't need to be wrapped
|
||||
* 4. Preserves tree integrity and logical operators
|
||||
*
|
||||
* @param expression - The filter expression to sanitize
|
||||
* @returns The sanitized expression or null if no valid conditions remain
|
||||
*/
|
||||
export const sanitizeAndStabilizeExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): TFilterExpression<P> | null => {
|
||||
const result = transformExpressionTree(expression, (node) => {
|
||||
// Only transform condition nodes - check if they have valid values
|
||||
if (isConditionNode(node)) {
|
||||
return {
|
||||
expression: hasValidValue(node.value) ? node : null,
|
||||
shouldNotify: false,
|
||||
};
|
||||
}
|
||||
// For group nodes, let the generic transformer handle the recursion
|
||||
return { expression: node, shouldNotify: false };
|
||||
});
|
||||
|
||||
return result.expression;
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
import { processGroupNode } from "../../types/shared";
|
||||
import { transformGroupWithChildren, TTreeTransformFn, TTreeTransformResult } from "./core";
|
||||
|
||||
/**
|
||||
* Transforms groups by processing children.
|
||||
* Handles AND/OR groups with children and NOT groups with single child.
|
||||
* @param group - The group to transform
|
||||
* @param transformFn - The transformation function
|
||||
* @returns The transformation result
|
||||
*/
|
||||
export const transformGroup = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>,
|
||||
transformFn: TTreeTransformFn<P>
|
||||
): TTreeTransformResult<P> =>
|
||||
processGroupNode(group, {
|
||||
onAndGroup: (andGroup) => transformGroupWithChildren(andGroup, transformFn),
|
||||
});
|
||||
210
packages/utils/src/rich-filters/operations/traversal/core.ts
Normal file
210
packages/utils/src/rich-filters/operations/traversal/core.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// plane imports
|
||||
import {
|
||||
TAllAvailableOperatorsForDisplay,
|
||||
TFilterConditionNode,
|
||||
TFilterConditionNodeForDisplay,
|
||||
TFilterExpression,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { isConditionNode, isGroupNode } from "../../types/core";
|
||||
import { getGroupChildren } from "../../types/shared";
|
||||
import { getDisplayOperator } from "./shared";
|
||||
|
||||
/**
|
||||
* Generic tree visitor function type
|
||||
*/
|
||||
export type TreeVisitorFn<P extends TFilterProperty, T> = (
|
||||
expression: TFilterExpression<P>,
|
||||
parent?: TFilterGroupNode<P>,
|
||||
depth?: number
|
||||
) => T | null;
|
||||
|
||||
/**
|
||||
* Tree traversal modes
|
||||
*/
|
||||
export enum TreeTraversalMode {
|
||||
/** Visit all nodes depth-first */
|
||||
ALL = "ALL",
|
||||
/** Visit only condition nodes */
|
||||
CONDITIONS = "CONDITIONS",
|
||||
/** Visit only group nodes */
|
||||
GROUPS = "GROUPS",
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic tree traversal utility that visits nodes based on the specified mode.
|
||||
* This eliminates code duplication in tree walking functions.
|
||||
*
|
||||
* @param expression - The expression to traverse
|
||||
* @param visitor - Function to call for each visited node
|
||||
* @param mode - Traversal mode to determine which nodes to visit
|
||||
* @param parent - Parent node (used internally for recursion)
|
||||
* @param depth - Current depth (used internally for recursion)
|
||||
* @returns Array of results from the visitor function (nulls are filtered out)
|
||||
*/
|
||||
export const traverseExpressionTree = <P extends TFilterProperty, T>(
|
||||
expression: TFilterExpression<P> | null,
|
||||
visitor: TreeVisitorFn<P, T>,
|
||||
mode: TreeTraversalMode = TreeTraversalMode.ALL,
|
||||
parent?: TFilterGroupNode<P>,
|
||||
depth: number = 0
|
||||
): T[] => {
|
||||
if (!expression) return [];
|
||||
|
||||
const results: T[] = [];
|
||||
|
||||
// Determine if we should visit this node based on the mode
|
||||
const shouldVisit =
|
||||
mode === TreeTraversalMode.ALL ||
|
||||
(mode === TreeTraversalMode.CONDITIONS && isConditionNode(expression)) ||
|
||||
(mode === TreeTraversalMode.GROUPS && isGroupNode(expression));
|
||||
|
||||
if (shouldVisit) {
|
||||
const result = visitor(expression, parent, depth);
|
||||
if (result !== null) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively traverse children for group nodes
|
||||
if (isGroupNode(expression)) {
|
||||
const children = getGroupChildren(expression);
|
||||
for (const child of children) {
|
||||
const childResults = traverseExpressionTree(child, visitor, mode, expression, depth + 1);
|
||||
results.push(...childResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a node by its ID in the filter expression tree.
|
||||
* Uses the generic tree traversal utility for better maintainability.
|
||||
* @param expression - The filter expression to search in
|
||||
* @param targetId - The ID of the node to find
|
||||
* @returns The found node or null if not found
|
||||
*/
|
||||
export const findNodeById = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string
|
||||
): TFilterExpression<P> | null => {
|
||||
const results = traverseExpressionTree(
|
||||
expression,
|
||||
(node) => (node.id === targetId ? node : null),
|
||||
TreeTraversalMode.ALL
|
||||
);
|
||||
|
||||
// Return the first match (there should only be one with unique IDs)
|
||||
return results.length > 0 ? results[0] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the parent chain of a given node ID in the filter expression tree.
|
||||
* @param expression - The filter expression to search in
|
||||
* @param targetId - The ID of the node whose parent chain to find
|
||||
* @param currentPath - Current path of parent nodes (used internally for recursion)
|
||||
* @returns Array of parent nodes from immediate parent to root, or null if not found
|
||||
*/
|
||||
export const findParentChain = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string,
|
||||
currentPath: TFilterGroupNode<P>[] = []
|
||||
): TFilterGroupNode<P>[] | null => {
|
||||
// if the expression is a group, search in the children
|
||||
if (isGroupNode(expression)) {
|
||||
const children = getGroupChildren(expression);
|
||||
|
||||
// check if any direct child has the target ID
|
||||
for (const child of children) {
|
||||
if (child.id === targetId) {
|
||||
return [expression, ...currentPath];
|
||||
}
|
||||
}
|
||||
|
||||
// recursively search in child groups
|
||||
for (const child of children) {
|
||||
if (isGroupNode(child)) {
|
||||
const chain = findParentChain(child, targetId, [expression, ...currentPath]);
|
||||
if (chain) return chain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the immediate parent node of a given node ID.
|
||||
* @param expression - The filter expression to find parent in
|
||||
* @param targetId - The ID of the node whose parent to find
|
||||
* @returns The immediate parent node or null if not found or if the target is the root
|
||||
*/
|
||||
export const findImmediateParent = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
targetId: string
|
||||
): TFilterGroupNode<P> | null => {
|
||||
// if the expression is null, return null
|
||||
if (!expression) return null;
|
||||
|
||||
// find the parent chain
|
||||
const parentChain = findParentChain(expression, targetId);
|
||||
|
||||
// return the immediate parent if it exists
|
||||
return parentChain && parentChain.length > 0 ? parentChain[0] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all conditions from a filter expression.
|
||||
* Uses the generic tree traversal utility for better maintainability and consistency.
|
||||
* @param expression - The filter expression to extract conditions from
|
||||
* @returns An array of filter conditions
|
||||
*/
|
||||
export const extractConditions = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>
|
||||
): TFilterConditionNode<P, TFilterValue>[] =>
|
||||
traverseExpressionTree(
|
||||
expression,
|
||||
(node) => (isConditionNode(node) ? node : null),
|
||||
TreeTraversalMode.CONDITIONS
|
||||
) as TFilterConditionNode<P, TFilterValue>[];
|
||||
|
||||
/**
|
||||
* Extracts all conditions from a filter expression, including their display operators.
|
||||
* @param expression - The filter expression to extract conditions from
|
||||
* @returns An array of filter conditions with their display operators
|
||||
*/
|
||||
export const extractConditionsWithDisplayOperators = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>
|
||||
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
|
||||
// First extract all raw conditions
|
||||
const rawConditions = extractConditions(expression);
|
||||
|
||||
// Transform operators using the extended helper
|
||||
return rawConditions.map((condition) => {
|
||||
const displayOperator = getDisplayOperator(condition.operator, expression, condition.id);
|
||||
return {
|
||||
...condition,
|
||||
operator: displayOperator,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds all conditions by property and operator.
|
||||
* @param expression - The filter expression to search in
|
||||
* @param property - The property to find the conditions by
|
||||
* @param operator - The operator to find the conditions by
|
||||
* @returns An array of conditions that match the property and operator
|
||||
*/
|
||||
export const findConditionsByPropertyAndOperator = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P>,
|
||||
property: P,
|
||||
operator: TAllAvailableOperatorsForDisplay
|
||||
): TFilterConditionNodeForDisplay<P, TFilterValue>[] => {
|
||||
const conditions = extractConditionsWithDisplayOperators(expression);
|
||||
return conditions.filter((condition) => condition.property === property && condition.operator === operator);
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// plane imports
|
||||
import {
|
||||
TAllAvailableOperatorsForDisplay,
|
||||
TFilterExpression,
|
||||
TFilterProperty,
|
||||
TSupportedOperators,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Helper function to get the display operator for a condition.
|
||||
* This checks for NOT group context and applies negation if needed.
|
||||
* @param operator - The original operator
|
||||
* @param expression - The filter expression
|
||||
* @param conditionId - The ID of the condition
|
||||
* @returns The display operator (possibly negated)
|
||||
*/
|
||||
export const getDisplayOperator = <P extends TFilterProperty>(
|
||||
operator: TSupportedOperators,
|
||||
_expression: TFilterExpression<P>,
|
||||
_conditionId: string
|
||||
): TAllAvailableOperatorsForDisplay =>
|
||||
// Otherwise, return the operator as-is
|
||||
operator;
|
||||
42
packages/utils/src/rich-filters/operators/core.ts
Normal file
42
packages/utils/src/rich-filters/operators/core.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import get from "lodash/get";
|
||||
// plane imports
|
||||
import { DATE_OPERATOR_LABELS_MAP, EMPTY_OPERATOR_LABEL, OPERATOR_LABELS_MAP } from "@plane/constants";
|
||||
import {
|
||||
TAllAvailableOperatorsForDisplay,
|
||||
TFilterValue,
|
||||
TAllAvailableDateFilterOperatorsForDisplay,
|
||||
} from "@plane/types";
|
||||
|
||||
// -------- OPERATOR LABEL UTILITIES --------
|
||||
|
||||
/**
|
||||
* Get the label for a filter operator
|
||||
* @param operator - The operator to get the label for
|
||||
* @returns The label for the operator
|
||||
*/
|
||||
export const getOperatorLabel = (operator: TAllAvailableOperatorsForDisplay | undefined): string => {
|
||||
if (!operator) return EMPTY_OPERATOR_LABEL;
|
||||
return get(OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the label for a date filter operator
|
||||
* @param operator - The operator to get the label for
|
||||
* @returns The label for the operator
|
||||
*/
|
||||
export const getDateOperatorLabel = (operator: TAllAvailableDateFilterOperatorsForDisplay | undefined): string => {
|
||||
if (!operator) return EMPTY_OPERATOR_LABEL;
|
||||
return get(DATE_OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL);
|
||||
};
|
||||
|
||||
// -------- OPERATOR TYPE GUARDS --------
|
||||
|
||||
/**
|
||||
* Type guard to check if an operator supports date filter types.
|
||||
* @param operator - The operator to check
|
||||
* @returns True if the operator supports date filters
|
||||
*/
|
||||
export const isDateFilterOperator = <V extends TFilterValue = TFilterValue>(
|
||||
operator: TAllAvailableOperatorsForDisplay
|
||||
): operator is TAllAvailableDateFilterOperatorsForDisplay<V> =>
|
||||
Object.keys(DATE_OPERATOR_LABELS_MAP).includes(operator);
|
||||
2
packages/utils/src/rich-filters/operators/index.ts
Normal file
2
packages/utils/src/rich-filters/operators/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./core";
|
||||
export * from "./shared";
|
||||
24
packages/utils/src/rich-filters/operators/shared.ts
Normal file
24
packages/utils/src/rich-filters/operators/shared.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { TAllAvailableOperatorsForDisplay, TSupportedOperators } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Result type for operator conversion
|
||||
*/
|
||||
export type TOperatorForPayload = {
|
||||
operator: TSupportedOperators;
|
||||
isNegation: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a display operator to the format needed for supported by filter expression condition.
|
||||
* @param displayOperator - The operator from the UI
|
||||
* @returns Object with supported operator and negation flag
|
||||
*/
|
||||
export const getOperatorForPayload = (displayOperator: TAllAvailableOperatorsForDisplay): TOperatorForPayload => {
|
||||
const isNegation = false;
|
||||
const operator = displayOperator;
|
||||
|
||||
return {
|
||||
operator,
|
||||
isNegation,
|
||||
};
|
||||
};
|
||||
68
packages/utils/src/rich-filters/types/core.ts
Normal file
68
packages/utils/src/rich-filters/types/core.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
FILTER_FIELD_TYPE,
|
||||
FILTER_NODE_TYPE,
|
||||
LOGICAL_OPERATOR,
|
||||
TFilterAndGroupNode,
|
||||
TFilterConditionNode,
|
||||
TFilterExpression,
|
||||
TFilterFieldType,
|
||||
TFilterGroupNode,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Type guard to check if a node is a condition node.
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a condition node
|
||||
*/
|
||||
export const isConditionNode = <P extends TFilterProperty, V extends TFilterValue>(
|
||||
node: TFilterExpression<P>
|
||||
): node is TFilterConditionNode<P, V> => node.type === FILTER_NODE_TYPE.CONDITION;
|
||||
|
||||
/**
|
||||
* Type guard to check if a node is a group node.
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a group node
|
||||
*/
|
||||
export const isGroupNode = <P extends TFilterProperty>(node: TFilterExpression<P>): node is TFilterGroupNode<P> =>
|
||||
node.type === FILTER_NODE_TYPE.GROUP;
|
||||
|
||||
/**
|
||||
* Type guard to check if a group node is an AND group.
|
||||
* @param group - The group node to check
|
||||
* @returns True if the group is an AND group
|
||||
*/
|
||||
export const isAndGroupNode = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>
|
||||
): group is TFilterAndGroupNode<P> => group.logicalOperator === LOGICAL_OPERATOR.AND;
|
||||
|
||||
/**
|
||||
* Type guard to check if a group node has children property
|
||||
* @param group - The group node to check
|
||||
* @returns True if the group has children property
|
||||
*/
|
||||
export const hasChildrenProperty = <P extends TFilterProperty>(
|
||||
group: TFilterGroupNode<P>
|
||||
): group is TFilterAndGroupNode<P> => {
|
||||
const groupWithChildren = group as { children?: unknown };
|
||||
return "children" in group && Array.isArray(groupWithChildren.children);
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely gets the children array from an AND group node.
|
||||
* @param group - The AND group node
|
||||
* @returns The children array
|
||||
*/
|
||||
export const getAndGroupChildren = <P extends TFilterProperty>(group: TFilterAndGroupNode<P>): TFilterExpression<P>[] =>
|
||||
group.children;
|
||||
|
||||
/**
|
||||
* Type guard to check if a filter type is a date filter type.
|
||||
* @param type - The filter type to check
|
||||
* @returns True if the filter type is a date filter type
|
||||
*/
|
||||
export const isDateFilterType = (
|
||||
type: TFilterFieldType
|
||||
): type is typeof FILTER_FIELD_TYPE.DATE | typeof FILTER_FIELD_TYPE.DATE_RANGE =>
|
||||
type === FILTER_FIELD_TYPE.DATE || type === FILTER_FIELD_TYPE.DATE_RANGE;
|
||||
2
packages/utils/src/rich-filters/types/index.ts
Normal file
2
packages/utils/src/rich-filters/types/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./core";
|
||||
export * from "./shared";
|
||||
35
packages/utils/src/rich-filters/types/shared.ts
Normal file
35
packages/utils/src/rich-filters/types/shared.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// plane imports
|
||||
import { TFilterAndGroupNode, TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { getAndGroupChildren, isAndGroupNode } from "./core";
|
||||
|
||||
type TProcessGroupNodeHandlers<P extends TFilterProperty, T> = {
|
||||
onAndGroup: (group: TFilterAndGroupNode<P>) => T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic helper to process group nodes with type-safe handlers.
|
||||
* @param group - The group node to process
|
||||
* @param handlers - Object with handlers for each group type
|
||||
* @returns Result of the appropriate handler
|
||||
*/
|
||||
export const processGroupNode = <P extends TFilterProperty, T>(
|
||||
group: TFilterGroupNode<P>,
|
||||
handlers: TProcessGroupNodeHandlers<P, T>
|
||||
): T => {
|
||||
if (isAndGroupNode(group)) {
|
||||
return handlers.onAndGroup(group);
|
||||
}
|
||||
throw new Error(`Invalid group node: unknown logical operator ${group}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the children of a group node, handling AND/OR groups (children array) and NOT groups (single child).
|
||||
* Uses processGroupNode for consistent group type handling.
|
||||
* @param group - The group node to get children from
|
||||
* @returns Array of child expressions
|
||||
*/
|
||||
export const getGroupChildren = <P extends TFilterProperty>(group: TFilterGroupNode<P>): TFilterExpression<P>[] =>
|
||||
processGroupNode(group, {
|
||||
onAndGroup: (andGroup) => getAndGroupChildren(andGroup),
|
||||
});
|
||||
52
packages/utils/src/rich-filters/validators/core.ts
Normal file
52
packages/utils/src/rich-filters/validators/core.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// plane imports
|
||||
import { SingleOrArray, TFilterExpression, TFilterProperty, TFilterValue } from "@plane/types";
|
||||
// local imports
|
||||
import { getGroupChildren } from "../types";
|
||||
import { isConditionNode, isGroupNode } from "../types/core";
|
||||
|
||||
/**
|
||||
* Determines whether to notify about a change based on the filter value.
|
||||
* @param value - The filter value to check
|
||||
* @returns True if we should notify, false otherwise
|
||||
*/
|
||||
export const hasValidValue = (value: SingleOrArray<TFilterValue>): boolean => {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's an array, check if it's empty or contains only null/undefined values
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return value.some((v) => v !== null && v !== undefined);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether to notify about a change based on the entire filter expression.
|
||||
* @param expression - The filter expression to check
|
||||
* @returns True if we should notify, false otherwise
|
||||
*/
|
||||
export const shouldNotifyChangeForExpression = <P extends TFilterProperty>(
|
||||
expression: TFilterExpression<P> | null
|
||||
): boolean => {
|
||||
if (!expression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a condition, check its value
|
||||
if (isConditionNode(expression)) {
|
||||
return hasValidValue(expression.value);
|
||||
}
|
||||
|
||||
// If it's a group, check if any of its children have meaningful values
|
||||
if (isGroupNode(expression)) {
|
||||
const children = getGroupChildren(expression);
|
||||
return children.some((child) => shouldNotifyChangeForExpression(child));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
2
packages/utils/src/rich-filters/validators/index.ts
Normal file
2
packages/utils/src/rich-filters/validators/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./core";
|
||||
export * from "./shared";
|
||||
22
packages/utils/src/rich-filters/validators/shared.ts
Normal file
22
packages/utils/src/rich-filters/validators/shared.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// plane imports
|
||||
import { TFilterGroupNode, TFilterProperty } from "@plane/types";
|
||||
// local imports
|
||||
import { getGroupChildren } from "../types/shared";
|
||||
|
||||
/**
|
||||
* Determines if a group should be unwrapped based on the number of children and group type.
|
||||
* @param group - The group node to check
|
||||
* @param preserveNotGroups - Whether to preserve NOT groups even with single children
|
||||
* @returns True if the group should be unwrapped, false otherwise
|
||||
*/
|
||||
export const shouldUnwrapGroup = <P extends TFilterProperty>(group: TFilterGroupNode<P>, _preserveNotGroups = true) => {
|
||||
const children = getGroupChildren(group);
|
||||
|
||||
// Never unwrap groups with multiple children
|
||||
if (children.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unwrap AND/OR groups with single children, and NOT groups if preserveNotGroups is false
|
||||
return true;
|
||||
};
|
||||
24
packages/utils/src/rich-filters/values/core.ts
Normal file
24
packages/utils/src/rich-filters/values/core.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { SingleOrArray, TFilterValue } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Converts any value to a non-null array for UI components that expect arrays
|
||||
* Returns empty array for null/undefined values
|
||||
*/
|
||||
export const toFilterArray = <V extends TFilterValue>(value: SingleOrArray<V>): NonNullable<V>[] => {
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? (value as NonNullable<V>[]) : ([value] as NonNullable<V>[]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the length of a filter value
|
||||
*/
|
||||
export const getFilterValueLength = <V extends TFilterValue>(value: SingleOrArray<V>): number => {
|
||||
if (value === null || value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value.length : 1;
|
||||
};
|
||||
1
packages/utils/src/rich-filters/values/index.ts
Normal file
1
packages/utils/src/rich-filters/values/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./core";
|
||||
Loading…
Add table
Add a link
Reference in a new issue