[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

@ -15,13 +15,21 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"@plane/constants": "workspace:*",
"@plane/types": "workspace:*",
"@plane/utils": "workspace:*",
"lodash": "catalog:",
"mobx": "catalog:",
"mobx-utils": "catalog:",
"uuid": "catalog:",
"zod": "^3.22.2"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "^22.5.4",
"@types/lodash": "catalog:",
"@types/uuid": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -0,0 +1,2 @@
export * from "./store";
export * from "./utils";

View file

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

View file

@ -0,0 +1,31 @@
// plane imports
import { IFilterAdapter, TExternalFilter, TFilterExpression, TFilterProperty } from "@plane/types";
/**
* Abstract base class for converting between external filter formats and internal filter expressions.
* Provides common utilities for creating and manipulating filter nodes.
*
* @template K - Property key type that extends TFilterProperty
* @template E - External filter type that extends TExternalFilter
*/
export abstract class FilterAdapter<K extends TFilterProperty, E extends TExternalFilter>
implements IFilterAdapter<K, E>
{
/**
* Converts an external filter format to internal filter expression.
* Must be implemented by concrete adapter classes.
*
* @param externalFilter - The external filter to convert
* @returns The internal filter expression or null if conversion fails
*/
abstract toInternal(externalFilter: E): TFilterExpression<K> | null;
/**
* Converts an internal filter expression to external filter format.
* Must be implemented by concrete adapter classes.
*
* @param internalFilter - The internal filter expression to convert
* @returns The external filter format
*/
abstract toExternal(internalFilter: TFilterExpression<K> | null): E;
}

View file

@ -0,0 +1,173 @@
import { action, computed, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { DEFAULT_FILTER_CONFIG_OPTIONS, TConfigOptions } from "@plane/constants";
import { TExternalFilter, TFilterConfig, TFilterProperty, TFilterValue } from "@plane/types";
// local imports
import { FilterConfig, IFilterConfig } from "./config";
import { IFilterInstance } from "./filter";
/**
* Interface for managing filter configurations.
* Provides methods to register, update, and retrieve filter configurations.
* - filterConfigs: Map storing filter configurations by their ID
* - configOptions: Configuration options controlling filter behavior
* - allConfigs: All registered filter configurations
* - allAvailableConfigs: All available filter configurations based on current state
* - getConfigByProperty: Retrieves a filter configuration by its ID
* - register: Registers a single filter configuration
* - registerAll: Registers multiple filter configurations
* - updateConfigByProperty: Updates an existing filter configuration by ID
* @template P - The filter property type extending TFilterProperty
*/
export interface IFilterConfigManager<P extends TFilterProperty> {
// observables
filterConfigs: Map<P, IFilterConfig<P, TFilterValue>>; // filter property -> config
configOptions: TConfigOptions;
// computed
allAvailableConfigs: IFilterConfig<P, TFilterValue>[];
// computed functions
getConfigByProperty: (property: P) => IFilterConfig<P, TFilterValue> | undefined;
// helpers
register: <C extends TFilterConfig<P, TFilterValue>>(config: C) => void;
registerAll: (configs: TFilterConfig<P, TFilterValue>[]) => void;
updateConfigByProperty: (property: P, configUpdates: Partial<TFilterConfig<P, TFilterValue>>) => void;
}
/**
* Parameters for initializing the FilterConfigManager.
* - options: Optional configuration options to override defaults
*/
export type TConfigManagerParams = {
options?: Partial<TConfigOptions>;
};
/**
* Manages filter configurations for a filter instance.
* Handles registration, updates, and retrieval of filter configurations.
* Provides computed properties for available configurations based on current filter state.
*
* @template P - The filter property type extending TFilterProperty
* @template V - The filter value type extending TFilterValue
* @template E - The external filter type extending TExternalFilter
*/
export class FilterConfigManager<P extends TFilterProperty, E extends TExternalFilter = TExternalFilter>
implements IFilterConfigManager<P>
{
// observables
filterConfigs: IFilterConfigManager<P>["filterConfigs"];
configOptions: IFilterConfigManager<P>["configOptions"];
// parent filter instance
_filterInstance: IFilterInstance<P, E>;
/**
* Creates a new FilterConfigManager instance.
*
* @param filterInstance - The parent filter instance this manager belongs to
* @param params - Configuration parameters for the manager
*/
constructor(filterInstance: IFilterInstance<P, E>, params: TConfigManagerParams) {
this.filterConfigs = new Map<P, IFilterConfig<P>>();
this.configOptions = this._initializeConfigOptions(params.options);
// parent filter instance
this._filterInstance = filterInstance;
makeObservable(this, {
filterConfigs: observable,
configOptions: observable,
// computed
allAvailableConfigs: computed,
// helpers
register: action,
registerAll: action,
updateConfigByProperty: action,
});
}
// ------------ computed ------------
/**
* Returns all available filterConfigs.
* If allowSameFilters is true, all enabled configs are returned.
* Otherwise, only configs that are not already applied to the filter instance are returned.
* @returns All available filterConfigs.
*/
get allAvailableConfigs(): IFilterConfigManager<P>["allAvailableConfigs"] {
const appliedProperties = new Set(this._filterInstance.allConditions.map((condition) => condition.property));
// Return all enabled configs that either allow multiple filters or are not currently applied
return this._allEnabledConfigs.filter((config) => config.allowMultipleFilters || !appliedProperties.has(config.id));
}
// ------------ computed functions ------------
/**
* Returns a config by filter property.
* @param property - The property to get the config for.
* @returns The config for the property, or undefined if not found.
*/
getConfigByProperty: IFilterConfigManager<P>["getConfigByProperty"] = computedFn(
(property) => this.filterConfigs.get(property) as IFilterConfig<P, TFilterValue>
);
// ------------ helpers ------------
/**
* Register a config.
* If a config with the same property already exists, it will be updated with the new values.
* Otherwise, a new config will be created.
* @param configUpdates - The config updates to register.
*/
register: IFilterConfigManager<P>["register"] = action((configUpdates) => {
if (this.filterConfigs.has(configUpdates.id)) {
// Update existing config if it has differences
const existingConfig = this.filterConfigs.get(configUpdates.id)!;
existingConfig.mutate(configUpdates);
} else {
// Create new config if it doesn't exist
this.filterConfigs.set(configUpdates.id, new FilterConfig(configUpdates));
}
});
/**
* Register all configs.
* @param configs - The configs to register.
*/
registerAll: IFilterConfigManager<P>["registerAll"] = action((configs) => {
configs.forEach((config) => this.register(config));
});
/**
* Updates a config by filter property.
* @param property - The property of the config to update.
* @param configUpdates - The updates to apply to the config.
*/
updateConfigByProperty: IFilterConfigManager<P>["updateConfigByProperty"] = action((property, configUpdates) => {
const prevConfig = this.filterConfigs.get(property);
prevConfig?.mutate(configUpdates);
});
// ------------ private computed ------------
private get _allConfigs(): IFilterConfig<P, TFilterValue>[] {
return Array.from(this.filterConfigs.values());
}
/**
* Returns all enabled filterConfigs.
* @returns All enabled filterConfigs.
*/
private get _allEnabledConfigs(): IFilterConfig<P, TFilterValue>[] {
return this._allConfigs.filter((config) => config.isEnabled);
}
// ------------ private helpers ------------
/**
* Initializes the config options.
* @param options - The options to initialize the config options with.
* @returns The initialized config options.
*/
private _initializeConfigOptions(options?: Partial<TConfigOptions>): TConfigOptions {
return DEFAULT_FILTER_CONFIG_OPTIONS ? { ...DEFAULT_FILTER_CONFIG_OPTIONS, ...options } : options || {};
}
}

View file

@ -0,0 +1,212 @@
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { EMPTY_OPERATOR_LABEL } from "@plane/constants";
import {
FILTER_FIELD_TYPE,
TSupportedOperators,
TFilterConfig,
TFilterProperty,
TFilterValue,
TOperatorSpecificConfigs,
TAllAvailableOperatorsForDisplay,
} from "@plane/types";
import {
getOperatorLabel,
isDateFilterType,
getDateOperatorLabel,
isDateFilterOperator,
getOperatorForPayload,
} from "@plane/utils";
type TOperatorOptionForDisplay = {
value: TAllAvailableOperatorsForDisplay;
label: string;
};
export interface IFilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue>
extends TFilterConfig<P, V> {
// computed
allSupportedOperators: TSupportedOperators[];
allSupportedOperatorConfigs: TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>][];
firstOperator: TSupportedOperators | undefined;
// computed functions
getOperatorConfig: (
operator: TAllAvailableOperatorsForDisplay
) => TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>] | undefined;
getLabelForOperator: (operator: TAllAvailableOperatorsForDisplay | undefined) => string;
getDisplayOperatorByValue: <T extends TSupportedOperators | TAllAvailableOperatorsForDisplay>(
operator: T,
value: V
) => T;
getAllDisplayOperatorOptionsByValue: (value: V) => TOperatorOptionForDisplay[];
// actions
mutate: (updates: Partial<TFilterConfig<P, V>>) => void;
}
export class FilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue>
implements IFilterConfig<P, V>
{
// observables
id: IFilterConfig<P, V>["id"];
label: IFilterConfig<P, V>["label"];
icon?: IFilterConfig<P, V>["icon"];
isEnabled: IFilterConfig<P, V>["isEnabled"];
supportedOperatorConfigsMap: IFilterConfig<P, V>["supportedOperatorConfigsMap"];
allowMultipleFilters: IFilterConfig<P, V>["allowMultipleFilters"];
/**
* Creates a new FilterConfig instance.
* @param params - The parameters for the filter config.
*/
constructor(params: TFilterConfig<P, V>) {
this.id = params.id;
this.label = params.label;
this.icon = params.icon;
this.isEnabled = params.isEnabled;
this.supportedOperatorConfigsMap = params.supportedOperatorConfigsMap;
this.allowMultipleFilters = params.allowMultipleFilters;
makeObservable(this, {
id: observable,
label: observable,
icon: observable,
isEnabled: observable,
supportedOperatorConfigsMap: observable,
allowMultipleFilters: observable,
// computed
allSupportedOperators: computed,
allSupportedOperatorConfigs: computed,
firstOperator: computed,
// actions
mutate: action,
});
}
// ------------ computed ------------
/**
* Returns all supported operators.
* @returns All supported operators.
*/
get allSupportedOperators(): IFilterConfig<P, V>["allSupportedOperators"] {
return Array.from(this.supportedOperatorConfigsMap.keys());
}
/**
* Returns all supported operator configs.
* @returns All supported operator configs.
*/
get allSupportedOperatorConfigs(): IFilterConfig<P, V>["allSupportedOperatorConfigs"] {
return Array.from(this.supportedOperatorConfigsMap.values());
}
/**
* Returns the first operator.
* @returns The first operator.
*/
get firstOperator(): IFilterConfig<P, V>["firstOperator"] {
return this.allSupportedOperators[0];
}
// ------------ computed functions ------------
/**
* Returns the operator config.
* @param operator - The operator.
* @returns The operator config.
*/
getOperatorConfig: IFilterConfig<P, V>["getOperatorConfig"] = computedFn((operator) =>
this.supportedOperatorConfigsMap.get(getOperatorForPayload(operator).operator)
);
/**
* Returns the label for an operator.
* @param operator - The operator.
* @returns The label for the operator.
*/
getLabelForOperator: IFilterConfig<P, V>["getLabelForOperator"] = computedFn((operator) => {
if (!operator) return EMPTY_OPERATOR_LABEL;
const operatorConfig = this.getOperatorConfig(operator);
if (operatorConfig?.operatorLabel) {
return operatorConfig.operatorLabel;
}
if (operatorConfig?.type && isDateFilterType(operatorConfig.type) && isDateFilterOperator(operator)) {
return getDateOperatorLabel(operator);
}
return getOperatorLabel(operator);
});
/**
* Returns the operator for a value.
* @param value - The value.
* @returns The operator for the value.
*/
getDisplayOperatorByValue: IFilterConfig<P, V>["getDisplayOperatorByValue"] = computedFn((operator, value) => {
const operatorConfig = this.getOperatorConfig(operator);
if (operatorConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT && (Array.isArray(value) ? value.length : 0) <= 1) {
return operatorConfig.singleValueOperator as typeof operator;
}
return operator;
});
/**
* Returns all supported operator options for display in the filter UI.
* This method filters out operators that are already applied (unless multiple filters are allowed)
* and includes both positive and negative variants when supported.
*
* @param value - The current filter value used to determine the appropriate operator variant
* @returns Array of operator options with their display labels and values
*/
getAllDisplayOperatorOptionsByValue: IFilterConfig<P, V>["getAllDisplayOperatorOptionsByValue"] = computedFn(
(value) => {
const operatorOptions: TOperatorOptionForDisplay[] = [];
// Process each supported operator to build display options
for (const operator of this.allSupportedOperators) {
const displayOperator = this.getDisplayOperatorByValue(operator, value);
const displayOperatorLabel = this.getLabelForOperator(displayOperator);
operatorOptions.push({
value: operator,
label: displayOperatorLabel,
});
const additionalOperatorOption = this._getAdditionalOperatorOptions(operator, value);
if (additionalOperatorOption) {
operatorOptions.push(additionalOperatorOption);
}
}
return operatorOptions;
}
);
// ------------ actions ------------
/**
* Mutates the config.
* @param updates - The updates to apply to the config.
*/
mutate: IFilterConfig<P, V>["mutate"] = action((updates) => {
runInAction(() => {
for (const key in updates) {
if (updates.hasOwnProperty(key)) {
const configKey = key as keyof TFilterConfig<P, V>;
set(this, configKey, updates[configKey]);
}
}
});
});
// ------------ private helpers ------------
private _getAdditionalOperatorOptions = (
_operator: TSupportedOperators,
_value: V
): TOperatorOptionForDisplay | undefined => undefined;
}

