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
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:
3
packages/shared-state/.eslintignore
Normal file
3
packages/shared-state/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
4
packages/shared-state/.eslintrc.js
Normal file
4
packages/shared-state/.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
};
|
||||
5
packages/shared-state/.prettierrc
Normal file
5
packages/shared-state/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
34
packages/shared-state/package.json
Normal file
34
packages/shared-state/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
2
packages/shared-state/src/index.ts
Normal file
2
packages/shared-state/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./store";
|
||||
export * from "./utils";
|
||||
2
packages/shared-state/src/store/index.ts
Normal file
2
packages/shared-state/src/store/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./rich-filters";
|
||||
export * from "./work-item-filters";
|
||||
31
packages/shared-state/src/store/rich-filters/adapter.ts
Normal file
31
packages/shared-state/src/store/rich-filters/adapter.ts
Normal 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;
|
||||
}
|
||||
187
packages/shared-state/src/store/rich-filters/config-manager.ts
Normal file
187
packages/shared-state/src/store/rich-filters/config-manager.ts
Normal 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 || {};
|
||||
}
|
||||
}
|
||||
204
packages/shared-state/src/store/rich-filters/config.ts
Normal file
204
packages/shared-state/src/store/rich-filters/config.ts
Normal 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;
|
||||
}
|
||||
275
packages/shared-state/src/store/rich-filters/filter-helpers.ts
Normal file
275
packages/shared-state/src/store/rich-filters/filter-helpers.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
558
packages/shared-state/src/store/rich-filters/filter.ts
Normal file
558
packages/shared-state/src/store/rich-filters/filter.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
2
packages/shared-state/src/store/rich-filters/index.ts
Normal file
2
packages/shared-state/src/store/rich-filters/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./adapter";
|
||||
export * from "./filter";
|
||||
136
packages/shared-state/src/store/user.store.ts
Normal file
136
packages/shared-state/src/store/user.store.ts
Normal 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([]);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
258
packages/shared-state/src/store/work-item-filters/adapter.ts
Normal file
258
packages/shared-state/src/store/work-item-filters/adapter.ts
Normal 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();
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./adapter";
|
||||
export * from "./filter.store";
|
||||
export * from "./shared";
|
||||
@@ -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>;
|
||||
28
packages/shared-state/src/store/workspace.store.ts
Normal file
28
packages/shared-state/src/store/workspace.store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
packages/shared-state/src/utils/index.ts
Normal file
2
packages/shared-state/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./rich-filter.helper";
|
||||
export * from "./work-item-filters.helper";
|
||||
47
packages/shared-state/src/utils/rich-filter.helper.ts
Normal file
47
packages/shared-state/src/utils/rich-filter.helper.ts
Normal 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;
|
||||
};
|
||||
32
packages/shared-state/src/utils/work-item-filters.helper.ts
Normal file
32
packages/shared-state/src/utils/work-item-filters.helper.ts
Normal 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;
|
||||
};
|
||||
12
packages/shared-state/tsconfig.json
Normal file
12
packages/shared-state/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user