Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
build/*
dist/*
out/*

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
};

View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@@ -0,0 +1,34 @@
{
"name": "@plane/shared-state",
"version": "1.1.0",
"license": "AGPL-3.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"check:lint": "eslint . --max-warnings 4",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"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-es": "catalog:",
"mobx": "catalog:",
"mobx-utils": "catalog:",
"uuid": "catalog:",
"zod": "^3.22.2"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./rich-filters";
export * from "./work-item-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,187 @@
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;
areConfigsReady: boolean;
// 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;
setAreConfigsReady: (value: boolean) => 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"];
areConfigsReady: IFilterConfigManager<P>["areConfigsReady"];
// parent filter instance
private _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);
this.areConfigsReady = true;
// parent filter instance
this._filterInstance = filterInstance;
makeObservable(this, {
filterConfigs: observable,
configOptions: observable,
areConfigsReady: observable,
// computed
allAvailableConfigs: computed,
// helpers
register: action,
registerAll: action,
updateConfigByProperty: action,
setAreConfigsReady: 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);
});
/**
* Updates the configs ready state.
* @param value - The new configs ready state.
*/
setAreConfigsReady: IFilterConfigManager<P>["setAreConfigsReady"] = action((value) => {
this.areConfigsReady = value;
});
// ------------ 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,204 @@
import { set } from "lodash-es";
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
allEnabledSupportedOperators: TSupportedOperators[];
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
allEnabledSupportedOperators: computed,
firstOperator: computed,
// actions
mutate: action,
});
}
// ------------ computed ------------
/**
* Returns all supported operators.
* @returns All supported operators.
*/
get allEnabledSupportedOperators(): IFilterConfig<P, V>["allEnabledSupportedOperators"] {
return Array.from(this.supportedOperatorConfigsMap.entries())
.filter(([, operatorConfig]) => operatorConfig.isOperatorEnabled)
.map(([operator]) => operator);
}
/**
* Returns the first operator.
* @returns The first operator.
*/
get firstOperator(): IFilterConfig<P, V>["firstOperator"] {
return this.allEnabledSupportedOperators[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.allEnabledSupportedOperators) {
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,275 @@
import { cloneDeep } from "lodash-es";
import { action, makeObservable, observable, toJS } from "mobx";
// plane imports
import { DEFAULT_FILTER_EXPRESSION_OPTIONS, TAutoVisibilityOptions, TExpressionOptions } from "@plane/constants";
import {
IFilterAdapter,
LOGICAL_OPERATOR,
TSupportedOperators,
TFilterConditionNode,
TFilterExpression,
TFilterValue,
TFilterProperty,
TExternalFilter,
TLogicalOperator,
TFilterConditionPayload,
} from "@plane/types";
import { addAndCondition, createConditionNode, updateNodeInExpression } from "@plane/utils";
// local imports
import { type IFilterInstance } from "./filter";
type TFilterInstanceHelperParams<P extends TFilterProperty, E extends TExternalFilter> = {
adapter: IFilterAdapter<P, E>;
};
/**
* 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> {
isVisible: boolean;
// initialization
initializeExpression: (initialExpression?: E) => TFilterExpression<P> | null;
initializeExpressionOptions: (expressionOptions?: Partial<TExpressionOptions<E>>) => TExpressionOptions<E>;
// visibility
setInitialVisibility: (visibilityOption: TAutoVisibilityOptions) => void;
toggleVisibility: (isVisible?: boolean) => void;
// condition operations
addConditionToExpression: <V extends TFilterValue>(
expression: TFilterExpression<P> | null,
groupOperator: TLogicalOperator,
condition: TFilterConditionPayload<P, V>,
isNegation: boolean
) => TFilterExpression<P> | null;
handleConditionPropertyUpdate: (
expression: TFilterExpression<P>,
conditionId: string,
property: P,
operator: TSupportedOperators,
isNegation: boolean
) => TFilterExpression<P> | null;
// group operations
restructureExpressionForOperatorChange: (
expression: TFilterExpression<P>,
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>
{
// parent filter instance
private _filterInstance: IFilterInstance<P, E>;
// adapter
private adapter: IFilterAdapter<P, E>;
// visibility
isVisible: boolean;
/**
* Creates a new FilterInstanceHelper instance.
*
* @param adapter - The filter adapter for converting between internal and external formats
*/
constructor(filterInstance: IFilterInstance<P, E>, params: TFilterInstanceHelperParams<P, E>) {
this._filterInstance = filterInstance;
this.adapter = params.adapter;
this.isVisible = false;
makeObservable(this, {
isVisible: observable,
setInitialVisibility: action,
toggleVisibility: action,
});
}
// ------------ 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,
});
/**
* Sets the initial visibility state for the filter based on options and active filters.
* @param visibilityOption - The visibility options for the filter instance.
* @returns The initial visibility state
*/
setInitialVisibility: IFilterInstanceHelper<P, E>["setInitialVisibility"] = action((visibilityOption) => {
// If explicit initial visibility is provided, use it
if (visibilityOption.autoSetVisibility === false) {
this.isVisible = visibilityOption.isVisibleOnMount;
return;
}
// If filter has active filters, make it visible
if (this._filterInstance.hasActiveFilters) {
this.isVisible = true;
return;
}
// Default to hidden if no active filters
this.isVisible = false;
return;
});
/**
* Toggles the visibility of the filter.
* @param isVisible - The visibility to set.
*/
toggleVisibility: IFilterInstanceHelper<P, E>["toggleVisibility"] = action((isVisible) => {
if (isVisible !== undefined) {
this.isVisible = isVisible;
return;
}
this.isVisible = !this.isVisible;
});
// ------------ 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));
/**
* Updates the property and operator of a condition in the filter expression.
* This method updates the property, operator, resets the value, and handles negation properly.
* @param expression - The filter expression to operate on
* @param conditionId - The ID of the condition being updated
* @param property - The new property for the condition
* @param operator - The new operator for the condition
* @param isNegation - Whether the condition should be negated
* @returns The updated expression
*/
handleConditionPropertyUpdate: IFilterInstanceHelper<P, E>["handleConditionPropertyUpdate"] = (
expression,
conditionId,
property,
operator,
isNegation
) => {
const payload = { property, operator, value: undefined };
return this._updateCondition(expression, conditionId, payload, 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
) => {
const payload = shouldResetValue ? { operator: newOperator, value: undefined } : { operator: newOperator };
return this._updateCondition(expression, conditionId, payload, isNegation);
};
// ------------ 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;
}
}
/**
* Updates a condition with the given payload and handles negation wrapping/unwrapping.
* @param expression - The filter expression to operate on
* @param conditionId - The ID of the condition being updated
* @param payload - The payload to update the condition with
* @param isNegation - Whether the condition should be negated
* @returns The updated expression with proper negation handling
*/
private _updateCondition = (
expression: TFilterExpression<P>,
conditionId: string,
payload: Partial<TFilterConditionNode<P, TFilterValue>>,
_isNegation: boolean
): TFilterExpression<P> | null => {
// Update the condition with the payload
updateNodeInExpression(expression, conditionId, payload);
return expression;
};
}

