/* eslint-disable @typescript-eslint/no-explicit-any */ import { FilterExpressionViewModel } from "../../../data/advancedSearch"; import { FilterTargetType, Operator, OperatorCreiteria } from "../../../enum"; import { getUniqueId } from "../../../Helper/getUniqueId"; import { defaultCriteria, typeBasedOperators } from "../constant"; export const getTypeBasedOperators = (type: FilterTargetType) => { const operatorList = typeBasedOperators.find( (operator) => operator.type === type ); return operatorList ? operatorList?.operators : []; }; export const getAdvancedSearchIntitialFilterRow = () => { const id = "ADVS__" + getUniqueId(); const defaultAdvanceSearch: FilterExpressionViewModel = { id, idLabel: defaultCriteria, allPropertieseToFilter: [], alloperators: [], allValues: [], selectedFilter: { id, propertyToFilter: { label: "Select Filter...", apiPropertyName: "", type: FilterTargetType.TEXT, value: null, }, operator: { label: "Select Operator...", value: null }, value: undefined, selectedCriteria: OperatorCreiteria.AND, }, }; return defaultAdvanceSearch; }; /** * Operators that carry NO right-hand side. * * "IS NULL" / "IS NOT NULL" are complete on their own — the API's query * generators build them without reading Value, and explicitly skip the * null-value guard every other operator is subject to. Both the Apply-button * rule and the per-row validation ask this rather than each deciding for * itself: when they disagreed, Apply lit up on a row that then failed * validation, or the reverse — a row that could never be applied and never said * why. */ export const operatorNeedsValue = (operator: Operator | null | undefined): boolean => operator !== Operator.IsNull && operator !== Operator.IsNotNull; export const isFilterValueEmpty = (value: any): boolean => { if (value === undefined || value === null || value === "") return true; if (Array.isArray(value)) return value.length === 0; // A Between/NotBetween bound pair. Clearing both inputs yields // `{ start: null, end: null }`, which is empty — without this the Apply // button lights up and an all-null range is written into appliedQuery. if ( typeof value === "object" && !(value instanceof Date) && ("start" in value || "end" in value) ) { return ( (value.start === null || value.start === undefined) && (value.end === null || value.end === undefined) ); } if ( typeof value === "object" && !(value instanceof Date) && "value" in value && (value.value === null || value.value === undefined) ) { return true; } return false; }; // `normalizeLogicOperators` used to live here: a single regex over the gaps // between `)` and `(` that uppercased `and`/`or` and rewrote **anything else** // — including a typo, including a word — to `AND`, with no error. It was the // whole of the criteria box's input handling, and it is defect 45 in // docs/README.md: `(1) ANDD (2)` became `(1) AND (2)` and the user was never // told. `validateCriteriaExpression` below replaces it; the two leniencies // worth keeping (free case, implicit AND between adjacent operands) are carried // over there and are visible in the normalized expression it returns. // ── The criteria expression ────────────────────────────────────────────────── // // The criteria box is the one place in advanced search where the user writes // free text, and until 2.9.0 nothing read it back. `onCriteriaApplied` took // whatever the box contained, ran the AND/OR coercion above over it and stored // it — so an empty box stored `""`, which `buildFilterCriteria` sent as // `{"pattern":"","filters":[…]}`: a request the API answers 200 with the filter // silently ignored, while the toolbar button still showed its applied badge. // Free text went through too — `hello (9) world` was sent verbatim and came // back 500. // // Everything below exists so that cannot happen. The expression is a small // language and it is now parsed rather than pattern-matched: // // expression := operand ( ("AND" | "OR") operand )* // operand := "(" row ")" | "(" expression ")" // row := an integer naming a filter row, 1-based, as `idLabel` spells it // // Two deliberate leniencies, both of which are *shown* rather than assumed: // case is free (`and` → `AND`), and two adjacent operands with nothing between // them take an implicit AND (`(1) (2)` → `(1) AND (2)`). The normalized form is // what gets committed, so the user sees what the leniency did. Anything else — // a stray word, an unbalanced bracket, a row that does not exist, a row left // out, a row named twice — is an error naming the offending token. type CriteriaToken = | { kind: "("; text: string } | { kind: ")"; text: string } | { kind: "logic"; text: string; value: OperatorCreiteria } | { kind: "number"; text: string; value: number } | { kind: "unknown"; text: string }; const tokenizeCriteria = (input: string): CriteriaToken[] => { const tokens: CriteriaToken[] = []; let i = 0; while (i < input.length) { const char = input[i]; if (/\s/.test(char)) { i += 1; continue; } if (char === "(" || char === ")") { tokens.push({ kind: char, text: char }); i += 1; continue; } if (/[0-9]/.test(char)) { let end = i; while (end < input.length && /[0-9]/.test(input[end])) end += 1; const text = input.slice(i, end); tokens.push({ kind: "number", text, value: Number(text) }); i = end; continue; } if (/[A-Za-z]/.test(char)) { let end = i; while (end < input.length && /[A-Za-z]/.test(input[end])) end += 1; const text = input.slice(i, end); const upper = text.toUpperCase(); if (upper === OperatorCreiteria.AND || upper === OperatorCreiteria.OR) { tokens.push({ kind: "logic", text, value: upper as OperatorCreiteria }); } else { tokens.push({ kind: "unknown", text }); } i = end; continue; } // Any other run of punctuation — `&&`, `,`, `+` — is one unknown token, so // the message can quote it whole instead of one character at a time. let end = i; while (end < input.length && !/[\s()A-Za-z0-9]/.test(input[end])) end += 1; tokens.push({ kind: "unknown", text: input.slice(i, end) }); i = end; } return tokens; }; /** The `(n)` token for a row at a 0-based position — the string `idLabel` carries. */ export const getRowLabel = (index: number): string => `(${index + 1})`; /** * The expression every row-level edit produces: every row in order, joined by * each row's own AND/OR chip. * * Add, delete and the AND/OR toggle each rebuilt this inline, three times, with * three slightly different loops — one of which left a trailing separator that * only a `.trim()` hid. It is also what the criteria box's Reset restores to. */ export const buildCriteriaFromRows = ( filterRows: FilterExpressionViewModel[], ): string => filterRows .map((row, index) => index === filterRows.length - 1 ? row.idLabel : `${row.idLabel} ${ row.selectedFilter?.selectedCriteria ?? OperatorCreiteria.AND }`, ) .join(" "); export type CriteriaValidation = { valid: boolean; /** Why it was rejected, phrased for the user. `""` when valid. */ message: string; /** * The expression rewritten canonically — uppercase AND/OR, single spaces, * implicit ANDs made explicit. `""` when invalid; nothing is ever committed * out of an invalid expression. */ expression: string; /** * For each row label, the logic operator that follows its reference — i.e. * what that row's AND/OR chip should read. The row that comes last in the * expression has no operator after it and is absent from the map. * * Derived from the token stream rather than by pairing the Nth operator with * the Nth row, which is what the old code did and which stops being true the * moment the expression is grouped: in `((1) OR (2)) AND (3)` the second * operator belongs after row 2, not after row 1. */ rowOperators: Record; }; const invalidCriteria = (message: string): CriteriaValidation => ({ valid: false, message, expression: "", rowOperators: {}, }); /** * Parse and check a criteria expression against the rows it must describe. * * `rowCount` is how many filter rows exist; the expression must name each of * them — `(1)` through `(rowCount)` — exactly once. That is not pedantry: a row * the expression omits is still sent in `filters`, so the API receives a filter * that no part of the pattern applies, and a row named twice makes the * label→id substitution in Apply rewrite only the first of them. */ export const validateCriteriaExpression = ( input: string, rowCount: number, ): CriteriaValidation => { const example = rowCount > 1 ? `${getRowLabel(0)} AND ${getRowLabel(1)}` : getRowLabel(0); if (!input || !input.trim()) { return invalidCriteria( `Enter a criteria expression — for example ${example}.`, ); } const tokens = tokenizeCriteria(input); const stray = tokens.find((token) => token.kind === "unknown"); if (stray) { return invalidCriteria( `"${stray.text}" is not part of a criteria expression. Use row tokens ` + `like ${getRowLabel(0)} joined by AND or OR.`, ); } // ── Recursive descent over the grammar in the block comment above ───────── const out: CriteriaToken[] = []; let pos = 0; let failure = ""; const peek = (offset = 0): CriteriaToken | undefined => tokens[pos + offset]; const fail = (message: string) => { if (!failure) failure = message; }; const parseOperand = () => { if (failure) return; const token = peek(); if (!token) { fail( "The criteria expression is incomplete — it ends where a row was expected.", ); return; } if (token.kind === "number") { fail( `Put brackets around row numbers — write (${token.text}), not ${token.text}.`, ); return; } if (token.kind === "logic") { fail(`"${token.text.toUpperCase()}" needs a row on both sides of it.`); return; } if (token.kind === ")") { fail('Unmatched ")" in the criteria expression.'); return; } // token.kind === "(" out.push({ kind: "(", text: "(" }); pos += 1; const inner = peek(); const afterInner = peek(1); if (inner?.kind === "number" && afterInner?.kind === ")") { out.push(inner); out.push({ kind: ")", text: ")" }); pos += 2; return; } parseExpression(); if (failure) return; if (peek()?.kind !== ")") { fail('Unclosed "(" in the criteria expression.'); return; } out.push({ kind: ")", text: ")" }); pos += 1; }; function parseExpression() { parseOperand(); while (!failure) { const token = peek(); if (token?.kind === "logic") { out.push({ kind: "logic", text: token.value, value: token.value }); pos += 1; parseOperand(); continue; } // Two operands with nothing between them — the established implicit AND. if (token?.kind === "(") { out.push({ kind: "logic", text: OperatorCreiteria.AND, value: OperatorCreiteria.AND, }); parseOperand(); continue; } break; } } parseExpression(); if (failure) return invalidCriteria(failure); const trailing = peek(); if (trailing) { return invalidCriteria( trailing.kind === ")" ? 'Unmatched ")" in the criteria expression.' : `"${trailing.text}" is left over at the end of the criteria expression.`, ); } // ── The expression parses. Does it describe these rows? ─────────────────── const referenced: number[] = []; const rowOperators: Record = {}; out.forEach((token, index) => { if (token.kind !== "number") return; referenced.push(token.value); // The chip under a row is the first logic token that follows its // reference, at whatever bracket depth that lands. const next = out.slice(index + 1).find((later) => later.kind === "logic"); if (next && next.kind === "logic") { rowOperators[getRowLabel(token.value - 1)] = next.value; } }); const outOfRange = referenced.find((value) => value < 1 || value > rowCount); if (outOfRange !== undefined) { return invalidCriteria( rowCount === 1 ? `(${outOfRange}) is not a filter row — this filter has one row, ${getRowLabel( 0, )}.` : `(${outOfRange}) is not a filter row — use ${getRowLabel( 0, )} to ${getRowLabel(rowCount - 1)}.`, ); } const duplicate = referenced.find( (value, index) => referenced.indexOf(value) !== index, ); if (duplicate !== undefined) { return invalidCriteria( `(${duplicate}) is used more than once — name each filter row exactly once.`, ); } const missing: string[] = []; for (let row = 1; row <= rowCount; row += 1) { if (!referenced.includes(row)) missing.push(getRowLabel(row - 1)); } if (missing.length) { return invalidCriteria( `${missing.join(", ")} ${ missing.length === 1 ? "is" : "are" } missing from the criteria — every filter row must be used, or removed.`, ); } return { valid: true, message: "", expression: out .map((token) => token.text) .join(" ") // The tokens are joined with spaces so AND/OR get theirs; the brackets // are then closed back up, `( 1 )` → `(1)`. .replace(/\(\s+/g, "(") .replace(/\s+\)/g, ")"), rowOperators, }; };