View file

@ -0,0 +1,172 @@
import cloneDeep from "lodash/cloneDeep";
import { toJS } from "mobx";
// plane imports
import { DEFAULT_FILTER_EXPRESSION_OPTIONS, TExpressionOptions } from "@plane/constants";
import {
IFilterAdapter,
LOGICAL_OPERATOR,
TSupportedOperators,
TFilterExpression,
TFilterValue,
TFilterProperty,
TExternalFilter,
TLogicalOperator,
TFilterConditionPayload,
} from "@plane/types";
import { addAndCondition, createConditionNode, updateNodeInExpression } from "@plane/utils";
/**
* Interface for filter instance helper utilities.
* Provides comprehensive methods for filter expression manipulation, node operations,
* operator utilities, and expression restructuring.
* @template P - The filter property type extending TFilterProperty
* @template E - The external filter type extending TExternalFilter
*/
export interface IFilterInstanceHelper<P extends TFilterProperty, E extends TExternalFilter> {
// initialization
initializeExpression: (initialExpression?: E) => TFilterExpression<P> | null;
initializeExpressionOptions: (expressionOptions?: Partial<TExpressionOptions<E>>) => TExpressionOptions<E>;
// condition operations
addConditionToExpression: <V extends TFilterValue>(
expression: TFilterExpression<P> | null,
groupOperator: TLogicalOperator,
condition: TFilterConditionPayload<P, V>,
isNegation: boolean
) => TFilterExpression<P> | null;
// group operations
restructureExpressionForOperatorChange: (
expression: TFilterExpression<P> | null,
conditionId: string,
newOperator: TSupportedOperators,
isNegation: boolean,
shouldResetValue: boolean
) => TFilterExpression<P> | null;
}
/**
* Comprehensive helper class for filter instance operations.
* Provides utilities for filter expression manipulation, node operations,
* operator transformations, and expression restructuring.
*
* @template K - The filter property type extending TFilterProperty
* @template E - The external filter type extending TExternalFilter
*/
export class FilterInstanceHelper<P extends TFilterProperty, E extends TExternalFilter>
implements IFilterInstanceHelper<P, E>
{
private adapter: IFilterAdapter<P, E>;
/**
* Creates a new FilterInstanceHelper instance.
*
* @param adapter - The filter adapter for converting between internal and external formats
*/
constructor(adapter: IFilterAdapter<P, E>) {
this.adapter = adapter;
}
// ------------ initialization ------------
/**
* Initializes the filter expression from external format.
* @param initialExpression - The initial expression to initialize the filter with
* @returns The initialized filter expression or null if no initial expression provided
*/
initializeExpression: IFilterInstanceHelper<P, E>["initializeExpression"] = (initialExpression) => {
if (!initialExpression) return null;
return this.adapter.toInternal(toJS(cloneDeep(initialExpression)));
};
/**
* Initializes the filter expression options with defaults.
* @param expressionOptions - Optional expression options to override defaults
* @returns The initialized filter expression options
*/
initializeExpressionOptions: IFilterInstanceHelper<P, E>["initializeExpressionOptions"] = (expressionOptions) => ({
...DEFAULT_FILTER_EXPRESSION_OPTIONS,
...expressionOptions,
});
// ------------ condition operations ------------
/**
* Adds a condition to the filter expression based on the logical operator.
* @param expression - The current filter expression
* @param groupOperator - The logical operator to use for the condition
* @param condition - The condition to add
* @param isNegation - Whether the condition should be negated
* @returns The updated filter expression
*/
addConditionToExpression: IFilterInstanceHelper<P, E>["addConditionToExpression"] = (
expression,
groupOperator,
condition,
isNegation
) => this._addConditionByOperator(expression, groupOperator, this._getConditionPayloadToAdd(condition, isNegation));
// ------------ group operations ------------
/**
* Restructures the expression when a condition's operator changes between positive and negative.
* @param expression - The filter expression to operate on
* @param conditionId - The ID of the condition being updated
* @param newOperator - The new operator for the condition
* @param isNegation - Whether the operator is negation
* @param shouldResetValue - Whether to reset the condition value
* @returns The restructured expression
*/
restructureExpressionForOperatorChange: IFilterInstanceHelper<P, E>["restructureExpressionForOperatorChange"] = (
expression,
conditionId,
newOperator,
_isNegation,
shouldResetValue
) => {
if (!expression) return null;
const payload = shouldResetValue ? { operator: newOperator, value: undefined } : { operator: newOperator };
// Update the condition with the new operator
updateNodeInExpression(expression, conditionId, payload);
return expression;
};
// ------------ private helpers ------------
/**
* Gets the condition payload to add to the expression.
* @param conditionNode - The condition node to add
* @param isNegation - Whether the condition should be negated
* @returns The condition payload to add
*/
private _getConditionPayloadToAdd = (
condition: TFilterConditionPayload<P, TFilterValue>,
_isNegation: boolean
): TFilterExpression<P> => {
const conditionNode = createConditionNode(condition);
return conditionNode;
};
/**
* Handles the logical operator switch for adding conditions.
* @param expression - The current expression
* @param groupOperator - The logical operator
* @param conditionToAdd - The condition to add
* @returns The updated expression
*/
private _addConditionByOperator(
expression: TFilterExpression<P> | null,
groupOperator: TLogicalOperator,
conditionToAdd: TFilterExpression<P>
): TFilterExpression<P> | null {
switch (groupOperator) {
case LOGICAL_OPERATOR.AND:
return addAndCondition(expression, conditionToAdd);
default:
console.warn(`Unsupported logical operator: ${groupOperator}`);
return expression;
}
}
}

View file

@ -0,0 +1,490 @@
import cloneDeep from "lodash/cloneDeep";
import { action, computed, makeObservable, observable, toJS } from "mobx";
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
// plane imports
import {
TClearFilterOptions,
TExpressionOptions,
TFilterOptions,
TSaveViewOptions,
TUpdateViewOptions,
} from "@plane/constants";
import {
FILTER_NODE_TYPE,
IFilterAdapter,
SingleOrArray,
TAllAvailableOperatorsForDisplay,
TExternalFilter,
TFilterConditionNode,
TFilterConditionNodeForDisplay,
TFilterConditionPayload,
TFilterExpression,
TFilterProperty,
TFilterValue,
TLogicalOperator,
TSupportedOperators,
} from "@plane/types";
// local imports
import {
deepCompareFilterExpressions,
extractConditions,
extractConditionsWithDisplayOperators,
findConditionsByPropertyAndOperator,
findNodeById,
hasValidValue,
removeNodeFromExpression,
sanitizeAndStabilizeExpression,
shouldNotifyChangeForExpression,
updateNodeInExpression,
} from "@plane/utils";
import { FilterConfigManager, IFilterConfigManager } from "./config-manager";
import { FilterInstanceHelper, IFilterInstanceHelper } from "./filter-helpers";
/**
* Interface for a filter instance.
* Provides methods to manage the filter expression and notify changes.
* - id: The id of the filter instance
* - expression: The filter expression
* - adapter: The filter adapter
* - configManager: The filter config manager
* - onExpressionChange: The callback to notify when the expression changes
* - hasActiveFilters: Whether the filter instance has any active filters
* - allConditions: All conditions in the filter expression
* - allConditionsForDisplay: All conditions in the filter expression
* - addCondition: Adds a condition to the filter expression
* - updateConditionOperator: Updates the operator of a condition in the filter expression
* - updateConditionValue: Updates the value of a condition in the filter expression
* - removeCondition: Removes a condition from the filter expression
* - clearFilters: Clears the filter expression
* @template P - The filter property type extending TFilterProperty
* @template E - The external filter type extending TExternalFilter
*/
export interface IFilterInstance<P extends TFilterProperty, E extends TExternalFilter> {
// observables
id: string;
initialFilterExpression: TFilterExpression<P> | null;
expression: TFilterExpression<P> | null;
adapter: IFilterAdapter<P, E>;
configManager: IFilterConfigManager<P>;
onExpressionChange?: (expression: E) => void;
// computed
hasActiveFilters: boolean;
hasChanges: boolean;
allConditions: TFilterConditionNode<P, TFilterValue>[];
allConditionsForDisplay: TFilterConditionNodeForDisplay<P, TFilterValue>[];
// computed option helpers
clearFilterOptions: TClearFilterOptions | undefined;
saveViewOptions: TSaveViewOptions<E> | undefined;
updateViewOptions: TUpdateViewOptions<E> | undefined;
// computed permissions
canClearFilters: boolean;
canSaveView: boolean;
canUpdateView: boolean;
// filter expression actions
resetExpression: (externalExpression: E, shouldResetInitialExpression?: boolean) => void;
// filter condition
findConditionsByPropertyAndOperator: (
property: P,
operator: TAllAvailableOperatorsForDisplay
) => TFilterConditionNodeForDisplay<P, TFilterValue>[];
findFirstConditionByPropertyAndOperator: (
property: P,
operator: TAllAvailableOperatorsForDisplay
) => TFilterConditionNodeForDisplay<P, TFilterValue> | undefined;
addCondition: <V extends TFilterValue>(
groupOperator: TLogicalOperator,
condition: TFilterConditionPayload<P, V>,
isNegation: boolean
) => void;
updateConditionOperator: (conditionId: string, operator: TSupportedOperators, isNegation: boolean) => void;
updateConditionValue: <V extends TFilterValue>(conditionId: string, value: SingleOrArray<V>) => void;
removeCondition: (conditionId: string) => void;
// config actions
clearFilters: () => Promise<void>;
saveView: () => Promise<void>;
updateView: () => Promise<void>;
// expression options actions
updateExpressionOptions: (newOptions: Partial<TExpressionOptions<E>>) => void;
}
export type TFilterParams<P extends TFilterProperty, E extends TExternalFilter> = {
adapter: IFilterAdapter<P, E>;
options?: Partial<TFilterOptions<E>>;
initialExpression?: E;
onExpressionChange?: (expression: E) => void;
};
export class FilterInstance<P extends TFilterProperty, E extends TExternalFilter> implements IFilterInstance<P, E> {
// observables
id: string;
initialFilterExpression: TFilterExpression<P> | null;
expression: TFilterExpression<P> | null;
expressionOptions: TExpressionOptions<E>;
adapter: IFilterAdapter<P, E>;
configManager: IFilterConfigManager<P>;
onExpressionChange?: (expression: E) => void;
// helper instance
private helper: IFilterInstanceHelper<P, E>;
constructor(params: TFilterParams<P, E>) {
this.id = uuidv4();
this.adapter = params.adapter;
this.helper = new FilterInstanceHelper<P, E>(this.adapter);
this.configManager = new FilterConfigManager<P, E>(this, {
options: params.options?.config,
});
// initialize expression
const initialExpression = this.helper.initializeExpression(params.initialExpression);
this.initialFilterExpression = cloneDeep(initialExpression);
this.expression = cloneDeep(initialExpression);
this.expressionOptions = this.helper.initializeExpressionOptions(params.options?.expression);
this.onExpressionChange = params.onExpressionChange;
makeObservable(this, {
// observables
id: observable,
initialFilterExpression: observable,
expression: observable,
expressionOptions: observable,
adapter: observable,
configManager: observable,
// computed
hasActiveFilters: computed,
hasChanges: computed,
allConditions: computed,
allConditionsForDisplay: computed,
// computed option helpers
clearFilterOptions: computed,
saveViewOptions: computed,
updateViewOptions: computed,
// computed permissions
canClearFilters: computed,
canSaveView: computed,
canUpdateView: computed,
// actions
resetExpression: action,
findConditionsByPropertyAndOperator: action,
findFirstConditionByPropertyAndOperator: action,
addCondition: action,
updateConditionOperator: action,
updateConditionValue: action,
removeCondition: action,
clearFilters: action,
saveView: action,
updateView: action,
updateExpressionOptions: action,
});
}
// ------------ computed ------------
/**
* Checks if the filter instance has any active filters.
* @returns True if the filter instance has any active filters, false otherwise.
*/
get hasActiveFilters(): IFilterInstance<P, E>["hasActiveFilters"] {
// if the expression is null, return false
if (!this.expression) return false;
// if there are no conditions, return false
if (this.allConditionsForDisplay.length === 0) return false;
// if there are conditions, return true if any of them have a valid value
return this.allConditionsForDisplay.some((condition) => hasValidValue(condition.value));
}
/**
* Checks if the filter instance has any changes with respect to the initial expression.
* @returns True if the filter instance has any changes, false otherwise.
*/
get hasChanges(): IFilterInstance<P, E>["hasChanges"] {
return !deepCompareFilterExpressions(this.initialFilterExpression, this.expression);
}
/**
* Returns all conditions from the filter expression.
* @returns An array of filter conditions.
*/
get allConditions(): IFilterInstance<P, E>["allConditions"] {
if (!this.expression) return [];
return extractConditions(this.expression);
}
/**
* Returns all conditions in the filter expression for display purposes.
* @returns An array of filter conditions for display purposes.
*/
get allConditionsForDisplay(): IFilterInstance<P, E>["allConditionsForDisplay"] {
if (!this.expression) return [];
return extractConditionsWithDisplayOperators(this.expression);
}
// ------------ computed option helpers ------------
/**
* Returns the clear filter options.
* @returns The clear filter options.
*/
get clearFilterOptions(): IFilterInstance<P, E>["clearFilterOptions"] {
return this.expressionOptions.clearFilterOptions;
}
/**
* Returns the save view options.
* @returns The save view options.
*/
get saveViewOptions(): IFilterInstance<P, E>["saveViewOptions"] {
return this.expressionOptions.saveViewOptions;
}
/**
* Returns the update view options.
* @returns The update view options.
*/
get updateViewOptions(): IFilterInstance<P, E>["updateViewOptions"] {
return this.expressionOptions.updateViewOptions;
}
// ------------ computed permissions ------------
/**
* Checks if the filter expression can be cleared.
* @returns True if the filter expression can be cleared, false otherwise.
*/
get canClearFilters(): IFilterInstance<P, E>["canClearFilters"] {
if (!this.expression) return false;
if (this.allConditionsForDisplay.length === 0) return false;
return this.clearFilterOptions ? !this.clearFilterOptions.isDisabled : true;
}
/**
* Checks if the filter expression can be saved as a view.
* @returns True if the filter instance can be saved, false otherwise.
*/
get canSaveView(): IFilterInstance<P, E>["canSaveView"] {
return this.hasActiveFilters && !!this.saveViewOptions && !this.saveViewOptions.isDisabled;
}
/**
* Checks if the filter expression can be updated as a view.
* @returns True if the filter expression can be updated, false otherwise.
*/
get canUpdateView(): IFilterInstance<P, E>["canUpdateView"] {
return (
!!this.updateViewOptions &&
(this.hasChanges || !!this.updateViewOptions.hasAdditionalChanges) &&
!this.updateViewOptions.isDisabled
);
}
// ------------ actions ------------
/**
* Resets the filter expression to the initial expression.
* @param externalExpression - The external expression to reset to.
*/
resetExpression: IFilterInstance<P, E>["resetExpression"] = action(
(externalExpression, shouldResetInitialExpression = true) => {
this.expression = this.helper.initializeExpression(externalExpression);
if (shouldResetInitialExpression) {
this._resetInitialFilterExpression();
}
this._notifyExpressionChange();
}
);
/**
* Finds all conditions by property and operator.
* @param property - The property to find the conditions by.
* @param operator - The operator to find the conditions by.
* @returns All the conditions that match the property and operator.
*/
findConditionsByPropertyAndOperator: IFilterInstance<P, E>["findConditionsByPropertyAndOperator"] = action(
(property, operator) => {
if (!this.expression) return [];
return findConditionsByPropertyAndOperator(this.expression, property, operator);
}
);
/**
* Finds the first condition by property and operator.
* @param property - The property to find the condition by.
* @param operator - The operator to find the condition by.
* @returns The first condition that matches the property and operator.
*/
findFirstConditionByPropertyAndOperator: IFilterInstance<P, E>["findFirstConditionByPropertyAndOperator"] = action(
(property, operator) => {
if (!this.expression) return undefined;
const conditions = findConditionsByPropertyAndOperator(this.expression, property, operator);
return conditions[0];
}
);
/**
* Adds a condition to the filter expression.
* @param groupOperator - The logical operator to use for the condition.
* @param condition - The condition to add.
* @param isNegation - Whether the condition should be negated.
*/
addCondition: IFilterInstance<P, E>["addCondition"] = action((groupOperator, condition, isNegation = false) => {
const conditionValue = condition.value;
this.expression = this.helper.addConditionToExpression(this.expression, groupOperator, condition, isNegation);
if (hasValidValue(conditionValue)) {
this._notifyExpressionChange();
}
});
/**
* Updates the operator of a condition in the filter expression.
* @param conditionId - The id of the condition to update.
* @param operator - The new operator for the condition.
*/
updateConditionOperator: IFilterInstance<P, E>["updateConditionOperator"] = action(
(conditionId: string, operator: TSupportedOperators, isNegation: boolean) => {
if (!this.expression) return;
const conditionBeforeUpdate = cloneDeep(findNodeById(this.expression, conditionId));
if (!conditionBeforeUpdate || conditionBeforeUpdate.type !== FILTER_NODE_TYPE.CONDITION) return;
// Get the operator configs for the current and new operators
const currentOperatorConfig = this.configManager
.getConfigByProperty(conditionBeforeUpdate.property)
?.getOperatorConfig(conditionBeforeUpdate.operator);
const newOperatorConfig = this.configManager
.getConfigByProperty(conditionBeforeUpdate.property)
?.getOperatorConfig(operator);
// Reset the value if the operator config types are different
const shouldResetConditionValue = currentOperatorConfig?.type !== newOperatorConfig?.type;
// Use restructuring logic for operator changes
const updatedExpression = this.helper.restructureExpressionForOperatorChange(
this.expression,
conditionId,
operator,
isNegation,
shouldResetConditionValue
);
if (updatedExpression) {
this.expression = updatedExpression;
}
if (hasValidValue(conditionBeforeUpdate.value)) {
this._notifyExpressionChange();
}
}
);
/**
* Updates the value of a condition in the filter expression with automatic optimization.
* @param conditionId - The id of the condition to update.
* @param value - The new value for the condition.
*/
updateConditionValue: IFilterInstance<P, E>["updateConditionValue"] = action(
<V extends TFilterValue>(conditionId: string, value: SingleOrArray<V>) => {
// If the expression is not valid, return
if (!this.expression) return;
// If the value is not valid, remove the condition
if (!hasValidValue(value)) {
this.removeCondition(conditionId);
return;
}
// Update the condition value
updateNodeInExpression(this.expression, conditionId, {
value,
});
// Notify the change
this._notifyExpressionChange();
}
);
/**
* Removes a condition from the filter expression.
* @param conditionId - The id of the condition to remove.
*/
removeCondition: IFilterInstance<P, E>["removeCondition"] = action((conditionId) => {
if (!this.expression) return;
const { expression, shouldNotify } = removeNodeFromExpression(this.expression, conditionId);
this.expression = expression;
if (shouldNotify) {
this._notifyExpressionChange();
}
});
/**
* Clears the filter expression.
*/
clearFilters: IFilterInstance<P, E>["clearFilters"] = action(async () => {
if (this.canClearFilters) {
const shouldNotify = shouldNotifyChangeForExpression(this.expression);
this.expression = null;
await this.clearFilterOptions?.onFilterClear();
if (shouldNotify) {
this._notifyExpressionChange();
}
} else {
console.warn("Cannot clear filters: invalid expression or missing options.");
}
});
/**
* Saves the filter expression.
*/
saveView: IFilterInstance<P, E>["saveView"] = action(async () => {
if (this.canSaveView && this.saveViewOptions) {
await this.saveViewOptions.onViewSave(this._getExternalExpression());
} else {
console.warn("Cannot save view: invalid expression or missing options.");
}
});
/**
* Updates the filter expression.
*/
updateView: IFilterInstance<P, E>["updateView"] = action(async () => {
if (this.canUpdateView && this.updateViewOptions) {
await this.updateViewOptions.onViewUpdate(this._getExternalExpression());
this._resetInitialFilterExpression();
} else {
console.warn("Cannot update view: invalid expression or missing options.");
}
});
/**
* Updates the expression options for the filter instance.
* This allows dynamic updates to options like isDisabled properties.
*/
updateExpressionOptions: IFilterInstance<P, E>["updateExpressionOptions"] = action((newOptions) => {
this.expressionOptions = {
...this.expressionOptions,
...newOptions,
};
});
// ------------ private helpers ------------
/**
* Resets the initial filter expression to the current expression.
*/
private _resetInitialFilterExpression(): void {
this.initialFilterExpression = cloneDeep(this.expression);
}
/**
* Returns the external filter representation of the filter instance.
* @returns The external filter representation of the filter instance.
*/
private _getExternalExpression = computedFn(() =>
this.adapter.toExternal(sanitizeAndStabilizeExpression(toJS(this.expression)))
);
/**
* Notifies the parent component of the expression change.
*/
private _notifyExpressionChange(): void {
this.onExpressionChange?.(this._getExternalExpression());
}
}

