[WEB-4951] [WEB-4884] feat: work item filters revamp (#7810)

This commit is contained in:
Prateek Shourya 2025-09-19 18:27:36 +05:30 committed by GitHub
parent e6a7ca4c72
commit 9aef5d4aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
160 changed files with 5879 additions and 4881 deletions

View file

@ -1 +1,2 @@
export * from "./rich-filters";
export * from "./work-item-filters";

View file

@ -28,8 +28,7 @@ type TOperatorOptionForDisplay = {
export interface IFilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue>
extends TFilterConfig<P, V> {
// computed
allSupportedOperators: TSupportedOperators[];
allSupportedOperatorConfigs: TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>][];
allEnabledSupportedOperators: TSupportedOperators[];
firstOperator: TSupportedOperators | undefined;
// computed functions
getOperatorConfig: (
@ -76,8 +75,7 @@ export class FilterConfig<P extends TFilterProperty, V extends TFilterValue = TF
supportedOperatorConfigsMap: observable,
allowMultipleFilters: observable,
// computed
allSupportedOperators: computed,
allSupportedOperatorConfigs: computed,
allEnabledSupportedOperators: computed,
firstOperator: computed,
// actions
mutate: action,
@ -90,16 +88,10 @@ export class FilterConfig<P extends TFilterProperty, V extends TFilterValue = TF
* 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());
get allEnabledSupportedOperators(): IFilterConfig<P, V>["allEnabledSupportedOperators"] {
return Array.from(this.supportedOperatorConfigsMap.entries())
.filter(([, operatorConfig]) => operatorConfig.isOperatorEnabled)
.map(([operator]) => operator);
}
/**
@ -107,7 +99,7 @@ export class FilterConfig<P extends TFilterProperty, V extends TFilterValue = TF
* @returns The first operator.
*/
get firstOperator(): IFilterConfig<P, V>["firstOperator"] {
return this.allSupportedOperators[0];
return this.allEnabledSupportedOperators[0];
}
// ------------ computed functions ------------
@ -168,7 +160,7 @@ export class FilterConfig<P extends TFilterProperty, V extends TFilterValue = TF
const operatorOptions: TOperatorOptionForDisplay[] = [];
// Process each supported operator to build display options
for (const operator of this.allSupportedOperators) {
for (const operator of this.allEnabledSupportedOperators) {
const displayOperator = this.getDisplayOperatorByValue(operator, value);
const displayOperatorLabel = this.getLabelForOperator(displayOperator);
operatorOptions.push({

View file

@ -0,0 +1,259 @@
// plane imports
import isEmpty from "lodash/isEmpty";
import {
LOGICAL_OPERATOR,
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
if (!WORK_ITEM_FILTER_PROPERTY_KEYS.includes(property as TWorkItemFilterProperty)) {
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);
const rawValue = data[key as TWorkItemFilterConditionKey];
if (typeof rawValue !== "string") {
console.error(`Filter value must be a string, got: ${typeof rawValue}`);
return null;
}
// Parse comma-separated values
const parsedValue = this._parseFilterValue(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: string): SingleOrArray<TFilterValue> => {
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,215 @@
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, IFilterInstance } from "../rich-filters/filter";
import { workItemFiltersAdapter } from "./adapter";
type TGetOrCreateFilterParams = {
entityId: string;
entityType: EIssuesStoreType;
expressionOptions?: TExpressionOptions<TWorkItemFilterExpression>;
initialExpression?: TWorkItemFilterExpression;
onExpressionChange?: (expression: TWorkItemFilterExpression) => void;
};
type TWorkItemFilterKey = `${EIssuesStoreType}-${string}`;
export interface IWorkItemFilterStore {
filters: Map<TWorkItemFilterKey, IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression>>; // key is the entity id (project, cycle, workspace, teamspace, etc)
getFilter: (
entityType: EIssuesStoreType,
entityId: string
) => IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression> | undefined;
getOrCreateFilter: (
params: TGetOrCreateFilterParams
) => IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression>;
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, IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression>>();
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;
}
return existingFilter;
}
// create new filter instance
const newFilter = this._initializeFilterInstance(params);
this.filters.set(this._getFilterKey(params.entityType, params.entityId), 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,
},
});
}

View file

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

View file

@ -1 +1,2 @@
export * from "./rich-filter.helper";
export * from "./work-item-filters.helper";

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;
};