View File

@@ -0,0 +1,558 @@
import { cloneDeep, isEqual } from "lodash-es";
import { action, computed, makeObservable, observable, toJS } from "mobx";
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
// plane imports
import {
DEFAULT_FILTER_VISIBILITY_OPTIONS,
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;
isVisible: 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;
// visibility
toggleVisibility: (isVisible?: boolean) => void;
// 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;
updateConditionProperty: (
conditionId: string,
property: P,
operator: TSupportedOperators,
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;
}
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.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;
this.helper.setInitialVisibility(params.options?.visibility ?? DEFAULT_FILTER_VISIBILITY_OPTIONS);
makeObservable(this, {
// observables
id: observable,
initialFilterExpression: observable,
expression: observable,
expressionOptions: observable,
adapter: observable,
configManager: observable,
// computed
hasActiveFilters: computed,
hasChanges: computed,
isVisible: 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 the visibility of the filter instance.
* @returns The visibility of the filter instance.
*/
get isVisible(): IFilterInstance<P, E>["isVisible"] {
return this.helper.isVisible;
}
/**
* 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 ------------
/**
* Toggles the visibility of the filter instance.
* @param isVisible - The visibility to set.
*/
toggleVisibility: IFilterInstance<P, E>["toggleVisibility"] = action((isVisible) => {
this.helper.toggleVisibility(isVisible);
});
/**
* 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 property of a condition in the filter expression.
* @param conditionId - The id of the condition to update.
* @param property - The new property for the condition.
*/
updateConditionProperty: IFilterInstance<P, E>["updateConditionProperty"] = action(
(conditionId: string, property: P, operator: TSupportedOperators, isNegation: boolean) => {
if (!this.expression) return;
const conditionBeforeUpdate = cloneDeep(findNodeById(this.expression, conditionId));
if (!conditionBeforeUpdate || conditionBeforeUpdate.type !== FILTER_NODE_TYPE.CONDITION) return;
// Update the condition property
const updatedExpression = this.helper.handleConditionPropertyUpdate(
this.expression,
conditionId,
property,
operator,
isNegation
);
if (updatedExpression) {
this.expression = updatedExpression;
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;
// Get the condition before update
const conditionBeforeUpdate = cloneDeep(findNodeById(this.expression, conditionId));
// If the condition is not valid, return
if (!conditionBeforeUpdate || conditionBeforeUpdate.type !== FILTER_NODE_TYPE.CONDITION) return;
// If the value is not valid, remove the condition
if (!hasValidValue(value)) {
this.removeCondition(conditionId);
return;
}
// If the value is the same as the condition before update, return
if (isEqual(conditionBeforeUpdate.value, value)) {
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,136 @@
import { makeObservable, observable } from "mobx";
import { IWorkspaceStore } from "./workspace.store";
export interface IUserStore {
user: any;
workspaces: Map<string, IWorkspaceStore>;
isLoading: boolean;
error: any;
}
export class UserStore implements IUserStore {
user: any = null;
workspaces: Map<string, IWorkspaceStore> = new Map();
isLoading: boolean = false;
error: any = null;
constructor() {
makeObservable(this, {
user: observable.ref,
workspaces: observable,
isLoading: observable.ref,
error: observable.ref,
});
}
}
// userStore.ts
// class UserStore {
// user: User | null = null;
// workspaces: Workspace[] = [];
// isLoading = false;
// error: string | null = null;
// private indexedDBService: IndexedDBService;
// constructor() {
// makeAutoObservable(this);
// this.indexedDBService = new IndexedDBService();
// this.init();
// }
// private async init() {
// try {
// await this.indexedDBService.init();
// await this.loadWorkspacesFromIndexedDB();
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to initialize store";
// console.error("Store initialization error:", error);
// });
// }
// }
// setUser(user: User | null) {
// this.user = user;
// }
// async loadWorkspacesFromIndexedDB() {
// try {
// const workspaces = await this.indexedDBService.getWorkspaces();
// runInAction(() => {
// this.workspaces = workspaces;
// });
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to load workspaces from IndexedDB";
// console.error("Load workspaces error:", error);
// });
// }
// }
// async fetchAndSyncWorkspaces() {
// this.isLoading = true;
// this.error = null;
// try {
// // Simulate API call to fetch workspaces
// const response = await fetch("/api/workspaces");
// const workspaces = await response.json();
// // Save to IndexedDB
// await this.indexedDBService.saveWorkspaces(workspaces);
// // Update MobX store
// runInAction(() => {
// this.workspaces = workspaces;
// this.isLoading = false;
// });
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to fetch workspaces";
// this.isLoading = false;
// console.error("Fetch workspaces error:", error);
// });
// }
// }
// // Additional methods for workspace management
// async addWorkspace(workspace: Omit<Workspace, "id" | "createdAt" | "updatedAt">) {
// this.isLoading = true;
// this.error = null;
// try {
// // Simulate API call to create workspace
// const response = await fetch("/api/workspaces", {
// method: "POST",
// body: JSON.stringify(workspace),
// });
// const newWorkspace = await response.json();
// // Update local storage and state
// const updatedWorkspaces = [...this.workspaces, newWorkspace];
// await this.indexedDBService.saveWorkspaces(updatedWorkspaces);
// runInAction(() => {
// this.workspaces.push(newWorkspace);
// this.isLoading = false;
// });
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to add workspace";
// this.isLoading = false;
// console.error("Add workspace error:", error);
// });
// }
// }
// logout() {
// this.user = null;
// this.workspaces = [];
// // Optionally clear IndexedDB data
// this.indexedDBService.init().then(() => {
// this.indexedDBService.saveWorkspaces([]);
// });
// }
// }

View File

@@ -0,0 +1,258 @@
// plane imports
import { isEmpty } from "lodash-es";
import {
LOGICAL_OPERATOR,
MULTI_VALUE_OPERATORS,
SingleOrArray,
TFilterExpression,
TFilterValue,
TSupportedOperators,
TWorkItemFilterConditionData,
TWorkItemFilterConditionKey,
TWorkItemFilterExpression,
TWorkItemFilterExpressionData,
TWorkItemFilterProperty,
WORK_ITEM_FILTER_PROPERTY_KEYS,
} from "@plane/types";
import { createConditionNode, createAndGroupNode, isAndGroupNode, isConditionNode } from "@plane/utils";
// local imports
import { FilterAdapter } from "../rich-filters/adapter";
class WorkItemFiltersAdapter extends FilterAdapter<TWorkItemFilterProperty, TWorkItemFilterExpression> {
/**
* Converts external work item filter expression to internal filter tree
* @param externalFilter - The external filter expression
* @returns Internal filter expression or null
*/
toInternal(externalFilter: TWorkItemFilterExpression): TFilterExpression<TWorkItemFilterProperty> | null {
if (!externalFilter || isEmpty(externalFilter)) return null;
try {
return this._convertExpressionToInternal(externalFilter);
} catch (error) {
console.error("Failed to convert external filter to internal:", error);
return null;
}
}
/**
* Recursively converts external expression data to internal filter tree
* @param expression - The external expression data
* @returns Internal filter expression
*/
private _convertExpressionToInternal(
expression: TWorkItemFilterExpressionData
): TFilterExpression<TWorkItemFilterProperty> {
if (!expression || isEmpty(expression)) {
throw new Error("Invalid expression: empty or null data");
}
// Check if it's a simple condition (has field property)
if (this._isWorkItemFilterConditionData(expression)) {
const conditionResult = this._extractWorkItemFilterConditionData(expression);
if (!conditionResult) {
throw new Error("Failed to extract condition data");
}
const [property, operator, value] = conditionResult;
return createConditionNode({
property,
operator,
value,
});
}
// It's a logical group - check which type
const expressionKeys = Object.keys(expression);
if (LOGICAL_OPERATOR.AND in expression) {
const andExpression = expression as { [LOGICAL_OPERATOR.AND]: TWorkItemFilterExpressionData[] };
const andConditions = andExpression[LOGICAL_OPERATOR.AND];
if (!Array.isArray(andConditions) || andConditions.length === 0) {
throw new Error("AND group must contain at least one condition");
}
const convertedConditions = andConditions.map((item) => this._convertExpressionToInternal(item));
return createAndGroupNode(convertedConditions);
}
throw new Error(`Invalid expression: unknown structure with keys [${expressionKeys.join(", ")}]`);
}
/**
* Converts internal filter expression to external format
* @param internalFilter - The internal filter expression
* @returns External filter expression
*/
toExternal(internalFilter: TFilterExpression<TWorkItemFilterProperty>): TWorkItemFilterExpression {
if (!internalFilter) {
return {};
}
try {
return this._convertExpressionToExternal(internalFilter);
} catch (error) {
console.error("Failed to convert internal filter to external:", error);
return {};
}
}
/**
* Recursively converts internal expression to external format
* @param expression - The internal filter expression
* @returns External expression data
*/
private _convertExpressionToExternal(
expression: TFilterExpression<TWorkItemFilterProperty>
): TWorkItemFilterExpressionData {
if (isConditionNode(expression)) {
return this._createWorkItemFilterConditionData(expression.property, expression.operator, expression.value);
}
// It's a group node
if (isAndGroupNode(expression)) {
return {
[LOGICAL_OPERATOR.AND]: expression.children.map((child) => this._convertExpressionToExternal(child)),
} as TWorkItemFilterExpressionData;
}
throw new Error(`Unknown group node type for expression`);
}
/**
* Type guard to check if data is of type TWorkItemFilterConditionData
* @param data - The data to check
* @returns True if data is TWorkItemFilterConditionData, false otherwise
*/
private _isWorkItemFilterConditionData = (data: unknown): data is TWorkItemFilterConditionData => {
if (!data || typeof data !== "object" || isEmpty(data)) return false;
const keys = Object.keys(data);
if (keys.length === 0) return false;
// Check if any key contains logical operators (would indicate it's a group)
const hasLogicalOperators = keys.some((key) => key === LOGICAL_OPERATOR.AND);
if (hasLogicalOperators) return false;
// All keys must match the work item filter condition key pattern
return keys.every((key) => this._isValidWorkItemFilterConditionKey(key));
};
/**
* Validates if a key is a valid work item filter condition key
* @param key - The key to validate
* @returns True if the key is valid
*/
private _isValidWorkItemFilterConditionKey = (key: string): key is TWorkItemFilterConditionKey => {
if (typeof key !== "string" || key.length === 0) return false;
// Find the last occurrence of '__' to separate property from operator
const lastDoubleUnderscoreIndex = key.lastIndexOf("__");
if (
lastDoubleUnderscoreIndex === -1 ||
lastDoubleUnderscoreIndex === 0 ||
lastDoubleUnderscoreIndex === key.length - 2
) {
return false;
}
const property = key.substring(0, lastDoubleUnderscoreIndex);
const operator = key.substring(lastDoubleUnderscoreIndex + 2);
// Validate property is in allowed list
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!WORK_ITEM_FILTER_PROPERTY_KEYS.includes(property as any) && !property.startsWith("customproperty_")) {
return false;
}
// Validate operator is not empty
return operator.length > 0;
};
/**
* Extracts property, operator and value from work item filter condition data
* @param data - The condition data
* @returns Tuple of property, operator and value, or null if invalid
*/
private _extractWorkItemFilterConditionData = (
data: TWorkItemFilterConditionData
): [TWorkItemFilterProperty, TSupportedOperators, SingleOrArray<TFilterValue>] | null => {
const keys = Object.keys(data);
if (keys.length !== 1) {
console.error("Work item filter condition data must have exactly one key");
return null;
}
const key = keys[0];
if (!this._isValidWorkItemFilterConditionKey(key)) {
console.error(`Invalid work item filter condition key: ${key}`);
return null;
}
// Find the last occurrence of '__' to separate property from operator
const lastDoubleUnderscoreIndex = key.lastIndexOf("__");
const property = key.substring(0, lastDoubleUnderscoreIndex);
const operator = key.substring(lastDoubleUnderscoreIndex + 2) as TSupportedOperators;
const rawValue = data[key as TWorkItemFilterConditionKey];
// Parse comma-separated values
const parsedValue = MULTI_VALUE_OPERATORS.includes(operator) ? this._parseFilterValue(rawValue) : rawValue;
return [property as TWorkItemFilterProperty, operator as TSupportedOperators, parsedValue];
};
/**
* Parses filter value from string format
* @param value - The string value to parse
* @returns Parsed value as string or array of strings
*/
private _parseFilterValue = (value: TFilterValue): SingleOrArray<TFilterValue> => {
if (!value) return value;
if (typeof value !== "string") return value;
// Handle empty string
if (value === "") return value;
// Split by comma if contains comma, otherwise return as single value
if (value.includes(",")) {
// Split and trim each value, filter out empty strings
const splitValues = value
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
// Return single value if only one non-empty value after split
return splitValues.length === 1 ? splitValues[0] : splitValues;
}
return value;
};
/**
* Creates TWorkItemFilterConditionData from property, operator and value
* @param property - The filter property key
* @param operator - The filter operator
* @param value - The filter value
* @returns The condition data object
*/
private _createWorkItemFilterConditionData = (
property: TWorkItemFilterProperty,
operator: TSupportedOperators,
value: SingleOrArray<TFilterValue>
): TWorkItemFilterConditionData => {
const conditionKey = `${property}__${operator}` as TWorkItemFilterConditionKey;
// Convert value to string format
const stringValue = Array.isArray(value) ? value.join(",") : value;
return {
[conditionKey]: stringValue,
} as TWorkItemFilterConditionData;
};
}
export const workItemFiltersAdapter = new WorkItemFiltersAdapter();

View File

@@ -0,0 +1,218 @@
import { action, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { TExpressionOptions } from "@plane/constants";
import { EIssuesStoreType, LOGICAL_OPERATOR, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
import { getOperatorForPayload } from "@plane/utils";
// local imports
import { buildWorkItemFilterExpressionFromConditions, TWorkItemFilterCondition } from "../../utils";
import { FilterInstance } from "../rich-filters/filter";
import { workItemFiltersAdapter } from "./adapter";
import { IWorkItemFilterInstance, TWorkItemFilterKey } from "./shared";
type TGetOrCreateFilterParams = {
showOnMount?: boolean;
entityId: string;
entityType: EIssuesStoreType;
expressionOptions?: TExpressionOptions<TWorkItemFilterExpression>;
initialExpression?: TWorkItemFilterExpression;
onExpressionChange?: (expression: TWorkItemFilterExpression) => void;
};
export interface IWorkItemFilterStore {
filters: Map<TWorkItemFilterKey, IWorkItemFilterInstance>; // key is the entity id (project, cycle, workspace, teamspace, etc)
getFilter: (entityType: EIssuesStoreType, entityId: string) => IWorkItemFilterInstance | undefined;
getOrCreateFilter: (params: TGetOrCreateFilterParams) => IWorkItemFilterInstance;
resetExpression: (entityType: EIssuesStoreType, entityId: string, expression: TWorkItemFilterExpression) => void;
updateFilterExpressionFromConditions: (
entityType: EIssuesStoreType,
entityId: string,
conditions: TWorkItemFilterCondition[],
fallbackFn: (expression: TWorkItemFilterExpression) => Promise<void>
) => Promise<void>;
updateFilterValueFromSidebar: (
entityType: EIssuesStoreType,
entityId: string,
condition: TWorkItemFilterCondition
) => void;
deleteFilter: (entityType: EIssuesStoreType, entityId: string) => void;
}
export class WorkItemFilterStore implements IWorkItemFilterStore {
// observable
filters: IWorkItemFilterStore["filters"];
constructor() {
this.filters = new Map<TWorkItemFilterKey, IWorkItemFilterInstance>();
makeObservable(this, {
filters: observable,
getOrCreateFilter: action,
resetExpression: action,
updateFilterExpressionFromConditions: action,
deleteFilter: action,
});
}
// ------------ computed functions ------------
/**
* Returns a filter instance.
* @param entityType - The entity type.
* @param entityId - The entity id.
* @returns The filter instance.
*/
getFilter: IWorkItemFilterStore["getFilter"] = computedFn((entityType, entityId) =>
this.filters.get(this._getFilterKey(entityType, entityId))
);
// ------------ actions ------------
/**
* Gets or creates a new filter instance.
* If the instance already exists, updates its expression options to ensure they're current.
*/
getOrCreateFilter: IWorkItemFilterStore["getOrCreateFilter"] = action((params) => {
const existingFilter = this.getFilter(params.entityType, params.entityId);
if (existingFilter) {
// Update expression options on existing filter to ensure they're current
if (params.expressionOptions) {
existingFilter.updateExpressionOptions(params.expressionOptions);
}
// Update callback if provided
if (params.onExpressionChange) {
existingFilter.onExpressionChange = params.onExpressionChange;
}
// Update visibility if provided
if (params.showOnMount !== undefined) {
existingFilter.toggleVisibility(params.showOnMount);
}
return existingFilter;
}
// create new filter instance
const newFilter = this._initializeFilterInstance(params);
const filterKey = this._getFilterKey(params.entityType, params.entityId);
this.filters.set(filterKey, newFilter);
return newFilter;
});
/**
* Resets the initial expression for a filter instance.
* @param entityType - The entity type.
* @param entityId - The entity id.
* @param expression - The expression to update.
*/
resetExpression: IWorkItemFilterStore["resetExpression"] = action((entityType, entityId, expression) => {
const filter = this.getFilter(entityType, entityId);
if (filter) {
filter.resetExpression(expression);
}
});
/**
* Updates the filter expression from conditions.
* @param entityType - The entity type.
* @param entityId - The entity id.
* @param conditions - The conditions to update.
* @param fallbackFn - The fallback function to update the expression if the filter instance does not exist.
*/
updateFilterExpressionFromConditions: IWorkItemFilterStore["updateFilterExpressionFromConditions"] = action(
async (entityType, entityId, conditions, fallbackFn) => {
const filter = this.getFilter(entityType, entityId);
const newFilterExpression = buildWorkItemFilterExpressionFromConditions({
conditions,
});
if (!newFilterExpression) return;
// Update the filter expression using the filter instance if it exists, otherwise use the fallback function
if (filter) {
filter.resetExpression(newFilterExpression, false);
} else {
await fallbackFn(newFilterExpression);
}
}
);
/**
* Handles sidebar filter updates by adding new conditions or updating existing ones.
* This method processes filter conditions from the sidebar UI and applies them to the
* appropriate filter instance, handling both positive and negative operators correctly.
*
* @param entityType - The entity type (e.g., project, cycle, module)
* @param entityId - The unique identifier for the entity
* @param condition - The filter condition containing property, operator, and value
*/
updateFilterValueFromSidebar: IWorkItemFilterStore["updateFilterValueFromSidebar"] = action(
(entityType, entityId, condition) => {
// Retrieve the filter instance for the specified entity
const filter = this.getFilter(entityType, entityId);
// Early return if filter instance doesn't exist
if (!filter) {
console.warn(
`Cannot handle sidebar filters update: filter instance not found for entity type "${entityType}" with ID "${entityId}"`
);
return;
}
// Check for existing conditions with the same property and operator
const conditionNode = filter.findFirstConditionByPropertyAndOperator(condition.property, condition.operator);
// No existing condition found - add new condition with AND logic
if (!conditionNode) {
const { operator, isNegation } = getOperatorForPayload(condition.operator);
// Create the condition payload with normalized operator
const conditionPayload = {
property: condition.property,
operator,
value: condition.value,
};
filter.addCondition(LOGICAL_OPERATOR.AND, conditionPayload, isNegation);
return;
}
// Update existing condition (assuming single condition per property-operator pair)
filter.updateConditionValue(conditionNode.id, condition.value);
}
);
/**
* Deletes a filter instance.
* @param entityType - The entity type.
* @param entityId - The entity id.
*/
deleteFilter: IWorkItemFilterStore["deleteFilter"] = action((entityType, entityId) => {
this.filters.delete(this._getFilterKey(entityType, entityId));
});
// ------------ private helpers ------------
/**
* Returns a filter key.
* @param entityType - The entity type.
* @param entityId - The entity id.s
* @returns The filter key.
*/
_getFilterKey = (entityType: EIssuesStoreType, entityId: string): TWorkItemFilterKey => `${entityType}-${entityId}`;
/**
* Initializes a filter instance.
* @param params - The parameters for the filter instance.
* @returns The filter instance.
*/
_initializeFilterInstance = (params: TGetOrCreateFilterParams) =>
new FilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression>({
adapter: workItemFiltersAdapter,
initialExpression: params.initialExpression,
onExpressionChange: params.onExpressionChange,
options: {
expression: params.expressionOptions,
visibility: params.showOnMount
? { autoSetVisibility: false, isVisibleOnMount: true }
: { autoSetVisibility: true },
},
});
}

View File

@@ -0,0 +1,3 @@
export * from "./adapter";
export * from "./filter.store";
export * from "./shared";

View File

@@ -0,0 +1,8 @@
// plane imports
import { EIssuesStoreType, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
// local imports
import { IFilterInstance } from "../rich-filters";
export type TWorkItemFilterKey = `${EIssuesStoreType}-${string}`;
export type IWorkItemFilterInstance = IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression>;

View File

@@ -0,0 +1,28 @@
import { makeObservable, observable } from "mobx";
export interface IWorkspaceStore {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
export class WorkspaceStore implements IWorkspaceStore {
id: string;
name: string;
createdAt: string;
updatedAt: string;
constructor(data: IWorkspaceStore) {
makeObservable(this, {
id: observable.ref,
name: observable.ref,
createdAt: observable.ref,
updatedAt: observable.ref,
});
this.id = data.id;
this.name = data.name;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}

View File

@@ -0,0 +1,2 @@
export * from "./rich-filter.helper";
export * from "./work-item-filters.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;
};

View File

@@ -0,0 +1,32 @@
// plane imports
import {
TBuildFilterExpressionParams,
TFilterConditionForBuild,
TFilterValue,
TWorkItemFilterExpression,
TWorkItemFilterProperty,
} from "@plane/types";
// local imports
import { workItemFiltersAdapter } from "../store/work-item-filters/adapter";
import { buildTempFilterExpressionFromConditions } from "./rich-filter.helper";
export type TWorkItemFilterCondition = TFilterConditionForBuild<TWorkItemFilterProperty, TFilterValue>;
/**
* Builds a work item filter expression from conditions.
* @param params.conditions - The conditions for building the filter expression.
* @returns The work item filter expression.
*/
export const buildWorkItemFilterExpressionFromConditions = (
params: Omit<
TBuildFilterExpressionParams<TWorkItemFilterProperty, TFilterValue, TWorkItemFilterExpression>,
"adapter"
>
): TWorkItemFilterExpression | undefined => {
const workItemFilterExpression = buildTempFilterExpressionFromConditions({
...params,
adapter: workItemFiltersAdapter,
});
if (!workItemFilterExpression) console.error("Failed to build work item filter expression from conditions");
return workItemFilterExpression;
};

View File

@@ -0,0 +1,12 @@
{
"extends": "@plane/typescript-config/react-library.json",
"compilerOptions": {
"jsx": "react",
"lib": ["esnext", "dom"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["./src"],
"exclude": ["dist", "build", "node_modules"]
}