// 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: handleAdd(itemPath)} variant="ghost" size="sm" /> }); } return { id: `filter-${itemPath.join('-')}`, label: handleUpdate(itemPath, updated)} maxMenuItems={maxMenuItems} isReadOnly={isReadOnly} />, isExpanded: true, children, endContent: !isReadOnly ? } variant="ghost" size="sm" onClick={() => handleRemove(itemPath)} isIconOnly /> : undefined }; } return { id: `filter-${itemPath.join('-')}`, isExpanded: true, label: handleUpdate(itemPath, updated)} maxMenuItems={maxMenuItems} isReadOnly={isReadOnly} />, endContent: !isReadOnly ? } variant="ghost" size="sm" onClick={() => handleRemove(itemPath)} isIconOnly /> : undefined }; }); return items; } const treeChildren = buildTreeItems(subFilters, []); if (!isReadOnly) { treeChildren.push({ id: 'add-filter', label: handleAdd([])} variant="ghost" size="sm" /> }); } const rootLabel = {operatorOptions.length > 1 ? : operatorOptions[0]?.label ?? t('@astryx.powersearch.editor.group')} ; const items: TreeListItemData[] = [{ id: 'nested-root', label: rootLabel, isExpanded: true, children: treeChildren }]; return ; } // ============================================================================= // Value editor cell // ============================================================================= // A field switch must replace the editor, not update it in place: a reused // editor keeps its open menu and old results. function valueEditorKey(filter: { field: string; operator?: string; }): string { return `${filter.field}\u0000${filter.operator ?? ''}`; } // Keyed on field + operator, so this also mounts on a field switch, not just // when the popover opens; the focus handoff below runs for both. function ValueEditorCell(props: PowerSearchValueEditorProps) { const ref = useRef(null); useEffect(() => { const frame = requestAnimationFrame(() => { const container = ref.current; if (!container) { return; } const focusable = container.querySelector('input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'); focusable?.focus(); }); return () => cancelAnimationFrame(frame); }, []); return ; } // ============================================================================= // Main popover // ============================================================================= export function PowerSearchEditPopover({ config, filter: initialFilter, mode, onSave, onCancel, saveButtonLabel: saveButtonLabelFromProps, maxMenuItems, isReadOnly = false }: PowerSearchEditPopoverProps) { const t = useTranslator(); const saveButtonLabel = saveButtonLabelFromProps ?? t('@astryx.powersearch.editor.apply'); const [partialFilter, setPartialFilter] = useState(initialFilter); const currentOperator = partialFilter.operator ? config.getOperator(partialFilter.field, partialFilter.operator) : undefined; // Build field options for the selector const fieldOptions = useMemo(() => config.getVisibleFields().map(field => ({ value: field.key, label: field.label })), [config]); // Build operator options for the current field const operatorOptions = useMemo(() => { const operators = config.getVisibleOperators(partialFilter.field); return operators.map(op => ({ value: op.key, label: resolveOperatorLabel(op, t) })); }, [config, partialFilter.field, t]); const handleFieldChange = useCallback((fieldKey: string) => { const defaultOp = config.getDefaultOperator(fieldKey); setPartialFilter({ field: fieldKey, operator: defaultOp?.key, value: undefined }); }, [config]); const handleOperatorChange = useCallback((operatorKey: string) => { const newOp = config.getOperator(partialFilter.field, operatorKey); const oldOp = currentOperator; const keepValue = newOp && oldOp && newOp.value.type === oldOp.value.type; setPartialFilter(prev => ({ ...prev, operator: operatorKey, value: keepValue ? prev.value : undefined })); }, [config, partialFilter.field, currentOperator]); const handleValueChange = useCallback((value: FilterValue, shouldSave?: boolean) => { setPartialFilter(prev => { const updated = { ...prev, value }; if (shouldSave && updated.field && updated.operator && updated.value) { onSave({ field: updated.field, operator: updated.operator, value: updated.value }); } return updated; }); }, [onSave]); const handleSave = useCallback(() => { if (partialFilter.field && partialFilter.operator && partialFilter.value) { onSave({ field: partialFilter.field, operator: partialFilter.operator, value: partialFilter.value }); } }, [partialFilter, onSave]); const handleDelete = useCallback(() => { onSave(null); }, [onSave]); const isSaveDisabled = !partialFilter.operator || !partialFilter.value; // Handle Enter to save, Escape to cancel const handleKeyDown = useCallback((e: React.KeyboardEvent) => { // Don't treat an IME composition-commit Enter as save-to-close -- // typing a CJK filter value and pressing Enter to confirm the // composition would otherwise close the popover mid-composition. if (isImeKeyEvent(e.nativeEvent)) { return; } if (e.key === 'Enter' && !isSaveDisabled && !e.defaultPrevented) { e.preventDefault(); handleSave(); } else if (e.key === 'Escape' && !e.defaultPrevented) { e.preventDefault(); onCancel(); } }, [isSaveDisabled, handleSave, onCancel]); const operatorValue: OperatorValue | undefined = currentOperator?.value; const isEmptyType = operatorValue?.type === 'empty'; const isNestedType = operatorValue?.type === 'nested'; // For empty type, auto-save on mount useEffect(() => { if (isEmptyType && partialFilter.field && partialFilter.operator) { onSave({ field: partialFilter.field, operator: partialFilter.operator, value: { type: 'empty' } }); } }, [isEmptyType, partialFilter.field, partialFilter.operator, onSave]); const showOperatorSelector = operatorOptions.length > 1 || !isEmptyType; // Nested filter editing if (isNestedType) { return {!isReadOnly && mode === 'edit' ? : } ; } return {showOperatorSelector && operatorOptions.length > 0 && } {operatorValue && !isEmptyType && } {!isEmptyType && {!isReadOnly && mode === 'edit' ? : } } ; }