// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file PowerSearchEditPopover.tsx * @input InternalConfig, PartialFilter * @output Edit popover content with field/operator selectors and value editor * @position Sub-component; consumed by PowerSearch * * SYNC: When modified, update: * - /packages/core/src/PowerSearch/index.ts */ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import * as stylex from "../style-runtime.js"; import { Button } from "../Button/index.js"; import { Selector } from "../Selector/index.js"; import { HStack, VStack } from "../Stack/index.js"; import { Icon } from "../Icon/index.js"; import { TreeList, type TreeListItemData } from "../TreeList/index.js"; import { useTranslator } from "../i18n/index.js"; import { isImeKeyEvent } from "../utils/ime.js"; import "../theme/tokens.stylex.js"; import { spacingVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { PowerSearchValueEditor, type PowerSearchValueEditorProps } from "./PowerSearchValueEditor.js"; import { resolveOperatorLabel } from "./resolveOperatorLabel.js"; import type { InternalConfig } from "./useInternalConfig.js"; import type { PowerSearchFilter, PartialFilter, FilterValue, OperatorValue } from "./types.js"; // Below the popover layer's 400px floor (see popoverLayerStyles in // PowerSearch.tsx) the chip rows collapse from one line to wrapped lines. // A container query, not a viewport one, so the rows track the width the // popover actually got (#4761). const CHIP_ROW_COLLAPSE = '@container (max-width: 399px)'; const styles = { chipRow: { kwnvtZ: "xozqiw3 x1alyx8p", $$css: true as const } }; export interface PowerSearchEditPopoverProps { config: InternalConfig; /** The filter being edited/created. */ filter: PartialFilter; /** 'create' for new filters, 'edit' for existing. */ mode: 'create' | 'edit'; /** Called when save is clicked with a complete filter, or null to delete. */ onSave: (filter: PowerSearchFilter | null) => void; /** Called when the popover is closed without saving. */ onCancel: () => void; /** Label for the save button. @default 'Apply' */ saveButtonLabel?: string; /** Max suggestions in string and entity value typeaheads. */ maxMenuItems?: number; /** Whether the filter is read-only. */ isReadOnly?: boolean; } // ============================================================================= // Editable filter state for recursive nesting // ============================================================================= interface EditablePartialFilter { field: string; operator?: string; value?: FilterValue; _subFilters?: EditablePartialFilter[]; } function initEditableFilter(config: InternalConfig, filter: PowerSearchFilter): EditablePartialFilter { const op = config.getOperator(filter.field, filter.operator); if (op?.value.type === 'nested' && filter.value?.type === 'nested') { return { field: filter.field, operator: filter.operator, value: filter.value, _subFilters: filter.value.value.map(f => initEditableFilter(config, f)) }; } return { field: filter.field, operator: filter.operator, value: filter.value }; } function isEditableFilterComplete(config: InternalConfig, ef: EditablePartialFilter): boolean { if (!ef.field || !ef.operator) { return false; } const op = config.getOperator(ef.field, ef.operator); if (op?.value.type === 'nested') { const subs = ef._subFilters ?? []; return subs.length > 0 && subs.every(s => isEditableFilterComplete(config, s)); } return ef.value != null; } function editableToCompleteFilter(config: InternalConfig, ef: EditablePartialFilter): PowerSearchFilter | null { if (!ef.operator) { return null; } const op = config.getOperator(ef.field, ef.operator); if (op?.value.type === 'nested') { const subs = (ef._subFilters ?? []).map(s => editableToCompleteFilter(config, s)).filter((s): s is PowerSearchFilter => s != null); return { field: ef.field, operator: ef.operator, value: { type: 'nested', value: subs } }; } if (ef.value == null) { return null; } return { field: ef.field, operator: ef.operator, value: ef.value }; } function updateAtPath(filters: EditablePartialFilter[], path: number[], updater: (filter: EditablePartialFilter) => EditablePartialFilter): EditablePartialFilter[] { const [idx, ...rest] = path; const next = [...filters]; if (rest.length === 0) { next[idx] = updater(next[idx]); } else { const sf = next[idx]; next[idx] = { ...sf, _subFilters: updateAtPath(sf._subFilters ?? [], rest, updater) }; } return next; } function removeAtPath(filters: EditablePartialFilter[], path: number[]): EditablePartialFilter[] { const [idx, ...rest] = path; if (rest.length === 0) { return filters.filter((_, i) => i !== idx); } const next = [...filters]; const sf = next[idx]; next[idx] = { ...sf, _subFilters: removeAtPath(sf._subFilters ?? [], rest) }; return next; } function addAtPath(filters: EditablePartialFilter[], parentPath: number[], newFilter: EditablePartialFilter): EditablePartialFilter[] { if (parentPath.length === 0) { return [...filters, newFilter]; } const [idx, ...rest] = parentPath; const next = [...filters]; const sf = next[idx]; next[idx] = { ...sf, _subFilters: addAtPath(sf._subFilters ?? [], rest, newFilter) }; return next; } // ============================================================================= // Nested sub-filter row // ============================================================================= interface NestedSubFilterRowProps { config: InternalConfig; subFilter: EditablePartialFilter; onChange: (subFilter: EditablePartialFilter) => void; maxMenuItems?: number; isReadOnly: boolean; } function NestedSubFilterRow({ config, subFilter, onChange, maxMenuItems, isReadOnly }: NestedSubFilterRowProps) { const t = useTranslator(); const fieldOptions = useMemo(() => config.getVisibleFields().map(field => ({ value: field.key, label: field.label })), [config]); const operatorOptions = useMemo(() => { const operators = config.getVisibleOperators(subFilter.field); return operators.map(op => ({ value: op.key, label: resolveOperatorLabel(op, t) })); }, [config, subFilter.field, t]); const currentOperator = subFilter.operator ? config.getOperator(subFilter.field, subFilter.operator) : undefined; const operatorValue: OperatorValue | undefined = currentOperator?.value; const isEmptyType = operatorValue?.type === 'empty'; const isNestedType = operatorValue?.type === 'nested'; const handleFieldChange = useCallback((fieldKey: string) => { const defaultOp = config.getDefaultOperator(fieldKey); const newOp = defaultOp ? config.getOperator(fieldKey, defaultOp.key) : undefined; onChange({ field: fieldKey, operator: defaultOp?.key, value: undefined, _subFilters: newOp?.value.type === 'nested' ? [] : undefined }); }, [config, onChange]); const handleOperatorChange = useCallback((operatorKey: string) => { const newOp = config.getOperator(subFilter.field, operatorKey); const oldOp = currentOperator; const keepValue = newOp && oldOp && newOp.value.type === oldOp.value.type; onChange({ ...subFilter, operator: operatorKey, value: keepValue ? subFilter.value : undefined, _subFilters: newOp?.value.type === 'nested' ? subFilter._subFilters ?? [] : undefined }); }, [config, subFilter, currentOperator, onChange]); const handleValueChange = useCallback((value: FilterValue) => { onChange({ ...subFilter, value }); }, [subFilter, onChange]); return
{operatorOptions.length > 0 &&
} {operatorValue && !isEmptyType && !isNestedType &&
}
; } // ============================================================================= // Nested editor // ============================================================================= interface NestedEditorProps { config: InternalConfig; partialFilter: PartialFilter; operatorOptions: { value: string; label: string; }[]; onOperatorChange: (operatorKey: string) => void; onPartialFilterChange: (filter: PartialFilter) => void; maxMenuItems?: number; isReadOnly: boolean; } function NestedEditor({ config, partialFilter, operatorOptions, onOperatorChange, onPartialFilterChange, maxMenuItems, isReadOnly }: NestedEditorProps) { const t = useTranslator(); const [subFilters, setSubFilters] = useState(() => { if (partialFilter.value && partialFilter.value.type === 'nested') { return partialFilter.value.value.map(f => initEditableFilter(config, f)); } return []; }); const syncToParent = useCallback((newSubFilters: EditablePartialFilter[]) => { if (newSubFilters.length > 0 && newSubFilters.every(sf => isEditableFilterComplete(config, sf))) { onPartialFilterChange({ ...partialFilter, value: { type: 'nested', value: newSubFilters.map(sf => editableToCompleteFilter(config, sf)).filter((sf): sf is PowerSearchFilter => sf != null) } }); } else { onPartialFilterChange({ ...partialFilter, value: undefined }); } }, [config, partialFilter, onPartialFilterChange]); const handleUpdate = useCallback((path: number[], updated: EditablePartialFilter) => { setSubFilters(prev => { const next = updateAtPath(prev, path, () => updated); syncToParent(next); return next; }); }, [syncToParent]); const handleRemove = useCallback((path: number[]) => { setSubFilters(prev => { const next = removeAtPath(prev, path); syncToParent(next); return next; }); }, [syncToParent]); const handleAdd = useCallback((parentPath: number[]) => { const fields = config.getVisibleFields(); const defaultField = fields[0]; if (!defaultField) { return; } const defaultOp = config.getDefaultOperator(defaultField.key); const op = defaultOp ? config.getOperator(defaultField.key, defaultOp.key) : undefined; const newSubFilter: EditablePartialFilter = { field: defaultField.key, operator: defaultOp?.key, value: undefined, _subFilters: op?.value.type === 'nested' ? [] : undefined }; setSubFilters(prev => { const next = addAtPath(prev, parentPath, newSubFilter); syncToParent(next); return next; }); }, [config, syncToParent]); function buildTreeItems(filters: EditablePartialFilter[], parentPath: number[]): TreeListItemData[] { const items: TreeListItemData[] = filters.map((sf, idx) => { const itemPath = [...parentPath, idx]; const op = sf.operator ? config.getOperator(sf.field, sf.operator) : undefined; const isNested = op?.value.type === 'nested'; if (isNested) { const children = buildTreeItems(sf._subFilters ?? [], itemPath); if (!isReadOnly) { children.push({ id: `${itemPath.join('-')}-add`, label: