UNPKG

@abaktiar/ql-input

Version:

React QL input component with intelligent autocomplete, parameterized functions, and query validation. Framework-independent with zero external dependencies.

1,486 lines 56.7 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { jsxs, jsx } from "react/jsx-runtime"; import * as React from "react"; import { useState, useRef, useCallback, useEffect } from "react"; class QLExpressionParser { constructor() { __publicField(this, "tokens", []); __publicField(this, "position", 0); } parse(tokens) { this.tokens = tokens.filter((t) => t.type !== "whitespace"); this.position = 0; if (this.tokens.length === 0) { return null; } const hasNonKeywordTokens = this.tokens.some((token) => token.type !== "keyword" && token.type !== "whitespace"); if (!hasNonKeywordTokens) { return null; } return this.parseExpression(); } parseExpression() { return this.parseOrExpression(); } parseOrExpression() { var _a, _b; let left = this.parseAndExpression(); while (((_a = this.current()) == null ? void 0 : _a.type) === "logical" && ((_b = this.current()) == null ? void 0 : _b.value) === "OR") { this.advance(); const right = this.parseAndExpression(); if (this.isLogicalGroup(left) && left.operator === "OR") { left.conditions.push(right); } else { left = { operator: "OR", conditions: [left, right] }; } } return left; } parseAndExpression() { var _a, _b; let left = this.parsePrimaryExpression(); while (((_a = this.current()) == null ? void 0 : _a.type) === "logical" && ((_b = this.current()) == null ? void 0 : _b.value) === "AND") { this.advance(); const right = this.parsePrimaryExpression(); if (this.isLogicalGroup(left) && left.operator === "AND") { left.conditions.push(right); } else { left = { operator: "AND", conditions: [left, right] }; } } return left; } parsePrimaryExpression() { var _a, _b; const token = this.current(); if (!token) { throw new Error("Unexpected end of input"); } if (token.type === "logical" && token.value === "NOT") { this.advance(); const expr = this.parsePrimaryExpression(); if ("operator" in expr && "conditions" in expr) { return { ...expr, not: true }; } else { return { ...expr, not: true }; } } if (token.type === "parenthesis" && token.value === "(") { this.advance(); const expr = this.parseExpression(); if (((_a = this.current()) == null ? void 0 : _a.type) !== "parenthesis" || ((_b = this.current()) == null ? void 0 : _b.value) !== ")") { throw new Error("Expected closing parenthesis"); } this.advance(); return expr; } if (token.type === "field") { return this.parseCondition(); } throw new Error(`Unexpected token: ${token.type} "${token.value}"`); } parseCondition() { const fieldToken = this.current(); if (!fieldToken || fieldToken.type !== "field") { throw new Error("Expected field name"); } const condition = { field: fieldToken.value }; this.advance(); const operatorToken = this.current(); if (!operatorToken || operatorToken.type !== "operator") { throw new Error("Expected operator"); } condition.operator = operatorToken.value; this.advance(); if (this.operatorRequiresValue(condition.operator)) { condition.value = this.parseValue(condition.operator); } return condition; } parseValue(operator) { if (operator === "IN" || operator === "NOT IN") { return this.parseInList(); } const token = this.current(); if (!token) { throw new Error("Expected value"); } if (token.type === "value" || token.type === "field" || token.type === "function" || token.type === "unknown") { let value = token.value; const nextToken = this.tokens[this.position + 1]; if (nextToken && nextToken.type === "parenthesis" && nextToken.value === "(") { return this.parseFunctionCall(value); } if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { value = value.slice(1, -1); } this.advance(); return value; } throw new Error(`Expected value, got ${token.type} "${token.value}"`); } parseFunctionCall(functionName) { var _a, _b, _c, _d, _e; this.advance(); this.advance(); const parameters = []; while (this.current() && !(((_a = this.current()) == null ? void 0 : _a.type) === "parenthesis" && ((_b = this.current()) == null ? void 0 : _b.value) === ")")) { const token = this.current(); if ((token == null ? void 0 : token.type) === "value" || (token == null ? void 0 : token.type) === "field" || (token == null ? void 0 : token.type) === "function" || (token == null ? void 0 : token.type) === "unknown") { let paramValue = token.value; const nextToken = this.tokens[this.position + 1]; if (nextToken && nextToken.type === "parenthesis" && nextToken.value === "(") { paramValue = this.parseFunctionCall(paramValue); } else { if (paramValue.startsWith('"') && paramValue.endsWith('"') || paramValue.startsWith("'") && paramValue.endsWith("'")) { paramValue = paramValue.slice(1, -1); } this.advance(); } parameters.push(paramValue); if (((_c = this.current()) == null ? void 0 : _c.type) === "comma") { this.advance(); } } else { throw new Error(`Unexpected token in function parameters: ${token == null ? void 0 : token.type} "${token == null ? void 0 : token.value}"`); } } if (((_d = this.current()) == null ? void 0 : _d.type) !== "parenthesis" || ((_e = this.current()) == null ? void 0 : _e.value) !== ")") { throw new Error("Expected closing parenthesis for function call"); } this.advance(); if (parameters.length === 0) { return `${functionName}()`; } else { return `${functionName}(${parameters.join(", ")})`; } } parseInList() { var _a, _b, _c, _d, _e, _f, _g; if (((_a = this.current()) == null ? void 0 : _a.type) !== "parenthesis" || ((_b = this.current()) == null ? void 0 : _b.value) !== "(") { throw new Error("Expected opening parenthesis for IN list"); } this.advance(); const values = []; while (this.current() && !(((_c = this.current()) == null ? void 0 : _c.type) === "parenthesis" && ((_d = this.current()) == null ? void 0 : _d.value) === ")")) { const token = this.current(); if ((token == null ? void 0 : token.type) === "value" || (token == null ? void 0 : token.type) === "field" || (token == null ? void 0 : token.type) === "function" || (token == null ? void 0 : token.type) === "unknown") { let value = token.value; const nextToken = this.tokens[this.position + 1]; if (nextToken && nextToken.type === "parenthesis" && nextToken.value === "(") { value = this.parseFunctionCall(value); } else { if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { value = value.slice(1, -1); } this.advance(); } values.push(value); if (((_e = this.current()) == null ? void 0 : _e.type) === "comma") { this.advance(); } } else { throw new Error(`Unexpected token in IN list: ${token == null ? void 0 : token.type} "${token == null ? void 0 : token.value}"`); } } if (((_f = this.current()) == null ? void 0 : _f.type) !== "parenthesis" || ((_g = this.current()) == null ? void 0 : _g.value) !== ")") { throw new Error("Expected closing parenthesis for IN list"); } this.advance(); return values; } operatorRequiresValue(operator) { return !["IS EMPTY", "IS NOT EMPTY"].includes(operator); } current() { return this.tokens[this.position]; } advance() { this.position++; } isLogicalGroup(expr) { return "operator" in expr && "conditions" in expr; } } const LOGICAL_OPERATORS = ["AND", "OR", "NOT"]; const COMPARISON_OPERATORS = ["=", "!=", ">", "<", ">=", "<="]; const TEXT_OPERATORS = ["~", "!~"]; const LIST_OPERATORS = ["IN", "NOT IN"]; const NULL_OPERATORS = ["IS EMPTY", "IS NOT EMPTY"]; const ALL_OPERATORS = [ ...COMPARISON_OPERATORS, ...TEXT_OPERATORS, ...LIST_OPERATORS, ...NULL_OPERATORS ]; const KEYWORDS = ["ORDER", "BY", "ASC", "DESC"]; class QLParser { constructor(_config) { } /** * Tokenize the input string into QL tokens */ tokenize(input) { const tokens = []; let position = 0; while (position < input.length) { const char = input[position]; const remaining = input.slice(position); if (/\s/.test(char)) { const start2 = position; while (position < input.length && /\s/.test(input[position])) { position++; } tokens.push({ type: "whitespace", value: input.slice(start2, position), start: start2, end: position }); continue; } let matched = false; if (char === '"' || char === "'") { const quote = char; const start2 = position; position++; while (position < input.length && input[position] !== quote) { if (input[position] === "\\") { position += 2; } else { position++; } } if (position < input.length) { position++; } tokens.push({ type: "value", value: input.slice(start2, position), start: start2, end: position }); continue; } if (char === "(" || char === ")") { tokens.push({ type: "parenthesis", value: char, start: position, end: position + 1 }); position++; continue; } if (char === ",") { tokens.push({ type: "comma", value: char, start: position, end: position + 1 }); position++; continue; } if (["=", "!", ">", "<", "~"].includes(char)) { const start2 = position; let operator = char; if (position + 1 < input.length) { const nextChar = input[position + 1]; if (char === "!" && nextChar === "=" || char === ">" && nextChar === "=" || char === "<" && nextChar === "=" || char === "!" && nextChar === "~") { operator += nextChar; position++; } } tokens.push({ type: "operator", value: operator, start: start2, end: position + 1 }); position++; continue; } for (const op of ALL_OPERATORS.sort((a, b) => b.length - a.length)) { if (remaining.toUpperCase().startsWith(op)) { const nextChar = remaining[op.length]; if (!nextChar || /\s/.test(nextChar) || nextChar === "(" || nextChar === ")") { tokens.push({ type: "operator", value: op, // Always store in uppercase for consistency start: position, end: position + op.length }); position += op.length; matched = true; break; } } } if (matched) continue; for (const op of LOGICAL_OPERATORS) { if (remaining.toUpperCase().startsWith(op)) { const nextChar = remaining[op.length]; if (!nextChar || /\s/.test(nextChar) || nextChar === "(" || nextChar === ")") { tokens.push({ type: "logical", value: op, // Always store in uppercase for consistency start: position, end: position + op.length }); position += op.length; matched = true; break; } } } if (matched) continue; for (const keyword of KEYWORDS) { if (remaining.toUpperCase().startsWith(keyword)) { const nextChar = remaining[keyword.length]; if (!nextChar || /\s/.test(nextChar) || nextChar === "(" || nextChar === ")") { tokens.push({ type: "keyword", value: keyword, // Always store in uppercase for consistency start: position, end: position + keyword.length }); position += keyword.length; matched = true; break; } } } if (matched) continue; const start = position; while (position < input.length && !/\s/.test(input[position]) && input[position] !== "(" && input[position] !== ")" && input[position] !== "," && input[position] !== '"' && input[position] !== "'") { position++; } if (position > start) { const value = input.slice(start, position); tokens.push({ type: "unknown", // Will be classified later value, start, end: position }); } else { position++; } } return this.classifyTokens(tokens); } /** * Classify unknown tokens based on context */ classifyTokens(tokens) { const context = { expectingField: true, expectingOperator: false, expectingValue: false, expectingLogical: false, parenthesesLevel: 0, inOrderBy: false }; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token.type === "whitespace") continue; if (token.type === "unknown") { if (context.expectingField) { token.type = "field"; context.expectingField = false; context.expectingOperator = true; } else if (context.expectingValue) { token.type = "value"; context.expectingValue = false; context.expectingLogical = true; } else { token.type = "field"; } } if (token.type === "field") { context.expectingOperator = true; context.expectingField = false; context.expectingValue = false; context.expectingLogical = false; } else if (token.type === "operator") { context.expectingValue = true; context.expectingField = false; context.expectingOperator = false; context.expectingLogical = false; } else if (token.type === "value") { context.expectingLogical = true; context.expectingField = false; context.expectingOperator = false; context.expectingValue = false; } else if (token.type === "logical") { context.expectingField = true; context.expectingOperator = false; context.expectingValue = false; context.expectingLogical = false; } else if (token.type === "parenthesis") { if (token.value === "(") { context.parenthesesLevel++; context.expectingField = true; context.expectingOperator = false; context.expectingValue = false; context.expectingLogical = false; } else { context.parenthesesLevel--; context.expectingLogical = true; context.expectingField = false; context.expectingOperator = false; context.expectingValue = false; } } else if (token.type === "keyword") { if (token.value.toUpperCase() === "ORDER") { context.inOrderBy = true; context.expectingField = false; context.expectingOperator = false; context.expectingValue = false; context.expectingLogical = false; } else if (token.value.toUpperCase() === "BY") { context.expectingField = true; context.expectingOperator = false; context.expectingValue = false; context.expectingLogical = false; } } } return tokens; } /** * Parse tokens into a QL query object */ parse(input) { const errors = []; let whereExpression = void 0; let orderBy = []; try { const { whereClause, orderByClause } = this.splitQuery(input); if (whereClause.trim()) { const tokens = this.tokenize(whereClause); const expressionParser = new QLExpressionParser(); const result = expressionParser.parse(tokens); whereExpression = result || void 0; } if (orderByClause.trim()) { orderBy = this.parseOrderBy(orderByClause); } } catch (error) { errors.push(error instanceof Error ? error.message : "Parse error"); } return { where: whereExpression || void 0, orderBy, raw: input, valid: errors.length === 0, errors }; } splitQuery(input) { const orderByMatch = input.match(/\s*ORDER\s+BY\s+/i); if (!orderByMatch) { return { whereClause: input, orderByClause: "" }; } const orderByIndex = orderByMatch.index + orderByMatch[0].length; const whereClause = input.substring(0, orderByMatch.index); const orderByClause = input.substring(orderByIndex); return { whereClause, orderByClause }; } parseOrderBy(orderByClause) { const orderByItems = []; const items = orderByClause.split(",").map((item) => item.trim()); for (const item of items) { const parts = item.trim().split(/\s+/); if (parts.length === 0 || !parts[0]) { continue; } const field = parts[0]; const direction = parts.length > 1 && parts[1].toUpperCase() === "DESC" ? "DESC" : "ASC"; orderByItems.push({ field, direction }); } return orderByItems; } } function isCondition(expr) { return "field" in expr && "operator" in expr; } function isLogicalGroup(expr) { return "operator" in expr && "conditions" in expr; } function toMongooseQuery(expr) { if (isCondition(expr)) { return conditionToMongoDB(expr); } else if (isLogicalGroup(expr)) { return logicalGroupToMongoDB(expr); } return {}; } function conditionToMongoDB(condition) { const { field, operator, value } = condition; switch (operator) { case "=": return { [field]: value }; case "!=": return { [field]: { $ne: value } }; case ">": return { [field]: { $gt: value } }; case "<": return { [field]: { $lt: value } }; case ">=": return { [field]: { $gte: value } }; case "<=": return { [field]: { $lte: value } }; case "IN": return { [field]: { $in: Array.isArray(value) ? value : [value] } }; case "NOT IN": return { [field]: { $nin: Array.isArray(value) ? value : [value] } }; case "~": return { [field]: { $regex: value, $options: "i" } }; case "!~": return { [field]: { $not: { $regex: value, $options: "i" } } }; case "IS EMPTY": return { $or: [{ [field]: null }, { [field]: { $exists: false } }] }; case "IS NOT EMPTY": return { [field]: { $ne: null, $exists: true } }; default: return { [field]: value }; } } function logicalGroupToMongoDB(group) { const conditions = group.conditions.map(toMongooseQuery); if (group.operator === "AND") { return { $and: conditions }; } else if (group.operator === "OR") { return { $or: conditions }; } return {}; } function toSQLQuery(expr) { if (isCondition(expr)) { return conditionToSQL(expr); } else if (isLogicalGroup(expr)) { return logicalGroupToSQL(expr); } return ""; } function conditionToSQL(condition) { const { field, operator, value } = condition; switch (operator) { case "=": return `${field} = '${value}'`; case "!=": return `${field} != '${value}'`; case ">": return `${field} > '${value}'`; case "<": return `${field} < '${value}'`; case ">=": return `${field} >= '${value}'`; case "<=": return `${field} <= '${value}'`; case "IN": const inValues = Array.isArray(value) ? value : [value]; return `${field} IN (${inValues.map((v) => `'${v}'`).join(", ")})`; case "NOT IN": const notInValues = Array.isArray(value) ? value : [value]; return `${field} NOT IN (${notInValues.map((v) => `'${v}'`).join(", ")})`; case "~": return `${field} LIKE '%${value}%'`; case "!~": return `${field} NOT LIKE '%${value}%'`; case "IS EMPTY": return `${field} IS NULL`; case "IS NOT EMPTY": return `${field} IS NOT NULL`; default: return `${field} = '${value}'`; } } function logicalGroupToSQL(group) { const conditions = group.conditions.map(toSQLQuery); const operator = group.operator; if (conditions.length === 1) { return conditions[0]; } return `(${conditions.join(` ${operator} `)})`; } function countConditions(expr) { if (isCondition(expr)) { return 1; } else if (isLogicalGroup(expr)) { return expr.conditions.reduce((count, condition) => count + countConditions(condition), 0); } return 0; } function getUsedFields(expr) { if (isCondition(expr)) { return [expr.field]; } else if (isLogicalGroup(expr)) { return expr.conditions.flatMap(getUsedFields); } return []; } function printExpression(expr, indent = 0) { const spaces = " ".repeat(indent); if (isCondition(expr)) { return `${spaces}${expr.field} ${expr.operator} ${Array.isArray(expr.value) ? `[${expr.value.join(", ")}]` : expr.value}`; } else if (isLogicalGroup(expr)) { const conditions = expr.conditions.map((c) => printExpression(c, indent + 1)).join("\n"); return `${spaces}${expr.operator}: ${conditions}`; } return ""; } function classNames(...classes) { return classes.filter(Boolean).join(" "); } class QLSuggestionEngine { constructor(config) { __publicField(this, "config"); this.config = config; } /** * Get suggestions based on current context */ getSuggestions(context) { const suggestions = []; const { partialInput = "" } = context; if (context.expectingField) { suggestions.push(...this.getFieldSuggestions()); if (this.config.allowFunctions) { suggestions.push(...this.getFunctionSuggestions()); } } else if (context.expectingOperator && context.lastField) { suggestions.push(...this.getOperatorSuggestions(context.lastField)); } else if (context.expectingValue && context.lastField) { if (context.inInList) { suggestions.push(...this.getInListValueSuggestions(context.lastField)); } else { suggestions.push(...this.getValueSuggestions(context.lastField)); } } else if (context.expectingLogical) { suggestions.push(...this.getLogicalOperatorSuggestions()); } else if (context.inOrderBy) { if (context.expectingField) { suggestions.push(...this.getSortableFieldSuggestions()); } else { suggestions.push(...this.getSortDirectionSuggestions()); } } if (this.config.allowParentheses && (context.expectingField || context.expectingLogical)) { suggestions.push({ type: "parenthesis", value: "(", displayValue: "(", description: "Group conditions", insertText: "(" }); } if (context.inInList) { suggestions.push({ type: "comma", value: ",", displayValue: ",", description: "Add another value", insertText: ", " }); } const filtered = this.filterSuggestions(suggestions, partialInput); const maxSuggestions = this.config.maxSuggestions || 10; return filtered.slice(0, maxSuggestions); } /** * Get field suggestions */ getFieldSuggestions() { return this.config.fields.map((field) => ({ type: "field", value: field.name, displayValue: field.displayName || field.name, description: field.description, category: "Fields" })); } /** * Get operator suggestions for a specific field */ getOperatorSuggestions(fieldName) { const field = this.config.fields.find((f) => f.name === fieldName); if (!field) return []; return field.operators.map((operator) => { const isListOperator = operator === "IN" || operator === "NOT IN"; const insertText = isListOperator ? `${operator} (` : operator; return { type: "operator", value: operator, displayValue: operator, description: this.getOperatorDescription(operator), insertText, category: "Operators" }; }); } /** * Get value suggestions for a specific field */ getValueSuggestions(fieldName) { const field = this.config.fields.find((f) => f.name === fieldName); if (!field) return []; const suggestions = []; if (field.options) { suggestions.push( ...field.options.map((option) => { const needsQuotes = /\s/.test(option.value) || /[,()"]/.test(option.value); const quotedValue = needsQuotes ? `"${option.value}"` : option.value; return { type: "value", value: option.value, displayValue: option.displayValue || option.value, description: option.description, category: "Values", insertText: needsQuotes ? quotedValue : void 0 // Only set insertText if quoting is needed }; }) ); } if (field.type === "boolean") { suggestions.push( { type: "value", value: "true", displayValue: "true", category: "Values" }, { type: "value", value: "false", displayValue: "false", category: "Values" } ); } if (this.config.allowFunctions) { suggestions.push(...this.getFunctionSuggestions()); } return suggestions; } /** * Get operator description */ getOperatorDescription(operator) { const descriptions = { "=": "Equals", "!=": "Not equals", ">": "Greater than", "<": "Less than", ">=": "Greater than or equal", "<=": "Less than or equal", "~": "Contains", "!~": "Does not contain", IN: "In list", "NOT IN": "Not in list", "IS EMPTY": "Is empty", "IS NOT EMPTY": "Is not empty" }; return descriptions[operator] || operator; } /** * Get logical operator suggestions */ getLogicalOperatorSuggestions() { const operators = this.config.logicalOperators || ["AND", "OR", "NOT"]; return operators.map((operator) => ({ type: "logical", value: operator, displayValue: operator, description: this.getLogicalOperatorDescription(operator), category: "Logical" })); } /** * Get sortable field suggestions for ORDER BY */ getSortableFieldSuggestions() { return this.config.fields.filter((field) => field.sortable !== false).map((field) => ({ type: "field", value: field.name, displayValue: field.displayName || field.name, description: field.description, category: "Sortable Fields" })); } /** * Get sort direction suggestions */ getSortDirectionSuggestions() { const directions = ["ASC", "DESC"]; return directions.map((direction) => ({ type: "keyword", value: direction, displayValue: direction, description: direction === "ASC" ? "Ascending" : "Descending", category: "Sort Direction" })); } /** * Get function suggestions */ getFunctionSuggestions() { if (!this.config.functions) return []; return this.config.functions.map((func) => { var _a; const hasParameters = func.parameters && func.parameters.length > 0; let insertText; let displayValue; if (hasParameters) { const paramPlaceholders = func.parameters.map((param) => { let placeholder; switch (param.type) { case "text": placeholder = param.required ? `"${param.name}"` : `"${param.name}?"`; break; case "number": placeholder = param.required ? param.name : `${param.name}?`; break; case "date": case "datetime": placeholder = param.required ? `"YYYY-MM-DD"` : `"YYYY-MM-DD?"`; break; default: placeholder = param.required ? param.name : `${param.name}?`; } return placeholder; }); insertText = `${func.name}(${paramPlaceholders.join(", ")})`; displayValue = func.displayName || `${func.name}(${((_a = func.parameters) == null ? void 0 : _a.map((p) => p.name).join(", ")) || ""})`; } else { insertText = `${func.name}()`; displayValue = func.displayName || `${func.name}()`; } return { type: "function", value: func.name, displayValue, description: func.description, insertText, category: "Functions" }; }); } /** * Get value suggestions for IN lists (inside parentheses) */ getInListValueSuggestions(fieldName) { const field = this.config.fields.find((f) => f.name === fieldName); if (!field) return []; const suggestions = []; if (field.options) { suggestions.push( ...field.options.map((option) => { const needsQuotes = /\s/.test(option.value) || /[,()"]/.test(option.value); const quotedValue = needsQuotes ? `"${option.value}"` : option.value; return { type: "value", value: option.value, displayValue: option.displayValue || option.value, description: option.description, category: "Values", // Add comma and space after value for easier multi-selection in IN lists insertText: `${quotedValue}, ` }; }) ); } if (this.config.allowFunctions && this.config.functions) { suggestions.push( ...this.config.functions.map((func) => ({ type: "function", value: func.name, displayValue: func.displayName || func.name, description: func.description, category: "Functions", // Add comma and space after function for easier multi-selection in IN lists insertText: `${func.name}(), ` })) ); } return suggestions; } /** * Get logical operator description */ getLogicalOperatorDescription(operator) { const descriptions = { AND: "Both conditions must be true", OR: "Either condition can be true", NOT: "Negates the condition" }; return descriptions[operator] || operator; } /** * Filter suggestions based on partial input */ filterSuggestions(suggestions, partialInput) { if (!partialInput.trim()) { return suggestions; } const searchTerm = this.config.caseSensitive ? partialInput : partialInput.toLowerCase(); const filtered = suggestions.filter((suggestion) => { const value = this.config.caseSensitive ? suggestion.value : suggestion.value.toLowerCase(); const displayValue = this.config.caseSensitive ? suggestion.displayValue || suggestion.value : (suggestion.displayValue || suggestion.value).toLowerCase(); const matches = value.includes(searchTerm) || displayValue.includes(searchTerm); return matches; }); return filtered; } /** * Get suggestion context from input and cursor position */ getSuggestionContext(input, cursorPosition, tokens) { const context = { input, cursorPosition, tokens, expectingField: true, expectingOperator: false, expectingValue: false, expectingLogical: false, inOrderBy: false, parenthesesLevel: 0, inInList: false }; let currentTokenIndex = -1; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token.start <= cursorPosition && cursorPosition <= token.end) { currentTokenIndex = i; break; } else if (token.start > cursorPosition) { currentTokenIndex = i - 1; break; } } if (currentTokenIndex === -1 && tokens.length > 0) { currentTokenIndex = tokens.length - 1; } if (currentTokenIndex >= 0) { context.currentToken = tokens[currentTokenIndex]; context.previousToken = currentTokenIndex > 0 ? tokens[currentTokenIndex - 1] : void 0; context.nextToken = currentTokenIndex < tokens.length - 1 ? tokens[currentTokenIndex + 1] : void 0; } let expectingField = true; let expectingOperator = false; let expectingValue = false; let expectingLogical = false; let inOrderBy = false; let parenthesesLevel = 0; let inInList = false; let lastField; let lastOperator; let originalField; for (let i = 0; i <= currentTokenIndex; i++) { const token = tokens[i]; if (token.type === "whitespace") continue; if (token.type === "keyword" && token.value.toUpperCase() === "ORDER") { inOrderBy = true; expectingField = false; expectingOperator = false; expectingValue = false; expectingLogical = false; } else if (token.type === "keyword" && token.value.toUpperCase() === "BY") { expectingField = true; expectingOperator = false; expectingValue = false; expectingLogical = false; } else if (token.type === "field") { if (!inInList) { lastField = token.value; originalField = token.value; } if (inOrderBy) { expectingField = false; expectingOperator = false; expectingValue = false; expectingLogical = false; } else if (!inInList) { expectingField = false; expectingOperator = true; expectingValue = false; expectingLogical = false; } } else if (token.type === "operator") { lastOperator = token.value; expectingField = false; expectingOperator = false; expectingValue = true; expectingLogical = false; if (token.value === "IN" || token.value === "NOT IN") { for (let j = i + 1; j < tokens.length; j++) { const nextToken = tokens[j]; if (nextToken.type === "whitespace") continue; if (nextToken.type === "parenthesis" && nextToken.value === "(") { inInList = true; } break; } } } else if (token.type === "value") { expectingField = false; expectingOperator = false; expectingValue = false; expectingLogical = true; } else if (token.type === "logical") { expectingField = true; expectingOperator = false; expectingValue = false; expectingLogical = false; } else if (token.type === "parenthesis") { if (token.value === "(") { parenthesesLevel++; if (!inInList) { expectingField = true; expectingOperator = false; expectingValue = false; expectingLogical = false; } } else if (token.value === ")") { parenthesesLevel--; if (inInList && parenthesesLevel === 0) { inInList = false; expectingField = false; expectingOperator = false; expectingValue = false; expectingLogical = true; } else { expectingField = false; expectingOperator = false; expectingValue = false; expectingLogical = true; } } } else if (token.type === "comma") { if (inInList) { expectingField = false; expectingOperator = false; expectingValue = true; expectingLogical = false; } } } let partialInput = ""; if (context.currentToken && context.currentToken.type !== "whitespace") { const tokenStart = context.currentToken.start; const tokenEnd = context.currentToken.end; if (cursorPosition >= tokenStart && cursorPosition <= tokenEnd) { partialInput = input.slice(tokenStart, cursorPosition); if (context.currentToken.type === "parenthesis" || context.currentToken.type === "comma") { partialInput = ""; } } else { partialInput = context.currentToken.value; if (context.currentToken.type === "parenthesis" || context.currentToken.type === "comma") { partialInput = ""; } } } else { const beforeCursor = input.slice(0, cursorPosition); const match = beforeCursor.match(/(\w+)$/); if (match) { partialInput = match[1]; } } if (context.currentToken && context.currentToken.type !== "whitespace" && partialInput) { const isPartialToken = cursorPosition >= context.currentToken.start && cursorPosition <= context.currentToken.end; if (isPartialToken) { if (context.currentToken.type === "field" || context.currentToken.type === "unknown") { if (inInList) { expectingField = false; expectingOperator = false; expectingValue = true; expectingLogical = false; } else { expectingField = true; expectingOperator = false; expectingValue = false; expectingLogical = false; } } else if (context.currentToken.type === "operator") { expectingField = false; expectingOperator = true; expectingValue = false; expectingLogical = false; } else if (context.currentToken.type === "value") { expectingField = false; expectingOperator = false; expectingValue = true; expectingLogical = false; } else if (context.currentToken.type === "logical") { expectingField = false; expectingOperator = false; expectingValue = false; expectingLogical = true; } else if (context.currentToken.type === "function") { expectingField = false; expectingOperator = false; expectingValue = true; expectingLogical = false; } } } context.expectingField = expectingField; context.expectingOperator = expectingOperator; context.expectingValue = expectingValue; context.expectingLogical = expectingLogical; context.inOrderBy = inOrderBy; context.parenthesesLevel = parenthesesLevel; context.inInList = inInList; context.lastField = inInList && originalField ? originalField : lastField; context.lastOperator = lastOperator; context.partialInput = partialInput; return context; } } function useQLInput({ config, initialValue = "", onChange, onExecute, getAsyncValueSuggestions, getPredefinedValueSuggestions }) { const [state, setState] = useState({ value: initialValue, tokens: [], suggestions: [], showSuggestions: false, selectedSuggestionIndex: 0, cursorPosition: 0, query: { raw: initialValue, valid: true, errors: [] }, validationErrors: [], isLoading: false, justSelectedSuggestion: false }); const parser = useRef(new QLParser(config)); const suggestionEngine = useRef(new QLSuggestionEngine(config)); const updateQuery = useCallback( (value) => { const tokens = parser.current.tokenize(value); const query = parser.current.parse(value); setState((prev) => ({ ...prev, tokens, query })); onChange == null ? void 0 : onChange(value, query); }, [onChange] ); const updateSuggestions = useCallback( async (value, cursorPosition) => { const tokens = parser.current.tokenize(value); const context = suggestionEngine.current.getSuggestionContext(value, cursorPosition, tokens); let suggestions = suggestionEngine.current.getSuggestions(context); if (context.expectingValue && context.lastField && getAsyncValueSuggestions) { const field = config.fields.find((f) => f.name === context.lastField); if ((field == null ? void 0 : field.asyncValueSuggestions) && context.partialInput) { setState((prev) => ({ ...prev, isLoading: true })); try { const asyncValues = await getAsyncValueSuggestions(context.lastField, context.partialInput); const asyncSuggestions = asyncValues.map((value2) => ({ type: "value", value: value2.value, displayValue: value2.displayValue || value2.value, description: value2.description, insertText: value2.value.includes(" ") ? `"${value2.value}"` : value2.value, category: "Dynamic Values" })); suggestions = [...asyncSuggestions, ...suggestions]; } catch (error) { console.error("Failed to fetch async suggestions:", error); } finally { setState((prev) => ({ ...prev, isLoading: false })); } } } if (context.expectingValue && context.lastField && getPredefinedValueSuggestions) { const predefinedValues = getPredefinedValueSuggestions(context.lastField); const predefinedSuggestions = predefinedValues.map((value2) => ({ type: "value", value: value2.value, displayValue: value2.displayValue || value2.value, description: value2.description, insertText: value2.value.includes(" ") ? `"${value2.value}"` : value2.value, category: "Predefined Values" })); suggestions = [...predefinedSuggestions, ...suggestions]; } setState((prev) => ({ ...prev, suggestions, selectedSuggestionIndex: 0, showSuggestions: suggestions.length > 0 })); }, [config.fields, getAsyncValueSuggestions, getPredefinedValueSuggestions] ); const handleInputChange = useCallback( (value, cursorPosition) => { const currentState = state; const prevValue = currentState.value; const lastCharTyped = value.length > prevValue.length ? value[value.length - 1] : ""; const shouldShowSuggestions = !currentState.justSelectedSuggestion || lastCharTyped === " "; setState((prev) => ({ ...prev, value, cursorPosition, justSelectedSuggestion: prev.justSelectedSuggestion && lastCharTyped !== " " // Clear flag when space is typed })); updateQuery(value); if (shouldShowSuggestions) { updateSuggestions(value, cursorPosition); } else { setState((prev) => ({ ...prev, showSuggestions: false, suggestions: [], selectedSuggestionIndex: 0 })); } }, [state, updateQuery, updateSuggestions] ); const selectSuggestion = useCallback( (suggestion) => { const { value, cursorPosition, tokens } = state; const currentToken = tokens.find((token) => cursorPosition >= token.start && cursorPosition <= token.end); let newValue; let newCursorPosition; if (currentToken && currentToken.type !== "whitespace") { const isInListPunctuation = currentToken.type === "parenthesis" && currentToken.value === "(" || currentToken.type === "comma"; if (isInListPunctuation && (suggestion.type === "value" || suggestion.type === "function")) { const before = value.slice(0, currentToken.end); const after = value.slice(currentToken.end); const insertText = suggestion.insertText || suggestion.value; newValue = before + insertText + after; newCursorPosition = currentToken.end + insertText.length; } else { const before = value.slice(0, currentToken.start); const after = value.slice(currentToken.end); const insertText = suggestion.insertText || suggestion.value; newValue = before + insertText + after; newCursorPosition = currentToken.start + insertText.length; } } else { const before = value.slice(0, cursorPosition); const after = value.slice(cursorPosition); const insertText = suggestion.insertText || suggestion.value; newValue = before + insertText + after; newCursorPosition = cursorPosition + insertText.length; } setState((prev) => ({ ...prev, value: newValue, cursorPosition: newCursorPosition, showSuggestions: false, selectedSuggestionIndex: 0, justSelectedSuggestion: true // Flag to prevent immediate suggestions })); updateQuery(newValue); }, [state, updateQuery] ); const handleKeyDown = useCallback( (event) => { if (event.ctrlKey && event.key === " ") { event.preventDefault(); setState((prev) => ({ ...prev, justSelectedSuggestion: false })); updateSuggestions(state.value, state.cursorPosition); return; } if (!state.showSuggestions) { if (event.key === "Enter") { onExecute == null ? void 0 : onExecute(state.query); return; } return; } switch (event.key) { case "ArrowDown": event.preventDefault(); setState((prev) => ({ ...prev, selectedSuggestionIndex: Math.min(prev.selectedSuggestionIndex + 1, prev.suggestions.length - 1) })); break; case "ArrowUp": event.preventDefault(); setState((prev) => ({ ...prev, selectedSuggestionIndex: Math.max(prev.selectedSuggestionIndex - 1, 0) })); break; case "Enter": event.preventDefault(); if (state.suggestions[state.selectedSuggestionIndex]) { selectSuggestion(state.suggestions[state.selectedSuggestionIndex]); } break; case "Escape": event.preventDefault(); setState((prev) => ({ ...prev, showSuggestions: false, selectedSuggestionIndex: 0 })); break; case "Tab": event.preventDefault(); if (state.suggestions[state.selectedSuggestionIndex]) { selectSuggestion(state.suggestions[state.selectedSuggestionIndex]); } break; } }, [ state.showSuggestions, state.suggestions, state.selectedSuggestionIndex, state.query, state.value, state.cursorPosition, onExecute, selectSuggestion, updateSuggestions ] ); const hideSuggestions = useCallback(() => { setState((prev) => ({ ...prev, showSuggestions: false, selectedSuggestionIndex: 0 })); }, []); return { state, handleInputChange, handleKeyDown, selectSuggestion, hideSuggestions }; } const SearchIcon = ({ className = "", size = 16 }) => /* @__PURE__ */ jsxs( "svg", { className: `ql-icon-search ${className}`, width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [ /* @__PURE__ */ jsx("circle", { cx: "11", cy: "11", r: "8" }), /* @__PURE__ */ jsx("path", { d: "m21 21-4.35-4.35" }) ] } ); const XIcon = ({ className = "", size = 16 }) => /* @__PURE__ */ jsxs( "svg", { className: `ql-icon-x ${className}`, width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block" }, children: [ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }) ] } ); const LoaderIcon = ({ className = "", size = 16 }) => /* @__PURE__ */ jsx( "svg", { className: `ql-icon-loader ${className}`, width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("path", { d: "M21 12a9 9 0 11-6.219-8.56" }) } ); const ChevronDownIcon = ({ className = "", size = 16 }) => /* @__PURE__ */ jsx( "svg", { className: `ql-icon-chevron-down ${className}`, width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" }) } ); const CheckIcon = ({ className = "", size = 16 }) => /* @__PURE__ */ jsx( "svg", { className: `ql-icon-check ${className}`, width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" }) } ); const QLInput = React.forwardRef( ({ value, onChange, onExecute, config, placeholder = "Enter QL query...", disabled = false, className, showSearchIcon = true, showClearIcon = true, getAsyncValueSuggestions, getPredefinedValueSuggestions, ...props }, ref) => { const inputRef = React.useRef(null); const suggestionsRef = React.useRef(null); const [isOpen, setIsOpen] = React.useState(false); const { state, h