// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file PowerSearchValueEditor.tsx * @input OperatorValue, FilterValue, onChange callback * @output Renders the appropriate editor for a filter value type * @position Sub-component; consumed by PowerSearchEditPopover * * SYNC: When modified, update: * - /packages/core/src/PowerSearch/index.ts */ import React, { useCallback, useMemo, useRef, type FormEvent, type KeyboardEvent } from 'react'; import type { OperatorValue, FilterValue, EnumItem, PowerSearchEntity, DateTimeRangePart } from "./types.js"; import type { InternalConfig } from "./useInternalConfig.js"; import type { SearchableItem, SearchSource } from "../Typeahead/types.js"; import type { ISODateString } from "../utils/dateTypes.js"; import type { ISOTimeString } from "../utils/index.js"; // Lazy import to avoid circular deps — these are all from the same package import { TextInput } from "../TextInput/index.js"; import { NumberInput } from "../NumberInput/index.js"; import { resolveNumberInputCommit, type NumberInputCommitDecision } from "../NumberInput/numberInputCommit.js"; import { DateInput } from "../DateInput/index.js"; import { DateRangeInput, type DateRange } from "../DateRangeInput/index.js"; import { resolveDateTimeRangePart } from "./resolveDateTimeRangePart.js"; import { TimeInput } from "../TimeInput/index.js"; import { Selector } from "../Selector/index.js"; import { Tokenizer } from "../Tokenizer/index.js"; import { Typeahead } from "../Typeahead/index.js"; import { useLocale, useTranslator } from "../i18n/index.js"; export interface PowerSearchValueEditorProps { operatorValue: OperatorValue; filterValue: FilterValue | undefined; onChange: (value: FilterValue, shouldSave?: boolean) => void; onEnter?: () => void; config: InternalConfig; /** Max suggestions in string and entity value typeaheads. */ maxMenuItems?: number; isDisabled?: boolean; timezoneID?: string; } // ============================================================================= // Helpers // ============================================================================= function createStaticSource(items: SearchableItem[]): SearchSource { return { search(query: string) { const lower = query.toLowerCase(); return items.filter(item => item.label.toLowerCase().includes(lower)); }, bootstrap() { return items; } }; } function enumItemsToSearchableItems(values: ReadonlyArray): SearchableItem[] { return values.map(item => ({ id: item.value, label: item.label })); } function dateRangePartToISO(part: DateTimeRangePart, nowSeconds: number): ISODateString { return new Date(resolveDateTimeRangePart(part, nowSeconds) * 1000).toISOString().split('T')[0] as ISODateString; } // ============================================================================= // Individual Editors // ============================================================================= function StringEditor({ operatorValue, filterValue, onChange, onEnter: _onEnter, maxMenuItems }: { operatorValue: OperatorValue & { type: 'string'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue, shouldSave?: boolean) => void; onEnter?: () => void; maxMenuItems?: number; }) { const t = useTranslator(); const currentValue = filterValue?.type === 'string' ? filterValue.value : ''; // When a searchSource is provided, render a typeahead instead of a plain // text input so users get suggestions (#1103). if (operatorValue.searchSource) { const selectedItem: SearchableItem | null = currentValue ? { id: currentValue, label: currentValue } : null; return { if (item) { onChange({ type: 'string', value: item.label }, true); } else { onChange({ type: 'string', value: '' }); } }} placeholder={t('@astryx.powersearch.valueEditor.searchPlaceholder')} debounceMs={150} maxMenuItems={maxMenuItems} />; } return { onChange({ type: 'string', value }); }} />; } function StringListEditor({ operatorValue, filterValue, onChange, maxMenuItems }: { operatorValue: OperatorValue & { type: 'string_list'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue) => void; maxMenuItems?: number; }) { const t = useTranslator(); const currentValue: SearchableItem[] = useMemo(() => { if (filterValue?.type !== 'string_list') { return []; } return filterValue.value.map(v => ({ id: v, label: v })); }, [filterValue]); const source = useMemo>(() => { if (operatorValue.searchSource) { return operatorValue.searchSource; } // Free-text: accept anything typed return { search: () => [], bootstrap: () => [] }; }, [operatorValue.searchSource]); // Enable creatable mode when no searchSource is provided (free-text tags) // or when isArbitraryStringAllowed is explicitly set (#1107). const hasCreate = operatorValue.isArbitraryStringAllowed || !operatorValue.searchSource; return { onChange({ type: 'string_list', value: items.map(item => item.label) }); }} placeholder={t('@astryx.powersearch.valueEditor.addValuesPlaceholder')} debounceMs={operatorValue.searchSource ? 150 : 0} hasCreate={hasCreate} maxMenuItems={maxMenuItems} />; } function useNumberEditorHandlers({ valueType, min, max, isIntegerOnly, onChange, onEnter }: { valueType: 'integer' | 'float'; min?: number; max?: number; isIntegerOnly: boolean; onChange: (value: FilterValue, shouldSave?: boolean) => void; onEnter?: () => void; }) { const locale = useLocale(); const pendingDecisionRef = useRef(null); const toFilterValue = useCallback((value: number): FilterValue => valueType === 'integer' ? { type: 'integer', value } : { type: 'float', value }, [valueType]); const handleChange = useCallback((value: number) => { onChange(toFilterValue(value)); }, [onChange, toFilterValue]); const handleFocus = useCallback(() => { pendingDecisionRef.current = null; }, []); const handleInput = useCallback((event: FormEvent) => { pendingDecisionRef.current = resolveNumberInputCommit((event.currentTarget as HTMLInputElement).value, { min, max, isIntegerOnly, locale, hasClear: false }); }, [isIntegerOnly, locale, max, min]); const handleEnter = useCallback(() => { const decision = pendingDecisionRef.current; pendingDecisionRef.current = null; if (decision === null) { onEnter?.(); } else if (decision.type === 'commit') { onChange(toFilterValue(decision.value), true); } }, [onChange, onEnter, toFilterValue]); const handleKeyDown = useCallback((event: KeyboardEvent) => { if (event.key === 'Enter') { event.stopPropagation(); } }, []); return { handleChange, handleEnter, handleFocus, handleInput, handleKeyDown }; } function IntegerEditor({ operatorValue, filterValue, onChange, onEnter }: { operatorValue: OperatorValue & { type: 'integer'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue, shouldSave?: boolean) => void; onEnter?: () => void; }) { const t = useTranslator(); const currentValue = filterValue?.type === 'integer' ? filterValue.value : undefined; const handlers = useNumberEditorHandlers({ valueType: 'integer', min: operatorValue.minValue, max: operatorValue.maxValue, isIntegerOnly: true, onChange, onEnter }); return ; } function FloatEditor({ operatorValue, filterValue, onChange, onEnter }: { operatorValue: OperatorValue & { type: 'float'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue, shouldSave?: boolean) => void; onEnter?: () => void; }) { const t = useTranslator(); const currentValue = filterValue?.type === 'float' ? filterValue.value : undefined; const handlers = useNumberEditorHandlers({ valueType: 'float', min: operatorValue.minValue, max: operatorValue.maxValue, isIntegerOnly: false, onChange, onEnter }); return ; } function TimeEditor({ operatorValue, filterValue, onChange }: { operatorValue: OperatorValue & { type: 'time'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue) => void; }) { const t = useTranslator(); const currentValue = filterValue?.type === 'time' ? filterValue.value as ISOTimeString : undefined; return { if (value != null) { onChange({ type: 'time', value }); } }} min={operatorValue.minValue as ISOTimeString | undefined} max={operatorValue.maxValue as ISOTimeString | undefined} />; } function DateAbsoluteEditor({ filterValue, onChange }: { operatorValue: OperatorValue & { type: 'date_absolute'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue) => void; }) { const t = useTranslator(); // Convert unixSeconds to ISO date string for the date input const currentValue = useMemo(() => { if (filterValue?.type !== 'date_absolute') { return undefined; } const date = new Date(filterValue.unixSeconds * 1000); return date.toISOString().split('T')[0] as ISODateString; }, [filterValue]); return { if (value != null) { const unixSeconds = Math.floor(new Date(value).getTime() / 1000); onChange({ type: 'date_absolute', unixSeconds }); } }} />; } function DateRelativeEditor({ operatorValue, filterValue, onChange }: { operatorValue: OperatorValue & { type: 'date_relative'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue, shouldSave?: boolean) => void; }) { const t = useTranslator(); const currentValue = filterValue?.type === 'date_relative' ? filterValue.value : undefined; const options = useMemo(() => { const result: { value: string; label: string; }[] = []; const units = [{ unit: 'day', plural: 'days' }, { unit: 'week', plural: 'weeks' }, { unit: 'month', plural: 'months' }]; for (const { unit, plural } of units) { const amounts = unit === 'day' ? [1, 3, 7, 14, 30] : unit === 'week' ? [1, 2, 4] : [1, 3, 6, 12]; for (const amount of amounts) { if (operatorValue.isPastAllowed !== false) { result.push({ value: `${amount}${unit[0]}_ago`, label: `${amount} ${amount === 1 ? unit : plural} ago` }); } if (operatorValue.isFutureAllowed !== false) { result.push({ value: `${amount}${unit[0]}_from_now`, label: `${amount} ${amount === 1 ? unit : plural} from now` }); } } } return result; }, [operatorValue.isPastAllowed, operatorValue.isFutureAllowed]); return { onChange({ type: 'date_relative', value }, true); }} />; } function DateRangeEditor({ filterValue, onChange }: { operatorValue: OperatorValue & { type: 'date_range'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue) => void; }) { const t = useTranslator(); const currentValue = useMemo(() => { if (filterValue?.type !== 'date_range') { return null; } const { start, end } = filterValue.value; const nowSeconds = Date.now() / 1000; return { start: dateRangePartToISO(start, nowSeconds), end: dateRangePartToISO(end, nowSeconds) }; }, [filterValue]); const handleChange = useCallback((range: DateRange | null) => { if (range == null) { return; } onChange({ type: 'date_range', value: { start: { type: 'ABSOLUTE', unixSeconds: Date.parse(`${range.start}T00:00:00Z`) / 1000 }, end: { type: 'ABSOLUTE', unixSeconds: Date.parse(`${range.end}T00:00:00Z`) / 1000 } } }); }, [onChange]); const handleKeyDown = useCallback((event: KeyboardEvent) => { // DateRangeInput owns an inner popover. Its Enter activation must not reach // the parent PowerSearch shortcut before the focused button's click runs. if (event.key === 'Enter') { event.stopPropagation(); } }, []); return
; } function EnumEditor({ operatorValue, filterValue, onChange }: { operatorValue: OperatorValue & { type: 'enum'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue, shouldSave?: boolean) => void; }) { const t = useTranslator(); const currentValue = filterValue?.type === 'enum' ? filterValue.value : undefined; const options = useMemo(() => operatorValue.values.map(item => ({ value: item.value, label: item.label })), [operatorValue.values]); return { onChange({ type: 'enum', value }, true); }} />; } function EnumListEditor({ operatorValue, filterValue, onChange }: { operatorValue: OperatorValue & { type: 'enum_list'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue) => void; }) { const t = useTranslator(); const items = useMemo(() => enumItemsToSearchableItems(operatorValue.values), [operatorValue.values]); const source = useMemo(() => createStaticSource(items), [items]); const currentValue: SearchableItem[] = useMemo(() => { if (filterValue?.type !== 'enum_list') { return []; } return filterValue.value.map(v => { const item = operatorValue.values.find(e => e.value === v); return { id: v, label: item?.label ?? v }; }); }, [filterValue, operatorValue.values]); return { onChange({ type: 'enum_list', value: selectedItems.map(item => item.id) }); }} placeholder={t('@astryx.powersearch.valueEditor.selectValuesPlaceholder')} hasEntriesOnFocus debounceMs={0} />; } function EntityListEditor({ operatorValue, filterValue, onChange, maxMenuItems }: { operatorValue: OperatorValue & { type: 'entity_list'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue) => void; maxMenuItems?: number; }) { const t = useTranslator(); const source = useMemo>(() => { if (operatorValue.searchSource) { return operatorValue.searchSource; } return { search: () => [], bootstrap: () => [] }; }, [operatorValue.searchSource]); // Preserve photo in auxiliaryData so it round-trips through the tokenizer (#1106). const currentValue: SearchableItem[] = useMemo(() => { if (filterValue?.type !== 'entity_list') { return []; } return filterValue.value.map((entity: PowerSearchEntity) => ({ id: entity.id, label: entity.label, auxiliaryData: entity.photo ? { photo: entity.photo } : undefined })); }, [filterValue]); return { onChange({ type: 'entity_list', // Round-trip photo from auxiliaryData back to PowerSearchEntity (#1106). value: items.map(item => { const aux = item.auxiliaryData as { photo?: string; } | undefined; return { id: item.id, label: item.label, ...(aux?.photo ? { photo: aux.photo } : {}) }; }) }); }} renderItem={operatorValue.renderItem} placeholder={t('@astryx.powersearch.valueEditor.searchPlaceholder')} debounceMs={operatorValue.searchSource ? 150 : 0} maxMenuItems={maxMenuItems} />; } function CustomEditor({ operatorValue, filterValue, onChange, isDisabled }: { operatorValue: OperatorValue & { type: 'custom'; }; filterValue: FilterValue | undefined; onChange: (value: FilterValue) => void; isDisabled?: boolean; }) { const t = useTranslator(); const currentValue = filterValue?.type === 'custom' ? filterValue.value : null; const EditorComponent = operatorValue.Editor; return { if (value != null) { onChange({ type: 'custom', value }); } }} placeholder={t('@astryx.powersearch.valueEditor.enterValuePlaceholder')} value={currentValue} />; } // ============================================================================= // Main Dispatcher // ============================================================================= export function PowerSearchValueEditor({ operatorValue, filterValue, onChange, onEnter, maxMenuItems, isDisabled }: PowerSearchValueEditorProps) { switch (operatorValue.type) { case 'empty': return null; case 'string': return ; case 'string_list': return ; case 'integer': return ; case 'float': return ; case 'time': return ; case 'date_absolute': return ; case 'date_relative': return ; case 'date_range': return ; case 'enum': return ; case 'enum_list': return ; case 'entity_list': return ; case 'custom': return ; case 'nested': // Nested filters are complex — simplified v1 just shows a message return null; default: return null; } }