/* eslint-disable @typescript-eslint/no-explicit-any */ import { useId, useMemo, useState } from "react"; import FactoryRenderer from "../../../../Renderer"; import { FilterExpressionViewModel, FilterTarget, } from "../../data/advancedSearch"; import { Operator, OperatorCreiteria } from "../../enum"; import Filter from "./Filter"; import { buildCriteriaFromRows, isFilterValueEmpty, operatorNeedsValue, validateCriteriaExpression, } from "./helper/AdvancedSearchFunctions"; interface FilterPopupViewProps { uiElementGroupId: string; filterRows: FilterExpressionViewModel[]; criteria: string; cache?: Record; loadSupportiveData: (propertyToFilter: FilterTarget) => Promise; onFilterTargetChange: (value: FilterTarget, id: string) => void; onOperatorChange: ( value: { label: string; value: Operator | null }, id: string ) => void; onFilterValueChange: (value: any, id: string) => void; onCriteriaToggle: (id: string) => void; onAddFilter: (id: string) => void; onDeleteFilter: (id: string) => void; onClear: () => void; onCriteriaChange: ( expression: string, rowOperators: Record, ) => void; onCopyToClipboard: (expression: string) => void; onCancel: () => void; onApply: ( expression: string, rowOperators: Record, ) => void; isApplyDisabled?: boolean; isClearDisabled?: boolean; } /** * Why each row is not ready to apply, in the order the user fills the row in. * One message per row, naming the cell to go to — the row used to turn pink and * say nothing, which told a user that something was wrong but not what. */ const getRowError = (row: FilterExpressionViewModel): string => { if (!row.selectedFilter?.propertyToFilter?.apiPropertyName) { return "Choose a filter for this row."; } if (row.selectedFilter?.operator?.value == null) { return "Choose an operator for this row."; } // Is Empty / Is Not Empty are complete without a value — demanding one // leaves a perfectly valid row flagged. if ( operatorNeedsValue(row.selectedFilter?.operator?.value) && isFilterValueEmpty(row.selectedFilter?.value) ) { return "Enter a value for this row."; } return ""; }; const FilterPopupView = (props: FilterPopupViewProps) => { const [rowErrors, setRowErrors] = useState>({}); // ── The criteria expression ──────────────────────────────────────────────── // // The box was a `contentEditable` paragraph that React rendered once and then // never owned: the user typed into the DOM, and the only way the value came // back was `getElementById(...).innerText` when the ✓ beside it was clicked. // Three things followed. Text that was never confirmed stayed on screen while // the model held something else, so the box could show `JUNK I TYPED` while // Apply used the last confirmed expression. Two toolbars sharing a // `uiElementGroupId` read each other's box. And whatever was in it went // straight into `appliedQuery` — including nothing at all. // // It is now an ordinary controlled input with a local draft. The draft is // what the user sees and what Apply uses; it is committed to the model on // every keystroke that parses, and re-seeded from the model whenever a row // edit rewrites the expression. `committedCriteria` is what keeps those two // from fighting: a criteria prop matching what this component last pushed is // its own echo, not an outside change, so it must not reset the caret. This // is React's documented "adjusting state when a prop changes" — a render-time // comparison against the last seen value, which re-renders before the DOM is // touched rather than flashing the stale draft through an effect. const [criteriaDraft, setCriteriaDraft] = useState(props.criteria); const [committedCriteria, setCommittedCriteria] = useState(props.criteria); if (props.criteria !== committedCriteria) { setCommittedCriteria(props.criteria); setCriteriaDraft(props.criteria); } const criteriaValidation = useMemo( () => validateCriteriaExpression(criteriaDraft, props.filterRows.length), [criteriaDraft, props.filterRows.length], ); // Shown as soon as it is true, not held back until the user has "earned" it. // Every row-level edit — add, delete, the AND/OR chip — rebuilds the // expression from the rows and so is valid by construction; the only way to // be looking at an invalid one is to have typed it, or to have opened a saved // view that stored one back when nothing checked. Both want telling. const showCriteriaError = !criteriaValidation.valid; const inputId = useId(); const hintId = useId(); const commitCriteria = (next: string) => { setCriteriaDraft(next); const validation = validateCriteriaExpression( next, props.filterRows.length, ); // An expression that does not parse stays local. The model keeps the last // good one, and Apply refuses until the box agrees with it. if (!validation.valid) return; setCommittedCriteria(validation.expression); props.onCriteriaChange(validation.expression, validation.rowOperators); }; // Blur is where the normalized form is shown: `(1) and (2)` becomes // `(1) AND (2)` in the box, so the two leniencies the parser allows are // visible rather than assumed. const onCriteriaBlur = () => { if (criteriaValidation.valid) setCriteriaDraft(criteriaValidation.expression); }; const resetCriteria = () => { commitCriteria(buildCriteriaFromRows(props.filterRows)); }; const clearRowError = (id: string) => setRowErrors((prev) => { if (!prev[id]) return prev; const next = { ...prev }; delete next[id]; return next; }); const validateAndApply = () => { const errors: Record = {}; props.filterRows.forEach((row) => { const message = getRowError(row); if (message) errors[row.id] = message; }); setRowErrors(errors); // Both halves are reported at once. Fixing the rows only to be told the // expression is also wrong is two round trips for one click. if (Object.keys(errors).length > 0 || !criteriaValidation.valid) return; props.onApply( criteriaValidation.expression, criteriaValidation.rowOperators, ); }; return ( <>
Filter
Operator
Value
{props.filterRows?.map((filterRow: FilterExpressionViewModel) => ( { clearRowError(id); props.onFilterTargetChange(value, id); }} onOperatorChange={(value, id) => { clearRowError(id); props.onOperatorChange(value, id); }} onFilterValueChange={(value, id) => { clearRowError(id); props.onFilterValueChange(value, id); }} onCriteriaToggle={props.onCriteriaToggle} onAddFilter={props.onAddFilter} onDeleteFilter={props.onDeleteFilter} loadSupportiveData={props.loadSupportiveData} errorMessage={rowErrors[filterRow.id]} /> ))}
{/* The expression used to be a 150px unlabelled box wedged between Clear Filter and Cancel, with a ✓ that had to be clicked for it to count and no way to find out what it wanted. It is its own band now: named, as wide as the panel, with the syntax written under it and the reason for a rejection in place of that syntax. */}
props.onCopyToClipboard(criteriaDraft)} uiElementType="WIDGET" widgetType="ICON" />
commitCriteria(event.target.value)} onBlur={onCriteriaBlur} />

{showCriteriaError ? criteriaValidation.message : "Join the rows above with AND / OR, and group with brackets — ((1) OR (2)) AND (3)."}

{/* A button, not a clickable span: focusable, in the tab order, and drawn by the theme's link variant. `tmpl-cancel-action` is a hook only. */}
); }; export default FilterPopupView;