View file

@ -0,0 +1,2 @@
export * from "./adapter";
export * from "./filter";

View file

@ -0,0 +1 @@
export * from "./rich-filter.helper";

View file

@ -0,0 +1,47 @@
// plane imports
import {
LOGICAL_OPERATOR,
TBuildFilterExpressionParams,
TExternalFilter,
TFilterProperty,
TFilterValue,
} from "@plane/types";
import { getOperatorForPayload } from "@plane/utils";
// local imports
import { FilterInstance } from "../store/rich-filters/filter";
/**
* Builds a temporary filter expression from conditions.
* @param params.conditions - The conditions for building the filter expression.
* @param params.adapter - The adapter for building the filter expression.
* @returns The temporary filter expression.
*/
export const buildTempFilterExpressionFromConditions = <
P extends TFilterProperty,
V extends TFilterValue,
E extends TExternalFilter,
>(
params: TBuildFilterExpressionParams<P, V, E>
): E | undefined => {
const { conditions, adapter } = params;
let tempExpression: E | undefined = undefined;
const tempFilterInstance = new FilterInstance<P, E>({
adapter,
onExpressionChange: (expression) => {
tempExpression = expression;
},
});
for (const condition of conditions) {
const { operator, isNegation } = getOperatorForPayload(condition.operator);
tempFilterInstance.addCondition(
LOGICAL_OPERATOR.AND,
{
property: condition.property,
operator,
value: condition.value,
},
isNegation
);
}
return tempExpression;
};