diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 3dc761f1..7c9b989d 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -118,12 +118,14 @@ Switch to an already-open tab/window (fuzzy match). ### Reading form state -#### `getFormState()` → `{ fields, buttons, tabs, table, filters, reportSettings? }` +#### `getFormState()` → `{ fields, buttons, tabs, table, tables, filters, reportSettings? }` Returns current form structure. This is the primary way to understand what's on screen. **fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields) -**table** — summary only: `{ name, columns, rowCount }`. Use `readTable()` for actual data. +**tables** — array of all visible grids: `[{ name, columns, rowCount, label? }]`. `label` is the visual group title shown on screen (e.g. "Входящие"), absent when grid has no visible title. Use `readTable()` for actual data. + +**table** — backward-compatible alias for the first grid: `{ present, columns, rowCount }`. **reportSettings** — for DCS reports: human-readable filter settings instead of raw technical names: ```js @@ -140,13 +142,14 @@ const form = await getFormState(); ### Reading data -#### `readTable({ maxRows?, offset? })` → `{ columns, rows, total, shown, offset }` +#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset }` Read actual grid data with pagination. Each row is `{ columnName: value }`. | Option | Default | Description | |--------|---------|-------------| | `maxRows` | 20 | Max rows to return per call | | `offset` | 0 | Skip first N rows | +| `table` | — | Grid name from `tables[]` (for multi-grid forms) | Special row fields: - `_kind: 'group'` — hierarchical group row @@ -190,9 +193,13 @@ Sections + all open tabs. ### Actions -#### `clickElement(text, { dblclick? })` → form state +#### `clickElement(text, { dblclick?, table? })` → form state Click button, hyperlink, tab, or grid row (fuzzy match). +- `table` — scope button search to a specific grid's command panel (by name from `tables[]`): + ```js + await clickElement('Добавить', { table: 'Исходящие' }); // clicks "Добавить" near "Исходящие" grid + ``` - Single click selects a row in a list. **Double-click opens** the item: ```js await clickElement('0000-000227', { dblclick: true }); // opens document @@ -250,6 +257,13 @@ Also supports DCS labels — auto-enables the paired checkbox. #### `fillTableRow(fields, opts)` → form state Fill table row cells via Tab navigation. Value is a plain string or `{ value, type }` for composite-type cells. +| Option | Description | +|--------|-------------| +| `tab` | Switch to tab before filling | +| `add` | Add new row before filling | +| `row` | Edit existing row by 0-based index | +| `table` | Grid name from `tables[]` (for multi-grid forms) | + ```js // Add new row: await fillTableRow( @@ -261,6 +275,11 @@ await fillTableRow( { 'Количество': '20' }, { tab: 'Товары', row: 0 } ); +// Multi-grid form — add row to specific table: +await fillTableRow( + { 'Объект': 'БДДС' }, + { table: 'Исходящие', add: true } +); // Composite-type cell (e.g. SubConto accepting multiple types): await fillTableRow( { 'СубконтоКт1': { value: 'Голованов', type: 'Физическое лицо' } }, @@ -272,8 +291,8 @@ await fillTableRow( - Fuzzy cell match: "Количество" matches "ТоварыКоличество" - Reference cells auto-detected by autocomplete popup -#### `deleteTableRow(row, { tab? })` → form state -Delete row by 0-based index. +#### `deleteTableRow(row, { tab?, table? })` → form state +Delete row by 0-based index. `table` targets a specific grid on multi-grid forms. #### `closeForm({ save? })` → form state Close the current form via Escape. @@ -359,6 +378,37 @@ console.log('Title:', report.title); console.log('Data rows:', report.data?.length); ``` +### Work with multi-grid forms + +Some forms have multiple grids (e.g. "Входящие" and "Исходящие" tables on a single form). Without `table`, buttons like "Добавить" hit the first match and `readTable` reads the first grid — which may not be the one you need. + +**Step 1 — discover tables** via `getFormState()`: +```js +const form = await getFormState(); +// form.tables = [ +// { name: "ДеревоБизнесПроцессов", columns: ["Полный код", "Бизнес-процесс"], rowCount: 21 }, +// { name: "Входящие", label: "Входящие", columns: ["Объект", "Бизнес-процесс источник", ...], rowCount: 1 }, +// { name: "Исходящие", label: "Исходящие", columns: ["Объект", "Бизнес-процесс приемник", ...], rowCount: 1 } +// ] +``` + +**Step 2 — use `table` name** in any grid operation: +```js +// Read specific table +const t = await readTable({ table: 'Исходящие' }); + +// Add row — fillTableRow with add:true already clicks the right "Добавить" button +await fillTableRow({ 'Объект': 'БДДС' }, { table: 'Исходящие', add: true }); + +// Or click buttons separately +await clickElement('Добавить', { table: 'Входящие' }); + +// Delete from specific table +await deleteTableRow(0, { table: 'Исходящие' }); +``` + +Table matching accepts both technical name (`tables[].name`) and visual label (`tables[].label`). Label is the group title shown on screen — useful when working from screenshots. Name match takes priority over label match. + ### Keyboard shortcuts (via `page.keyboard.press`) | Key | Context | Action | diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 5483f75e..af6a6544 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -18,7 +18,7 @@ import { findClickTargetScript, findFieldButtonScript, readSubmenuScript, resolveFieldsScript, getFormStateScript, detectFormScript, readTableScript, checkErrorsScript, - switchTabScript + switchTabScript, resolveGridScript } from './dom.mjs'; let browser = null; @@ -548,11 +548,17 @@ export async function getFormState() { } /** Read structured table data with pagination. Returns columns, rows, total count. */ -export async function readTable({ maxRows = 20, offset = 0 } = {}) { +export async function readTable({ maxRows = 20, offset = 0, table } = {}) { ensureConnected(); const formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error('readTable: no form found'); - return await page.evaluate(readTableScript(formNum, { maxRows, offset })); + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); } /** @@ -739,72 +745,50 @@ export async function readSpreadsheet() { } /** - * Pick a value from an opened selection form: filter + dblclick matching row. - * - * Strategy: - * - string: simple search via filterList → dblclick first row - * - object: advanced search (Alt+F) for each field sequentially - * (searches by specific column, efficient on large tables) → dblclick positioned row - * - * @param {number} selFormNum - selection form number - * @param {string} fieldName - field being filled (for error messages) - * @param {string|Object} search - string for simple search, or { field: value } for per-field search - * @param {number} origFormNum - original form number (to verify we returned) - * @returns {{ field, ok, method }} or {{ field, error, message }} + * Scan visible grid rows for a text match (exact → startsWith → includes). + * Returns center coords of the matched row, or null if not found. + * When searchLower is empty, returns coords of the first row (fallback). */ -async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { - // 1. Apply filters based on search type - if (typeof search === 'string') { - // Simple text search - if (search) { - try { - await filterList(search); - } catch (e) { - await page.keyboard.press('Escape'); - await waitForStable(); - return { field: fieldName, error: 'filter_failed', message: e.message }; - } - } - } else if (search && typeof search === 'object') { - // Per-field advanced search (Alt+F) for each entry — searches by specific column, - // more efficient than simple search on large tables (no full-text scan across all columns). - const entries = Object.entries(search); - for (const [fld, val] of entries) { - try { - await filterList(String(val), { field: fld }); - } catch (e) { - // Advanced search failed — fall through and try with what we have - } - } - } - - // 2. Find the target row — currently selected (positioned by advanced search) or first - const rowTarget = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; +async function scanGridRows(formNum, searchLower) { + return page.evaluate(`(() => { + const p = 'form${formNum}_'; const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); if (!grid) return null; const body = grid.querySelector('.gridBody'); if (!body) return null; const lines = [...body.querySelectorAll('.gridLine')]; if (!lines.length) return { rowCount: 0 }; - // Prefer selected/active row (positioned by advanced search), fall back to first - const sel = lines.find(l => l.classList.contains('select') || l.classList.contains('active')) || lines[0]; + const searchLower = ${JSON.stringify(searchLower || '')}; + let sel = null; + if (searchLower) { + const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); + const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) })); + sel = rowData.find(r => r.text === searchLower)?.el + || rowData.find(r => r.text.startsWith(searchLower))?.el + || rowData.find(r => r.text.includes(searchLower))?.el; + } else { + sel = lines[0]; // empty search → first row + } + if (!sel) return null; const r = sel.getBoundingClientRect(); - return { rowCount: lines.length, matched: true, - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); +} - if (!rowTarget?.matched) { - await page.keyboard.press('Escape'); - await waitForStable(); - const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); - return { field: fieldName, error: 'not_found', - message: 'No matches in selection form for ' + searchDesc + - (rowTarget?.rowCount === 0 ? ' (grid empty)' : '') }; - } - - // 3. Dblclick the target row - await page.mouse.dblclick(rowTarget.x, rowTarget.y); +/** + * Select a row in a selection form via click + Enter, verify it closed. + * Uses click + Enter instead of dblclick because dblclick toggles + * expand/collapse in tree-style selection forms. + * Returns { field, ok: true, method: 'form' } on success, + * or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row). + */ +async function dblclickAndVerify(coords, selFormNum, fieldName) { + // Click to highlight the row, then Enter to confirm selection. + // This works for both flat grids and tree forms (dblclick would + // toggle expand/collapse on tree group rows). + await page.mouse.click(coords.x, coords.y); + await page.waitForTimeout(200); + await page.keyboard.press('Enter'); await waitForStable(selFormNum); // Verify selection form closed @@ -813,19 +797,9 @@ async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); })()`); if (stillOpen) { - // Dblclick may have opened a folder — try Enter to select current row - await page.keyboard.press('Enter'); - await waitForStable(selFormNum); - - // Still open? Close and report - const stillOpen2 = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (stillOpen2) { - await page.keyboard.press('Escape'); - await waitForStable(); - } + // Enter didn't select — item is likely a non-selectable group. + // Don't Escape here — let the caller decide (may want to try another row). + return { field: fieldName, ok: false, reason: 'still_open' }; } // Check for 1C error modals after selection @@ -839,6 +813,187 @@ async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) return { field: fieldName, ok: true, method: 'form' }; } +/** + * Inline advanced search on a selection form via Alt+F. + * Does NOT click any column — FieldSelector auto-populates with main representation. + * Switches to "по части строки" (CompareType#1) to avoid composite type issues. + * Does not throw — returns silently on failure. + */ +async function advancedSearchInline(formNum, text) { + try { + // 1. Open advanced search via Alt+F + await page.keyboard.press('Alt+f'); + await page.waitForTimeout(2000); + + const dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog + + // 2. Switch to "по части строки" (CompareType#1) + const radioClicked = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = document.getElementById(p + 'CompareType#1#radio'); + if (!el || el.offsetWidth === 0) return false; + if (el.classList.contains('select')) return true; // already selected + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (radioClicked && typeof radioClicked === 'object') { + await page.mouse.click(radioClicked.x, radioClicked.y); + await page.waitForTimeout(300); + } + + // 3. Fill Pattern field via clipboard paste + const patternId = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + return el ? el.id : null; + })()`); + if (!patternId) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return; + } + await page.click(`[id="${patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(300); + + // 4. Click "Найти" + const findBtn = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); + const btn = btns.find(el => el.innerText?.trim() === 'Найти'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (findBtn) { + await page.mouse.click(findBtn.x, findBtn.y); + await page.waitForTimeout(2000); + } + + // 5. Close advanced search dialog + for (let attempt = 0; attempt < 3; attempt++) { + const dialogVisible = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (!dialogVisible) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await waitForStable(formNum); + } catch { /* silently fail — caller will re-scan and handle not_found */ } +} + +/** + * Pick a value from an opened selection form. + * + * Strategy (escalating): + * 1. Scan visible rows for text match (exact → startsWith → includes) + * 2. Simple search (search input + Enter) → re-scan + * 3. Advanced search (Alt+F, "по части строки") → re-scan + * 4. Not found → Escape → error + * + * For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan. + * For empty search: pick first visible row. + * + * @param {number} selFormNum - selection form number + * @param {string} fieldName - field being filled (for error messages) + * @param {string|Object} search - string for simple search, or { field: value } for per-field search + * @param {number} origFormNum - original form number (to verify we returned) + * @returns {{ field, ok, method }} or {{ field, error, message }} + */ +async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { + const searchText = typeof search === 'string' + ? search : (search ? Object.values(search).join(' ') : ''); + const searchLower = normYo((searchText || '').toLowerCase()); + + // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group). + let hadUnselectableMatch = false; + async function trySelect(row) { + const r = await dblclickAndVerify(row, selFormNum, fieldName); + if (r.ok) return r; + hadUnselectableMatch = true; // found match but couldn't select (group row) + return null; // form still open, try next step + } + + // Step 1: Scan visible rows (no filtering) + if (searchLower) { + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + // Step 2: Simple search via search input (directly on the known form, avoids filterList form-detection) + if (typeof search === 'string' && searchLower) { + const searchInputId = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? el.id : null; + })()`); + if (searchInputId) { + try { + await page.click(`[id="${searchInputId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(searchText))})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(selFormNum); + } catch { /* proceed to advanced search */ } + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + } + + // Step 3: Advanced search + if (typeof search === 'object' && search) { + // Per-field advanced search via filterList(val, {field}) + for (const [fld, val] of Object.entries(search)) { + try { await filterList(String(val), { field: fld }); } catch { /* proceed */ } + } + } else if (searchLower) { + // Inline advanced search (Alt+F, "по части строки") + await advancedSearchInline(selFormNum, searchText); + } + if (searchLower) { + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + // Step 4: Empty search → pick first row; otherwise not found + if (!searchLower) { + const row = await scanGridRows(selFormNum, ''); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + await page.keyboard.press('Escape'); + await waitForStable(); + const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); + if (hadUnselectableMatch) { + return { field: fieldName, error: 'not_selectable', + message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' }; + } + return { field: fieldName, error: 'not_found', + message: 'No matches in selection form for ' + searchDesc }; +} + /** * Detect whether a form is a type selection dialog ("Выбор типа данных"). * Type dialogs appear when selecting a value for a composite-type field. @@ -1286,7 +1441,7 @@ export async function fillFields(fields) { } /** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). */ -export async function clickElement(text, { dblclick } = {}) { +export async function clickElement(text, { dblclick, table } = {}) { ensureConnected(); await dismissPendingErrors(); if (highlightMode) try { await highlight(text); await page.waitForTimeout(500); await unhighlight(); } catch {} @@ -1366,8 +1521,16 @@ export async function clickElement(text, { dblclick } = {}) { let formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error(`clickElement: no form found`); + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + // Find the target element ID - let target = await page.evaluate(findClickTargetScript(formNum, text)); + let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); // Retry: if not found, a modal form may still be loading (e.g. after F4). // Wait up to 2s for a new form to appear and re-detect. @@ -1377,7 +1540,7 @@ export async function clickElement(text, { dblclick } = {}) { const newForm = await page.evaluate(detectFormScript()); if (newForm !== null && newForm !== formNum) { formNum = newForm; - target = await page.evaluate(findClickTargetScript(formNum, text)); + target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); if (!target?.error) break; } } @@ -1913,12 +2076,20 @@ export async function selectValue(fieldName, searchText, { type } = {}) { * @param {boolean} [options.add] - Click "Добавить" to create a new row first * @returns {{ filled[], notFilled[]?, form }} */ -export async function fillTableRow(fields, { tab, add, row } = {}) { +export async function fillTableRow(fields, { tab, add, row, table } = {}) { ensureConnected(); await dismissPendingErrors(); const formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error('fillTableRow: no form found'); + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + try { // 1. Switch tab if requested if (tab) { @@ -1927,7 +2098,7 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { // 2. Add new row if requested if (add) { - await clickElement('Добавить'); + await clickElement('Добавить', { table }); // Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait for (let aw = 0; aw < 6; aw++) { await page.waitForTimeout(150); @@ -1945,8 +2116,9 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { if (row != null) { const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); const cellCoords = await page.evaluate(`(() => { - const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); - const grid = grids[grids.length - 1]; + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; if (!grid) return { error: 'no_grid' }; const head = grid.querySelector('.gridHead'); const body = grid.querySelector('.gridBody'); @@ -1980,39 +2152,303 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { if (!box) return { error: 'no_cell' }; const cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + const currentText = (cell.innerText?.trim() || '').replace(/\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; })()`); if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); - await page.mouse.dblclick(cellCoords.x, cellCoords.y); - // Poll for edit mode instead of fixed 500ms wait + // Skip if cell already contains the desired value (single-field optimization) + const firstKey0 = Object.keys(fields)[0]; + const firstVal0 = typeof fields[firstKey0] === 'object' ? fields[firstKey0].value : String(fields[firstKey0]); + let firstFieldSkipped = false; + if (cellCoords.currentText && firstVal0 && + cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) { + firstFieldSkipped = true; + if (Object.keys(fields).length === 1) { + return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }]; + } + } + + // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse). + // Then escalate: dblclick → F4 if needed. + await page.mouse.click(cellCoords.x, cellCoords.y); let inEdit = false; - for (let dw = 0; dw < 5; dw++) { + let directEditForm = null; + for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); inEdit = await page.evaluate(`(() => { const f = document.activeElement; return f && f.tagName === 'INPUT'; })()`); if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; } - if (!inEdit) throw new Error(`fillTableRow: double-click on row ${row} did not enter edit mode`); - } - - // 3. Verify we're in grid edit mode (active INPUT inside a .grid) - const editCheck = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; - let node = f; - while (node) { - if (node.classList?.contains('grid')) return { inEdit: true }; - node = node.parentElement; + // Click didn't enter edit — try dblclick (works for flat grids) + if (!inEdit && directEditForm === null) { + await page.mouse.dblclick(cellCoords.x, cellCoords.y); + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + } + // Still nothing — try F4 (opens selection for direct-edit cells) + if (!inEdit && directEditForm === null) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } } - return { inEdit: false, hint: 'input not inside grid' }; - })()`); - if (!editCheck.inEdit) { - throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); + // When click entered INPUT mode but no selection form yet — try F4 (tree grid ref fields) + if (inEdit && directEditForm === null) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + // If F4 didn't open a selection form, the cell is a plain text field — fall through to Tab loop + } + + // Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing). + // Handle each field by picking from selection form, then dblclick next cell. + if (directEditForm !== null) { + const pending = new Map(); + for (const [key, val] of Object.entries(fields)) { + if (val && typeof val === 'object' && 'value' in val) { + pending.set(key, { value: String(val.value), type: val.type || null, filled: false }); + } else { + pending.set(key, { value: String(val), type: null, filled: false }); + } + } + const results = []; + + // Helper: handle type dialog + pick from selection form + async function directEditPick(openedForm, key, info) { + let selForm = openedForm; + // Check if opened form is a type selection dialog (composite type field) + if (await isTypeDialog(selForm)) { + if (info.type) { + await pickFromTypeDialog(selForm, info.type); + await waitForStable(selForm); + // After type selection, detect the actual selection form + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; + } + } else { + // No type specified — close type dialog and report error + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; + } + } + const pr = await pickFromSelectionForm(selForm, key, info.value, formNum); + return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message }; + } + + // First field: selection form is already open from the dblclick above + const firstKey = Object.keys(fields)[0]; + const firstInfo = pending.get(firstKey); + if (firstFieldSkipped) { + firstInfo.filled = true; + results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText }); + // Close the selection form that opened from the click + await page.keyboard.press('Escape'); + await waitForStable(formNum); + } else { + const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); + firstInfo.filled = true; + results.push(pickResult); + } + + // Remaining fields: dblclick on each column cell individually + for (const [key, info] of pending) { + if (info.filled) continue; + // Find column for this key and dblclick on it + const nextCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach((box, i) => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + cols.push({ idx: i, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + const kl = ${JSON.stringify(key.toLowerCase())}; + const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); + let colIdx = -1; + const exact = cols.find(c => c.text === kl); + if (exact) colIdx = exact.idx; + else { + const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) + || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); + if (inc) colIdx = inc.idx; + } + if (colIdx < 0) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return null; + const line = rows[${row}]; + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = boxes[colIdx]; + if (!box) return null; + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; + })()`); + if (!nextCoords) { + info.filled = true; + results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); + continue; + } + // Skip if cell already contains the desired value + if (nextCoords.currentText && info.value && + nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) { + info.filled = true; + results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText }); + continue; + } + await page.mouse.dblclick(nextCoords.x, nextCoords.y); + // Poll for selection form (with F4 fallback if dblclick didn't open it) + let selForm = null; + for (let attempt = 0; attempt < 2 && selForm === null; attempt++) { + if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback + for (let sw = 0; sw < 6; sw++) { + await page.waitForTimeout(200); + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm !== null) break; + } + } + if (selForm === null) { + info.filled = true; + results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); + continue; + } + const pr = await directEditPick(selForm, key, info); + info.filled = true; + results.push(pr); + } + // Commit the edit: click on a different row (Escape cancels in tree grids). + // Find the first visible row that is NOT the edited row and click it. + const commitCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + const otherIdx = ${row} === 0 ? 1 : 0; + const other = rows[otherIdx]; + if (!other) return null; + const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; + if (!box) return null; + const r = box.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (commitCoords) { + await page.mouse.click(commitCoords.x, commitCoords.y); + } else { + await page.keyboard.press('Escape'); + } + await waitForStable(formNum); + return results; + } + + if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); + } else { + // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent) + const editCheck = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; + let node = f; + while (node) { + if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; + node = node.parentElement; + } + return { inEdit: false, hint: 'input not inside grid' }; + })()`); + + if (!editCheck.inEdit) { + throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); + } } // 4. Prepare pending fields for fuzzy matching @@ -2079,8 +2515,8 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { matchedKey = key; break; } - // CamelCase cell names have no spaces — try matching without spaces - const klNoSpace = kl.replace(/\s+/g, ''); + // CamelCase cell names have no spaces/dashes — try matching without spaces and dashes + const klNoSpace = kl.replace(/[\s\-]+/g, ''); if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) { matchedKey = key; break; @@ -2522,6 +2958,50 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { // Tab already pressed — we're on next cell } + // Commit the new row: click on a different row or outside the grid. + // Without this, the row stays in "uncommitted add" state and a subsequent + // Escape (e.g. from closeForm) would cancel the entire row. + const commitTarget = await page.evaluate(`(() => { + // Find the active grid + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + // Find the currently active row (contains the focused input) + const activeInput = document.activeElement; + let activeRowIdx = -1; + if (activeInput) { + for (let i = 0; i < rows.length; i++) { + if (rows[i].contains(activeInput)) { activeRowIdx = i; break; } + } + } + // Click a DIFFERENT row to commit + const targetIdx = activeRowIdx === 0 ? 1 : 0; + const target = rows[targetIdx]; + if (target) { + const visBoxes = [...target.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; + if (box) { + const r = box.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + } + // Fallback: click the grid header + const head = grid.querySelector('.gridHead'); + if (head) { + const r = head.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + return null; + })()`); + if (commitTarget) { + await page.mouse.click(commitTarget.x, commitTarget.y); + await page.waitForTimeout(500); + } + // Dismiss any leftover error modals const err = await checkForErrors(); if (err?.modal) { @@ -2553,12 +3033,20 @@ export async function fillTableRow(fields, { tab, add, row } = {}) { * @param {string} [options.tab] - Switch to this form tab before operating * @returns {{ deleted, rowsBefore, rowsAfter, form }} */ -export async function deleteTableRow(row, { tab } = {}) { +export async function deleteTableRow(row, { tab, table } = {}) { ensureConnected(); await dismissPendingErrors(); const formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error('deleteTableRow: no form found'); + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + // 1. Switch tab if requested if (tab) { await clickElement(tab); @@ -2567,17 +3055,21 @@ export async function deleteTableRow(row, { tab } = {}) { // 2. Find the target row and click to select it const cellCoords = await page.evaluate(`(() => { - const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); - const grid = grids[grids.length - 1]; + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; if (!grid) return { error: 'no_grid' }; const body = grid.querySelector('.gridBody'); if (!body) return { error: 'no_grid_body' }; const rows = [...body.querySelectorAll('.gridLine')]; if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; const line = rows[${row}]; - const cells = [...line.querySelectorAll('.gridBoxText')]; - const cell = cells.length > 1 ? cells[1] : cells[0]; - if (!cell) return { error: 'no_cell' }; + // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + // Skip first column (row number / checkbox) — pick second visible box + const box = boxes.length > 1 ? boxes[1] : boxes[0]; + if (!box) return { error: 'no_cell' }; + const cell = box.querySelector('.gridBoxText') || box; const r = cell.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; })()`); @@ -2596,8 +3088,9 @@ export async function deleteTableRow(row, { tab } = {}) { // 4. Count rows after deletion const rowsAfter = await page.evaluate(`(() => { - const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); - const grid = grids[grids.length - 1]; + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; if (!grid) return 0; const body = grid.querySelector('.gridBody'); return body ? body.querySelectorAll('.gridLine').length : 0; @@ -2635,20 +3128,40 @@ export async function filterList(text, { field, exact } = {}) { .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); return el ? el.id : null; })()`); - if (!searchId) throw new Error('filterList: no search input found on this form'); - await page.click(`[id="${searchId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); - await page.keyboard.press('Control+V'); + if (searchId) { + await page.click(`[id="${searchId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + await page.keyboard.press('Control+V'); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'search', text }; + return state; + } + + // No search input — Ctrl+F opens advanced search on such forms. + // Click first grid cell then fall through to advanced search path below. + const firstCell = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return null; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return null; + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return null; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); + await page.mouse.click(firstCell.x, firstCell.y); await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'search', text }; - return state; + field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) } // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- @@ -2724,7 +3237,8 @@ export async function filterList(text, { field, exact } = {}) { } // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown - if (needDlb) { + // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) + if (needDlb && field) { const fsInfo = await page.evaluate(`(() => { const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 27fd6a2c..2c32bee4 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -185,24 +185,35 @@ const READ_FORM_FN = `function readForm(p) { } }); - // Table/grid — pick the first VISIBLE grid (tab switching hides inactive grids) - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0 && g.offsetHeight > 0); - if (grid) { - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - const columns = []; - if (head) { - const headLine = head.querySelector('.gridLine') || head; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const textEl = box.querySelector('.gridBoxText'); - const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; - if (text) columns.push(text); - }); - } - const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; - result.table = { present: true, columns, rowCount }; + // Tables/grids — collect ALL visible grids + const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .filter(g => g.offsetWidth > 0 && g.offsetHeight > 0); + if (allGrids.length > 0) { + const tables = allGrids.map(grid => { + const name = grid.id ? grid.id.replace(p, '') : ''; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + const columns = []; + if (head) { + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (text) columns.push(text); + }); + } + const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; + // Visual label from group title (e.g. "Входящие:" for grid "Входящие") + const titleEl = document.getElementById(p + name + '#title_div') + || document.getElementById(p + 'Группа' + name + '#title_div'); + const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null; + return { name, columns, rowCount, ...(label ? { label } : {}) }; + }); + result.tables = tables; + // Backward compat: table = first grid summary + const first = tables[0]; + result.table = { present: true, columns: first.columns, rowCount: first.rowCount }; } // Active filters (train badges above grid: *СостояниеПросмотра) @@ -356,18 +367,81 @@ export function readFormScript(formNum) { })()`; } +/** + * Resolve a specific grid by semantic name (table parameter). + * Cascade: exact gridName match → gridName contains → column contains. + * Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }. + */ +export function resolveGridScript(formNum, tableName) { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))}; + const norm = s => (s || '').replace(/ё/gi, 'е'); + const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .filter(g => g.offsetWidth > 0 && g.offsetHeight > 0); + if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' }; + const infos = allGrids.map((g, idx) => { + const gridId = g.id || ''; + const gridName = gridId.replace(p, ''); + const head = g.querySelector('.gridHead'); + const columns = []; + if (head) { + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (text) columns.push(text); + }); + } + // Visual label from group title element + const titleEl = document.getElementById(p + gridName + '#title_div') + || document.getElementById(p + 'Группа' + gridName + '#title_div'); + const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/\u00a0/g, ' ') || '') : ''; + return { idx, gridId, gridName, label, columns, el: g }; + }); + // 1. Exact gridName match (case-insensitive) + let found = infos.find(i => norm(i.gridName).toLowerCase() === target); + // 2. Exact label match + if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target); + // 3. gridName contains target + if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target)); + // 4. Label contains target + if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target)); + // 5. Any column contains target + if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target))); + if (found) { + return { + gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null, + gridId: found.gridId, + gridName: found.gridName, + gridIndex: found.idx, + columns: found.columns + }; + } + return { + error: 'not_found', + message: 'Table "' + ${JSON.stringify(tableName)} + '" not found', + available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns })) + }; + })()`; +} + /** * Read table/grid data with pagination. * Parses grid.innerText — \n separates rows, \t separates cells. * First row = column headers. * Returns { name, columns[], rows[{col:val}], total, offset, shown }. */ -export function readTableScript(formNum, { maxRows = 20, offset = 0 } = {}) { +export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) { const p = `form${formNum}_`; return `(() => { const p = ${JSON.stringify(p)}; - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0 && g.offsetHeight > 0); + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`}; if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' }; const name = grid.id ? grid.id.replace(p, '') : ''; @@ -476,8 +550,8 @@ export function getFormStateScript() { */ export function navigateSectionScript(name) { return `(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))}; + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))}; const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')]; let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); @@ -507,12 +581,14 @@ export function openCommandScript(name) { * Supports synonym matching: visible text AND internal name from DOM ID. * Fuzzy order: exact name -> exact label -> includes name -> includes label. */ -export function findClickTargetScript(formNum, text) { +export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) { const p = `form${formNum}_`; return `(() => { const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))}; const p = ${JSON.stringify(p)}; + const tableName = ${JSON.stringify(tableName || '')}; + const gridSelector = ${JSON.stringify(gridSelector || '')}; const items = []; // Buttons (a.press) @@ -561,6 +637,39 @@ export function findClickTargetScript(formNum, text) { items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab' }); }); + // When table is specified, scope button search to grid's parent container + if (gridSelector) { + const gridEl = document.querySelector(gridSelector); + if (gridEl) { + // Find parent container that has id with formPrefix and contains the grid + let container = gridEl.parentElement; + while (container && container !== document.body) { + if (container.id && container.id.startsWith(p)) break; + container = container.parentElement; + } + // Filter items to those inside the container + const containerItems = container && container !== document.body + ? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); }) + : []; + // Try fuzzy match within container first + let cf = containerItems.find(i => i.name.toLowerCase() === target); + if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target); + if (!cf) cf = containerItems.find(i => i.name.toLowerCase().includes(target)); + if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target)); + if (cf) return { id: cf.id, kind: cf.kind, name: cf.name }; + // Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить) + const gridName = gridEl.id ? gridEl.id.replace(p, '') : ''; + if (gridName) { + const prefixItems = items.filter(i => i.label && i.label.includes(gridName)); + let pf = prefixItems.find(i => i.name.toLowerCase() === target); + if (!pf) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target)); + if (!pf) pf = prefixItems.find(i => i.name.toLowerCase().includes(target)); + if (pf) return { id: pf.id, kind: pf.kind, name: pf.name }; + } + } + // Fall through to unscoped search + } + // Fuzzy match: exact name -> exact label -> startsWith name -> startsWith label -> includes name -> includes label let found = items.find(i => i.name.toLowerCase() === target); if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);