import type { AutoCompleteOption, OptionGroup } from "../AutoSuggest.types"; import type { InternalParsedTextState, InternalPropertyDefinition, InternalPropertyOption, OperatorT, } from "../TokenFilter.types"; import { createGroups } from "./grouping"; import { QUERY_OPERATORS } from "./operators"; import { OPERATOR_LABELS, buildQueryString } from "./query-builder"; import { matchesFilterText } from "./text-matching"; /** * Generates "options" to be used as autosuggest-ottion based on the current query state. * * The query parser recognizes three states: * - "property": User has selected/matched a property and operator ("Status = active") * - "operator": User has matched a property but is typing the operator ("Status" or "Status !") * - "free-text": User is typing freely without a property match (e.g., "act" or "!: test") * * @returns * - value: The canonical query string representation for the current state. * Used by the UI to determine cursor position and input replacement. * - options: Grouped suggestions to display (properties, operators, or values). */ type AutoCompleteResult = { value: string; options: OptionGroup[]; }; function generateAutoCompleteOptions( queryState: InternalParsedTextState, filteringProperties: InternalPropertyDefinition[] = [], filteringOptions: InternalPropertyOption[] = [], ): AutoCompleteResult { /* State: Property and operator are matched, suggest values */ if (queryState.step === "property") { const filterText = queryState.value || ""; return { value: queryState.value, options: createValueSuggestions( filteringOptions, queryState.operator, filterText, queryState.property, ), }; } /* State: Property matched, but operator is incomplete */ if (queryState.step === "operator") { const operators = filterOperatorsByPrefix( getValidOperatorsForProperty(queryState.property), queryState.operatorPrefix, ); const partialQuery = buildQueryString( queryState.property.label, queryState.operatorPrefix, "", ); /** * Edge case: User typed an invalid operator prefix that doesn't match any operators. * This can happen when typing characters that don't start any valid operator. * Return empty suggestions gracefully - the UI will show "no results". */ if (operators.length === 0) { return { value: partialQuery, options: [], }; } return { value: partialQuery, options: generateOperatorSuggestions( queryState.property, queryState.operatorPrefix, ), }; } /* * Edge case: Input starts with operator but has no value yet (user typed just "!=") * Wait for value before showing suggestions */ if (!queryState.value && queryState.operator) { return { value: "", options: [], }; } /* Empty input: Show all properties */ if (!queryState.value) { return { value: "", options: generatePropertySuggestions(filteringProperties), }; } /* * Free-text search: Show matching values across all properties * Use the detected operator if input started with one (e.g., "!= test"), otherwise default to "=" */ return { value: queryState.value, options: [ ...generatePropertySuggestions(filteringProperties, queryState.value), ...createValueSuggestions( filteringOptions, queryState.operator ?? "=", queryState.value, ), ], }; } /** * Returns the valid operators for a given property. * Extracts operators from the property's custom operator configuration. * If none are configured, falls back to all available operators. * * The QueryFilteringScopedOperator can be a simple string (e.g., "=") * or an object with operator and tokenType (e.g., { operator: ":", tokenType: "single" }). * This function normalizes both formats and returns just the operator strings. * * @returns Array of valid operators for the property * * TODO: We omit passing the tokenType for now since it's not currently used in the UI. But will be needed for single/multi-selection. */ function getValidOperatorsForProperty( property: InternalPropertyDefinition, ): OperatorT[] { const { operators } = property; /* If no operators configured, return all available operators */ if (!operators || operators.length === 0) { return QUERY_OPERATORS; } /* * Extract operator strings from QueryFilteringScopedOperator format * Handle both simple strings and objects with operator property */ const operatorStrings = operators.map((op) => typeof op === "string" ? op : op.operator, ); /* Filter to only valid QUERY_OPERATORS to ensure type safety */ return operatorStrings.filter((op) => QUERY_OPERATORS.includes(op as OperatorT), ) as OperatorT[]; } /** * Filters the list of operators based on the provided prefix. * If the prefix is empty, all operators are returned. */ function filterOperatorsByPrefix( operators: OperatorT[], prefix: string, ): OperatorT[] { if (!prefix) { return operators; } return operators.filter((operator) => operator.startsWith(prefix)); } function generatePropertySuggestions( filteringProperties: InternalPropertyDefinition[] = [], filterText = "", ): OptionGroup[] { const filteredProperties: InternalPropertyDefinition[] = []; for (const property of filteringProperties) { if (!property) { continue; } if ( matchesFilterText( [property.label, property.groupLabel, property.group].filter(Boolean), filterText, ) ) { filteredProperties.push(property); } } const groups = createGroups( filteredProperties, (property) => property.group, "Properties", ); return groups.map((group) => ({ label: group.label, options: group.options.map((property) => ({ value: buildQueryString(property.label, "", ""), label: property.label, })), })); } function generateOperatorSuggestions( property: InternalPropertyDefinition, operatorPrefix = "", ): OptionGroup[] { const operators = filterOperatorsByPrefix( getValidOperatorsForProperty(property), operatorPrefix, ); if (operators.length === 0) { return []; } return [ { label: "Operators", options: operators.map((operator) => ({ value: buildQueryString(property.label, operator, ""), label: buildQueryString(property.label, operator, ""), description: OPERATOR_LABELS[operator] ?? "", })), }, ]; } /** * Creates value suggestions for autocomplete. * When scopedProperty is provided, only shows values for that property (single group). * When scopedProperty is omitted, searches across all properties (multiple groups). * TODO: This could potentially contain an unlimited number of options if there are many values across properties. * May need virtualization/async or other filtering mechanism. */ function createValueSuggestions( filteringOptions: InternalPropertyOption[] = [], operator: OperatorT, filterText = "", scopedProperty?: InternalPropertyDefinition, ): OptionGroup[] { const groups: Record> = {}; for (const option of filteringOptions) { if (!option?.property) { continue; } /* If scoped to a property, filter to only that property's options */ if (scopedProperty && option.property !== scopedProperty) { continue; } /* Build search fields */ const searchFields = [option.label, ...(option.tags ?? [])]; if (!scopedProperty) { searchFields.push(option.property.label); } const matches = matchesFilterText(searchFields.filter(Boolean), filterText); if (!matches) { continue; } const groupLabel = option.property.groupLabel || "Values"; if (!groups[groupLabel]) { groups[groupLabel] = { label: groupLabel, options: [], }; } groups[groupLabel].options.push({ value: buildQueryString(option.property.label, operator, option.value), label: buildQueryString(option.property.label, operator, option.value), tags: option.tags, }); } return Object.values(groups).filter((group) => group.options.length > 0); } export { generateAutoCompleteOptions };