diff --git a/packages/backend.ai-ui/src/components/BAIGraphQLPropertyFilter.stories.tsx b/packages/backend.ai-ui/src/components/BAIGraphQLPropertyFilter.stories.tsx new file mode 100644 index 0000000000..ba756eb882 --- /dev/null +++ b/packages/backend.ai-ui/src/components/BAIGraphQLPropertyFilter.stories.tsx @@ -0,0 +1,745 @@ +import BAIGraphQLPropertyFilter from './BAIGraphQLPropertyFilter'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +const meta: Meta = { + title: 'Components/BAIGraphQLPropertyFilter', + component: BAIGraphQLPropertyFilter, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +**BAIGraphQLPropertyFilter** is an advanced filtering component designed for GraphQL-based Backend.AI applications. It provides a sophisticated interface for constructing GraphQL filter objects with support for: + +- **GraphQL Filter Types**: Compatible with standard GraphQL filter schemas including StringFilter, NumberFilter, BooleanFilter, and EnumFilter +- **Flexible Combination Mode**: Choose between AND or OR operators to combine multiple filter conditions +- **Rich Operator Set**: Comprehensive operators like eq, ne, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn +- **Type-Safe Filtering**: Automatic type detection and operator suggestions based on property types +- **Bidirectional Conversion**: Seamless conversion between UI conditions and GraphQL filter objects + +New in this version: +- Operatorless fields via valueMode: 'scalar' for properties that should emit direct scalar values (e.g., { isUrgent: true }). Use implicitOperator (defaults to 'eq') to control how tags are displayed in the UI. + +The component generates GraphQL-compatible filter objects that can be directly used in GraphQL queries, enabling powerful and flexible data filtering across the platform. + +**GraphQL Filter Object Examples:** +\`\`\`javascript +// Simple string filter +{ name: { contains: "john" } } +{ name: { equals: "john" } } // exact match with new GraphQL format +{ name: { iContains: "JOHN" } } // case-insensitive contains + +// Number filter +{ score: { greaterThan: 80 } } // number comparison with new GraphQL format +{ price: { lessOrEqual: 100 } } // number comparison + +// Boolean filter +{ active: true } + +// Filters combined with AND (all conditions must match) +{ + AND: [ + { name: { contains: "john" } }, + { status: { in: ["ACTIVE", "PENDING"] } }, + { priority: { equals: "HIGH" } } // exact match + ] +} + +// Filters combined with OR (any condition can match) +{ + OR: [ + { status: { equals: "URGENT" } }, // exact match + { priority: { equals: "HIGH" } }, // exact match + { assignee: { iEquals: "JOHN" } } // case-insensitive exact match + ] +} +\`\`\` + `, + }, + }, + }, + argTypes: { + filterProperties: { + description: 'Array of filterable properties with their configuration', + control: { type: 'object' }, + table: { + type: { summary: 'FilterProperty[]' }, + detail: ` +FilterProperty = { + key: string; // Property key in the GraphQL schema + propertyLabel: string; // Display label for the property + type: 'string' | 'number' | 'boolean' | 'enum'; + operators?: FilterOperator[]; // Available operators for this property + defaultOperator?: FilterOperator; + options?: AutoCompleteProps['options']; // Autocomplete suggestions + strictSelection?: boolean; // Require selection from options + rule?: { // Validation rule + message: string; + validate: (value: any) => boolean; + }; + // Serialization mode for this property: + // - 'scalar': emit { [key]: value } (operatorless). Default for boolean. + // - 'operator': emit { [key]: { op: value } }. Default for non-boolean. + valueMode?: 'scalar' | 'operator'; + // Visual operator for UI tags when valueMode='scalar' (default 'eq') + implicitOperator?: FilterOperator; +} + `, + }, + }, + value: { + control: { type: 'object' }, + description: 'Current GraphQL filter object', + table: { + type: { summary: 'GraphQLFilter' }, + detail: ` +GraphQLFilter = { + [property: string]: FilterValue; + AND?: GraphQLFilter[]; + OR?: GraphQLFilter[]; +} + `, + }, + }, + onChange: { + description: 'Callback when filter value changes', + table: { + type: { summary: '(value: GraphQLFilter | undefined) => void' }, + }, + }, + loading: { + control: { type: 'boolean' }, + description: 'Show loading state', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + }, + combinationMode: { + control: { type: 'radio' }, + options: ['AND', 'OR'], + description: 'How to combine multiple filter conditions', + table: { + type: { summary: "'AND' | 'OR'" }, + defaultValue: { summary: 'AND' }, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Basic Usage', + parameters: { + docs: { + description: { + story: + 'Basic GraphQL property filter with string and boolean properties. Try adding filters and see how they combine into a GraphQL filter object.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'name', + propertyLabel: 'Name', + type: 'string', + defaultOperator: 'contains', + }, + { + key: 'description', + propertyLabel: 'Description', + type: 'string', + }, + { + key: 'isActive', + propertyLabel: 'Active Status', + type: 'boolean', + }, + ], + combinationMode: 'AND', + onChange: action('Filter changed'), + }, +}; + +export const WithANDCombination: Story = { + name: 'Multiple Filters with AND', + parameters: { + docs: { + description: { + story: + 'Demonstrates filters combined with AND operator. All conditions must be satisfied for a match. This is useful when you need strict filtering.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'title', + propertyLabel: 'Title', + type: 'string', + }, + { + key: 'priority', + propertyLabel: 'Priority', + type: 'enum', + options: [ + { label: 'High', value: 'HIGH' }, + { label: 'Medium', value: 'MEDIUM' }, + { label: 'Low', value: 'LOW' }, + ], + }, + { + key: 'isUrgent', + propertyLabel: 'Urgent', + type: 'boolean', + }, + ], + combinationMode: 'AND', + value: { + AND: [ + { title: { contains: 'critical' } }, + { priority: { equals: 'HIGH' } }, + { isUrgent: true }, + ], + }, + onChange: action('AND Filter changed'), + }, +}; + +export const WithORCombination: Story = { + name: 'Multiple Filters with OR', + parameters: { + docs: { + description: { + story: + 'Demonstrates filters combined with OR operator. Any condition can match for a result. This is useful for more flexible, inclusive filtering.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'status', + propertyLabel: 'Status', + type: 'enum', + options: [ + { label: 'Urgent', value: 'URGENT' }, + { label: 'High Priority', value: 'HIGH_PRIORITY' }, + { label: 'Normal', value: 'NORMAL' }, + ], + }, + { + key: 'assignee', + propertyLabel: 'Assignee', + type: 'string', + }, + { + key: 'dueToday', + propertyLabel: 'Due Today', + type: 'boolean', + }, + ], + combinationMode: 'OR', + value: { + OR: [ + { status: { equals: 'URGENT' } }, + { assignee: { contains: 'john' } }, + { dueToday: true }, + ], + }, + onChange: action('OR Filter changed'), + }, +}; + +export const WithNumberFilters: Story = { + name: 'Number Filters with Comparisons', + parameters: { + docs: { + description: { + story: + 'Shows numeric filtering with comparison operators like greater than, less than, etc. Useful for filtering by quantities, scores, or metrics.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'score', + propertyLabel: 'Score', + type: 'number', + operators: [ + 'equals', + 'notEquals', + 'greaterThan', + 'greaterOrEqual', + 'lessThan', + 'lessOrEqual', + ], + }, + { + key: 'quantity', + propertyLabel: 'Quantity', + type: 'number', + }, + { + key: 'price', + propertyLabel: 'Price', + type: 'number', + operators: ['greaterThan', 'lessThan', 'equals'], + defaultOperator: 'greaterThan', + }, + ], + combinationMode: 'AND', + value: { + AND: [{ score: { greaterOrEqual: 80 } }, { quantity: { lessThan: 100 } }], + }, + onChange: action('Number filter changed'), + }, +}; + +export const WithEnumFilters: Story = { + name: 'Enum Filters with Multiple Selection', + parameters: { + docs: { + description: { + story: + 'Demonstrates enum type filtering with in/notIn operators for multiple value selection. Perfect for status fields, categories, or any predefined set of values.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'status', + propertyLabel: 'Status', + type: 'enum', + options: [ + { label: 'Active', value: 'ACTIVE' }, + { label: 'Inactive', value: 'INACTIVE' }, + { label: 'Pending', value: 'PENDING' }, + { label: 'Archived', value: 'ARCHIVED' }, + ], + operators: ['equals', 'notEquals', 'in', 'notIn'], + strictSelection: true, + }, + { + key: 'category', + propertyLabel: 'Category', + type: 'enum', + options: [ + { label: 'Frontend', value: 'FRONTEND' }, + { label: 'Backend', value: 'BACKEND' }, + { label: 'Database', value: 'DATABASE' }, + { label: 'DevOps', value: 'DEVOPS' }, + ], + defaultOperator: 'in', + }, + ], + combinationMode: 'AND', + value: { + AND: [ + { status: { in: ['ACTIVE', 'PENDING'] } }, + { category: { notEquals: 'DATABASE' } }, + ], + }, + onChange: action('Enum filter changed'), + }, +}; + +export const ComplexFilter: Story = { + name: 'Complex Combined Filter', + parameters: { + docs: { + description: { + story: + 'Example showing multiple filters with different property types combined with the selected mode (AND/OR) for comprehensive filtering.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'name', + propertyLabel: 'Name', + type: 'string', + }, + { + key: 'email', + propertyLabel: 'Email', + type: 'string', + operators: ['contains', 'startsWith', 'endsWith'], + }, + { + key: 'role', + propertyLabel: 'Role', + type: 'enum', + options: [ + { label: 'Admin', value: 'ADMIN' }, + { label: 'User', value: 'USER' }, + { label: 'Guest', value: 'GUEST' }, + ], + }, + { + key: 'credits', + propertyLabel: 'Credits', + type: 'number', + }, + { + key: 'isVerified', + propertyLabel: 'Verified', + type: 'boolean', + }, + ], + combinationMode: 'AND', + value: { + AND: [ + { name: { contains: 'john' } }, + { email: { endsWith: '@company.com' } }, + { role: { equals: 'USER' } }, + { credits: { greaterOrEqual: 100 } }, + { isVerified: true }, + ], + }, + onChange: action('Complex filter changed'), + }, +}; + +export const WithValidation: Story = { + name: 'Custom Validation Rules', + parameters: { + docs: { + description: { + story: + 'Property filter with custom validation rules for data integrity. Shows email validation and strict selection enforcement.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'email', + propertyLabel: 'Email Address', + type: 'string', + rule: { + message: 'Please enter a valid email address', + validate: (value: string) => /\S+@\S+\.\S+/.test(value), + }, + }, + { + key: 'phone', + propertyLabel: 'Phone Number', + type: 'string', + rule: { + message: 'Phone number must be 10 digits', + validate: (value: string) => + /^\d{10}$/.test(value.replace(/\D/g, '')), + }, + }, + { + key: 'department', + propertyLabel: 'Department', + type: 'enum', + options: [ + { label: 'Engineering', value: 'ENGINEERING' }, + { label: 'Marketing', value: 'MARKETING' }, + { label: 'Sales', value: 'SALES' }, + { label: 'HR', value: 'HR' }, + ], + strictSelection: true, + }, + ], + combinationMode: 'AND', + onChange: action('Validated filter changed'), + }, +}; + +export const WithAutocompleteOptions: Story = { + name: 'Autocomplete Suggestions', + parameters: { + docs: { + description: { + story: + 'Filter with predefined autocomplete options for improved user experience and data consistency.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'country', + propertyLabel: 'Country', + type: 'string', + options: [ + { label: 'United States', value: 'US' }, + { label: 'United Kingdom', value: 'UK' }, + { label: 'Canada', value: 'CA' }, + { label: 'Australia', value: 'AU' }, + { label: 'Germany', value: 'DE' }, + { label: 'France', value: 'FR' }, + ], + }, + { + key: 'language', + propertyLabel: 'Language', + type: 'string', + options: [ + { label: 'English', value: 'en' }, + { label: 'Spanish', value: 'es' }, + { label: 'French', value: 'fr' }, + { label: 'German', value: 'de' }, + { label: 'Chinese', value: 'zh' }, + { label: 'Japanese', value: 'ja' }, + ], + defaultOperator: 'in', + }, + ], + combinationMode: 'OR', + value: { + OR: [{ country: { eq: 'US' } }, { language: { in: ['en', 'es'] } }], + }, + onChange: action('Autocomplete filter changed'), + }, +}; + +export const EmptyState: Story = { + parameters: { + docs: { + description: { + story: + 'GraphQL property filter in its initial state with no applied filters. Start adding filters to see how they combine.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'title', + propertyLabel: 'Title', + type: 'string', + }, + { + key: 'isPublished', + propertyLabel: 'Published', + type: 'boolean', + }, + { + key: 'viewCount', + propertyLabel: 'View Count', + type: 'number', + }, + ], + combinationMode: 'AND', + onChange: action('Filter changed from empty'), + }, +}; + +export const LoadingState: Story = { + parameters: { + docs: { + description: { + story: + 'Filter component in loading state, typically shown while fetching schema information or processing complex queries.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'name', + propertyLabel: 'Name', + type: 'string', + }, + ], + loading: true, + combinationMode: 'AND', + onChange: action('Filter changed'), + }, +}; + +export const ArtifactFilterExample: Story = { + name: 'Artifact Filter (Real-world Example)', + parameters: { + docs: { + description: { + story: + 'Real-world example matching the ArtifactFilter GraphQL input type with name and status filtering capabilities. Shows how the component would be used in production.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'name', + propertyLabel: 'Artifact Name', + type: 'string', + operators: [ + 'equals', + 'notEquals', + 'contains', + 'startsWith', + 'endsWith', + ], + defaultOperator: 'contains', + }, + { + key: 'status', + propertyLabel: 'Artifact Status', + type: 'enum', + options: [ + { label: 'Draft', value: 'DRAFT' }, + { label: 'Published', value: 'PUBLISHED' }, + { label: 'Archived', value: 'ARCHIVED' }, + { label: 'Deleted', value: 'DELETED' }, + ], + operators: ['equals', 'in'], + strictSelection: true, + }, + ], + combinationMode: 'AND', + value: { + AND: [ + { name: { contains: 'model' } }, + { status: { in: ['PUBLISHED', 'DRAFT'] } }, + ], + }, + onChange: action('Artifact filter changed'), + }, +}; + +export const WithFixedOperator: Story = { + name: 'Fixed Operator (No Selector)', + parameters: { + docs: { + description: { + story: + 'Demonstrates properties with fixed operators where the operator selector is hidden. Useful when you want to enforce a specific operator for certain fields.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'search', + propertyLabel: 'Search (always contains)', + type: 'string', + fixedOperator: 'contains', // Only allows 'contains' operator + }, + { + key: 'username', + propertyLabel: 'Username (always equals)', + type: 'string', + fixedOperator: 'equals', // Only allows exact match + }, + { + key: 'tags', + propertyLabel: 'Tags (always in)', + type: 'string', + fixedOperator: 'in', // Only allows 'in' operator for multiple values + }, + { + key: 'score', + propertyLabel: 'Score (flexible)', + type: 'number', + // No fixedOperator, so operator selector is shown + operators: [ + 'equals', + 'greaterThan', + 'greaterOrEqual', + 'lessThan', + 'lessOrEqual', + ], + }, + ], + combinationMode: 'AND', + onChange: action('Fixed operator filter changed'), + }, +}; + +export const ToggleCombinationMode: Story = { + name: 'Toggle Between AND/OR', + parameters: { + docs: { + description: { + story: + 'Example showing how switching between AND and OR combination modes affects the filter logic. Try toggling the combination mode to see how the same conditions behave differently.', + }, + }, + }, + args: { + filterProperties: [ + { + key: 'type', + propertyLabel: 'Type', + type: 'enum', + options: [ + { label: 'Feature', value: 'FEATURE' }, + { label: 'Bug', value: 'BUG' }, + { label: 'Task', value: 'TASK' }, + ], + }, + { + key: 'priority', + propertyLabel: 'Priority', + type: 'enum', + options: [ + { label: 'Critical', value: 'CRITICAL' }, + { label: 'High', value: 'HIGH' }, + { label: 'Medium', value: 'MEDIUM' }, + { label: 'Low', value: 'LOW' }, + ], + }, + { + key: 'assignedToMe', + propertyLabel: 'Assigned to Me', + type: 'boolean', + }, + ], + combinationMode: 'AND', + onChange: action('Filter changed with mode toggle'), + }, +}; + +export const WithScalarValueModeOnString: Story = { + name: 'Scalar valueMode on string field', + parameters: { + docs: { + description: { + story: + "Demonstrates valueMode='scalar' on a non-boolean field. The filter emits { slugExact: 'my-slug' } without an operator, while tags still display using implicitOperator (default '=').", + }, + }, + }, + args: { + filterProperties: [ + { + key: 'slugExact', + propertyLabel: 'Slug (scalar exact)', + type: 'string', + valueMode: 'scalar', + implicitOperator: 'equals', + }, + { + key: 'title', + propertyLabel: 'Title', + type: 'string', + defaultOperator: 'contains', + }, + { + key: 'isPublished', + propertyLabel: 'Published', + type: 'boolean', // defaults to scalar mode + }, + ], + combinationMode: 'AND', + value: { + AND: [{ slugExact: 'hello-world' }, { isPublished: true }], + }, + onChange: action('Scalar mode (string) filter changed'), + }, +}; diff --git a/packages/backend.ai-ui/src/components/BAIGraphQLPropertyFilter.tsx b/packages/backend.ai-ui/src/components/BAIGraphQLPropertyFilter.tsx new file mode 100644 index 0000000000..7a8917f877 --- /dev/null +++ b/packages/backend.ai-ui/src/components/BAIGraphQLPropertyFilter.tsx @@ -0,0 +1,584 @@ +import BAIFlex from './BAIFlex'; +import { CloseCircleOutlined } from '@ant-design/icons'; +import { useControllableValue } from 'ahooks'; +import { + AutoComplete, + AutoCompleteProps, + Button, + GetRef, + Input, + Select, + Space, + Tag, + Tooltip, + theme, +} from 'antd'; +import _ from 'lodash'; +import React, { ComponentProps, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +// GraphQL Filter Types +export type StringFilter = { + contains?: string | null; + startsWith?: string | null; + endsWith?: string | null; + equals?: string | null; + notEquals?: string | null; + iContains?: string | null; + iStartsWith?: string | null; + iEndsWith?: string | null; + iEquals?: string | null; + iNotEquals?: string | null; +}; + +export type NumberFilter = { + equals?: number | null; + notEquals?: number | null; + greaterThan?: number | null; + greaterOrEqual?: number | null; + lessThan?: number | null; + lessOrEqual?: number | null; + in?: number[] | null; + notIn?: number[] | null; +}; + +export type BooleanFilter = boolean; + +export type EnumFilter = { + equals?: T | null; + notEquals?: T | null; + in?: T[] | null; + notIn?: T[] | null; +}; + +export type BaseFilter = { + AND?: T[] | T | null; + OR?: T[] | T | null; + NOT?: T | null; +}; + +export type GraphQLFilter = BaseFilter & { + [key: string]: any; +}; + +export type FilterPropertyType = 'string' | 'number' | 'boolean' | 'enum'; + +export type FilterOperator = + // String operators + | 'contains' + | 'startsWith' + | 'endsWith' + | 'equals' + | 'notEquals' + | 'iContains' + | 'iStartsWith' + | 'iEndsWith' + | 'iEquals' + | 'iNotEquals' + // Number operators + | 'greaterThan' + | 'greaterOrEqual' + | 'lessThan' + | 'lessOrEqual' + | 'in' + | 'notIn' + // Allow custom operators + | (string & {}); + +type BaseFilterProperty = { + key: string; + propertyLabel: string; + type: FilterPropertyType; + operators?: FilterOperator[]; + options?: AutoCompleteProps['options']; + strictSelection?: boolean; + rule?: { + message: string; + validate: (value: any) => boolean; + }; + // How to serialize this property into GraphQL filter: + // - 'scalar': emit the value directly, e.g., { isUrgent: true } + // - 'operator': emit as an operator object, e.g., { name: { contains: "x" } } + // Defaults to 'scalar' for boolean type, otherwise 'operator'. + valueMode?: 'scalar' | 'operator'; + // For UI/tag display when valueMode='scalar', use this operator symbol (default 'eq'). + implicitOperator?: FilterOperator; +}; + +// fixedOperator and defaultOperator are mutually exclusive +export type FilterProperty = BaseFilterProperty & + ( + | { fixedOperator: FilterOperator; defaultOperator?: never } // Fixed operator (no selector shown) + | { defaultOperator?: FilterOperator; fixedOperator?: never } // Default operator (can be changed) + | { defaultOperator?: never; fixedOperator?: never } // No operator preference + ); + +export interface BAIGraphQLPropertyFilterProps + extends Omit< + ComponentProps, + 'value' | 'onChange' | 'defaultValue' + > { + value?: GraphQLFilter; + onChange?: (value: GraphQLFilter | undefined) => void; + defaultValue?: GraphQLFilter; + filterProperties: Array; + loading?: boolean; + combinationMode?: 'AND' | 'OR'; +} + +interface FilterCondition { + id: string; + property: string; + operator: FilterOperator; + value: any; + propertyLabel: string; + type: FilterPropertyType; +} + +const OPERATORS_BY_TYPE: Record = { + string: ['equals', 'notEquals', 'contains', 'startsWith', 'endsWith', 'in', 'notIn'], + number: ['equals', 'notEquals', 'greaterThan', 'greaterOrEqual', 'lessThan', 'lessOrEqual', 'in', 'notIn'], + boolean: ['equals'], + enum: ['equals', 'notEquals', 'in', 'notIn'], +}; + +const OPERATOR_LABELS: Partial> = { + equals: 'equals', + notEquals: 'not equals', + contains: 'contains', + startsWith: 'starts with', + endsWith: 'ends with', + greaterThan: 'greater than', + greaterOrEqual: 'greater or equal', + lessThan: 'less than', + lessOrEqual: 'less or equal', + in: 'in', + notIn: 'not in', +}; + +const OPERATOR_SHORT_LABELS: Partial> = { + equals: '=', + notEquals: '≠', + contains: ':', + startsWith: '^', + endsWith: '$', + greaterThan: '>', + greaterOrEqual: '≥', + lessThan: '<', + lessOrEqual: '≤', + in: '∈', + notIn: '∉', +}; + +const DEFAULT_OPERATOR_BY_TYPE: Record = { + string: 'contains', + number: 'eq', + boolean: 'eq', + enum: 'eq', +}; + +function generateId(): string { + return `filter-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; +} + +function convertConditionsToGraphQLFilter( + conditions: FilterCondition[], + filterProperties: FilterProperty[], + combinationMode: 'AND' | 'OR' = 'AND', +): GraphQLFilter | undefined { + if (conditions.length === 0) return undefined; + + // Build individual filter for each condition (no grouping) + const filters: GraphQLFilter[] = []; + + conditions.forEach((condition) => { + const propertyConfig = filterProperties.find( + (p) => p.key === condition.property, + ); + let filterValue: any; + + // Convert value based on type and operator + const valueMode = + propertyConfig?.valueMode || + (propertyConfig?.type === 'boolean' ? 'scalar' : 'operator'); + + if (valueMode === 'scalar') { + // Emit scalar directly. Coerce by type when possible. + if (propertyConfig?.type === 'boolean') { + filterValue = + condition.value === true || condition.value === 'true' ? true : false; + } else if (propertyConfig?.type === 'number') { + filterValue = Number(condition.value); + } else { + filterValue = condition.value; + } + } else if (condition.operator === 'in' || condition.operator === 'notIn') { + const values = condition.value.split(',').map((v: string) => v.trim()); + filterValue = { + [condition.operator]: + propertyConfig?.type === 'number' ? values.map(Number) : values, + }; + } else { + let value = condition.value; + if (propertyConfig?.type === 'number') { + value = Number(value); + } + filterValue = { [condition.operator]: value }; + } + + // Create a separate filter object for each condition + filters.push({ + [condition.property]: filterValue, + }); + }); + + // If there's only one filter, return it directly + if (filters.length === 1) { + return filters[0]; + } + + // Multiple filters are combined with specified mode (AND or OR) + return { [combinationMode]: filters }; +} + +function convertGraphQLFilterToConditions( + filter: GraphQLFilter | undefined, + filterProperties: FilterProperty[], +): FilterCondition[] { + if (!filter) return []; + + const conditions: FilterCondition[] = []; + + // Handle AND/OR operators - flatten conditions from array + if (filter.AND || filter.OR) { + const filterArray = filter.AND || filter.OR; + const filters = Array.isArray(filterArray) ? filterArray : [filterArray]; + filters.forEach((subFilter) => { + conditions.push( + ...convertGraphQLFilterToConditions(subFilter, filterProperties), + ); + }); + return conditions; + } + + // Process property filters + Object.keys(filter).forEach((key) => { + if (key === 'AND' || key === 'OR' || key === 'NOT' || key === 'DISTINCT') + return; + + const propertyConfig = filterProperties.find((p) => p.key === key); + const filterValue = filter[key]; + + const propertyValueMode = + propertyConfig?.valueMode || + (propertyConfig?.type === 'boolean' ? 'scalar' : 'operator'); + + if (propertyValueMode === 'scalar' && typeof filterValue !== 'object') { + // Scalar value directly + conditions.push({ + id: generateId(), + property: key, + operator: propertyConfig?.implicitOperator || 'eq', + value: String(filterValue), + propertyLabel: propertyConfig?.propertyLabel || key, + type: propertyConfig?.type || 'string', + }); + } else if (filterValue && typeof filterValue === 'object') { + Object.keys(filterValue).forEach((operator) => { + const value = filterValue[operator]; + if (value !== null && value !== undefined) { + conditions.push({ + id: generateId(), + property: key, + operator: operator as FilterOperator, + value: Array.isArray(value) ? value.join(', ') : String(value), + propertyLabel: propertyConfig?.propertyLabel || key, + type: propertyConfig?.type || 'string', + }); + } + }); + } + }); + + return conditions; +} + +const BAIGraphQLPropertyFilter: React.FC = ({ + filterProperties, + value: propValue, + onChange: propOnChange, + defaultValue, + loading, + combinationMode = 'AND', + ...containerProps +}) => { + const { token } = theme.useToken(); + const { t } = useTranslation(); + const [value, setValue] = useControllableValue({ + value: propValue, + defaultValue: defaultValue, + onChange: propOnChange, + }); + + const [conditions, setConditions] = useState(() => + convertGraphQLFilterToConditions(value, filterProperties), + ); + + const [search, setSearch] = useState(''); + const [selectedProperty, setSelectedProperty] = useState( + filterProperties[0], + ); + const getEffectiveValueMode = (p: FilterProperty | undefined) => + p?.valueMode || (p?.type === 'boolean' ? 'scalar' : 'operator'); + + const [selectedOperator, setSelectedOperator] = useState( + () => { + const mode = getEffectiveValueMode(selectedProperty); + if (mode === 'scalar') return selectedProperty?.implicitOperator || 'eq'; + return ( + selectedProperty?.fixedOperator || + selectedProperty?.defaultOperator || + DEFAULT_OPERATOR_BY_TYPE[selectedProperty?.type || 'string'] + ); + }, + ); + + const autoCompleteRef = useRef>(null); + const [isOpenAutoComplete, setIsOpenAutoComplete] = useState(false); + const [isValid, setIsValid] = useState(true); + const [isFocused, setIsFocused] = useState(false); + + const propertyOptions = useMemo( + () => + filterProperties.map((property) => ({ + label: property.propertyLabel, + value: property.key, + filter: property, + })), + [filterProperties], + ); + + const availableOperators = useMemo(() => { + const mode = getEffectiveValueMode(selectedProperty); + if (mode === 'scalar') return [] as FilterOperator[]; + if (selectedProperty?.fixedOperator) { + return [selectedProperty.fixedOperator]; + } + return ( + selectedProperty?.operators || + OPERATORS_BY_TYPE[selectedProperty?.type || 'string'] + ); + }, [selectedProperty]); + + const operatorOptions = useMemo(() => { + return availableOperators.map((op) => ({ + label: OPERATOR_LABELS[op] || op, + value: op, + })); + }, [availableOperators]); + + const updateConditions = (newConditions: FilterCondition[]) => { + setConditions(newConditions); + const filter = convertConditionsToGraphQLFilter( + newConditions, + filterProperties, + combinationMode, + ); + setValue(filter); + }; + + // Get effective options and strictSelection based on property type + const effectiveOptions = useMemo(() => { + // Use provided options if available + if (selectedProperty?.options) { + return selectedProperty.options; + } + // Default options for boolean type + if (selectedProperty?.type === 'boolean') { + return [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ]; + } + return undefined; + }, [selectedProperty]); + + const effectiveStrictSelection = useMemo(() => { + // Use provided strictSelection if explicitly set + if (selectedProperty?.strictSelection !== undefined) { + return selectedProperty.strictSelection; + } + // Default strictSelection for boolean type + if (selectedProperty?.type === 'boolean') { + return true; + } + return false; + }, [selectedProperty]); + + const addCondition = (value: string) => { + if (_.isEmpty(value)) return; + + if (effectiveStrictSelection && effectiveOptions) { + const option = effectiveOptions.find((o) => o.value === value); + if (!option) return; + } + + const isValid = + !selectedProperty.rule?.validate || selectedProperty.rule.validate(value); + setIsValid(isValid); + if (!isValid) return; + + // Decide operator to store for UI display + const mode = getEffectiveValueMode(selectedProperty); + const operatorToUse = + mode === 'scalar' + ? selectedProperty.implicitOperator || 'eq' + : selectedProperty.fixedOperator || selectedOperator; + + const newCondition: FilterCondition = { + id: generateId(), + property: selectedProperty.key, + operator: operatorToUse, + value: value, + propertyLabel: selectedProperty.propertyLabel, + type: selectedProperty.type, + }; + + updateConditions([...conditions, newCondition]); + setSearch(''); + }; + + const removeCondition = (id: string) => { + const newConditions = conditions.filter((c) => c.id !== id); + updateConditions(newConditions); + }; + + const resetConditions = () => { + updateConditions([]); + }; + + const renderConditionTag = ( + condition: FilterCondition, + ): React.ReactElement => { + const operatorShortLabel = + OPERATOR_SHORT_LABELS[condition.operator] || condition.operator; + const displayValue = + condition.operator === 'in' || condition.operator === 'notIn' + ? `[${condition.value}]` + : condition.value; + + return ( + removeCondition(condition.id)} + style={{ margin: 0 }} + title={`${condition.propertyLabel} ${OPERATOR_LABELS[condition.operator] || condition.operator} ${condition.value}`} + > + {condition.propertyLabel} {operatorShortLabel} {displayValue} + + ); + }; + + return ( + + + + )} + + { + setIsValid(true); + setSearch(value); + }} + style={{ minWidth: 200 }} + options={effectiveOptions?.filter((option) => + !search ? true : option.label?.toString().includes(search), + )} + placeholder={t('comp:BAIPropertyFilter.PlaceHolder')} + onBlur={() => setIsFocused(false)} + onFocus={() => setIsFocused(true)} + > + + + + + + {conditions.length > 0 && ( + + {conditions.map(renderConditionTag)} + {conditions.length > 1 && ( + +