mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-11 16:34:57 +03:00
Auto-build: opencode (powershell) from 6d119eb
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
// web-test table/click-cell v1.4 — click a cell in a form grid by (row, column).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Routed from core/click.mjs when the user calls clickElement({row, column}) and
|
||||
// the form has no SpreadsheetDocument (or `table` matches a grid).
|
||||
//
|
||||
// Key behaviors:
|
||||
// - `row` can be a number (index in current DOM window) or `{col: value}` filter.
|
||||
// - `scroll: true | number` enables reveal-loop via PageDown when a filter row
|
||||
// isn't visible. End detected by snapshot stability between PageDowns.
|
||||
// - Horizontal scroll mirrors SpreadsheetDocument: focus a visible cell in the
|
||||
// target row, press ArrowRight/Left until the target column is in viewport.
|
||||
//
|
||||
// 1С virtualization quirks worth knowing:
|
||||
// - DOM holds a window of ~N visible rows. PageDown's first press moves the
|
||||
// cursor inside the window; subsequent presses swap the window contents.
|
||||
// - scrollTop/scrollLeft are always 0; absolute X of cells shifts on horizontal
|
||||
// scroll. So scroll progress must be inferred from cell coordinates / snapshot
|
||||
// diffs, never from scrollTop/Height.
|
||||
// - Frozen columns (.gridBoxFix) stay pinned at the left, overlap with scrolled
|
||||
// cells — DOM scripts handle the partition; engine just consumes their results.
|
||||
|
||||
import { page } from '../core/state.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { modifierClick, returnFormState, isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||
import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs';
|
||||
import {
|
||||
findGridCellScript, findFocusCellScript, snapshotGridScript,
|
||||
} from '../../dom.mjs';
|
||||
|
||||
const REVEAL_DEFAULT_LIMIT = 50;
|
||||
const PD_WAIT_MS = 300;
|
||||
const FOCUS_WAIT_MS = 150;
|
||||
|
||||
/**
|
||||
* Guard: a 'pic:N' filter value is a readTable picture token, not real cell text.
|
||||
* Picture cells render an icon (no text), so they can't select a row — fail fast
|
||||
* with guidance instead of a confusing 'row_not_found'.
|
||||
*/
|
||||
function assertNotPictureFilter(filter) {
|
||||
for (const [k, v] of Object.entries(filter)) {
|
||||
if (typeof v === 'string' && /^pic:\d+$/.test(v.trim())) {
|
||||
throw new Error(`clickElement: "${v}" is a readTable picture value (column "${k}"), not selectable text — it can't be used as a row filter. Filter by a text column (e.g. name/number) instead.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `{ col: value }` row filter to a numeric index into the grid's current
|
||||
* DOM window (`body.querySelectorAll('.gridLine')`). Reused by fillTableRow so it
|
||||
* can target an existing row by cell values, mirroring clickElement.
|
||||
*
|
||||
* The filter matches across ALL columns (AND). `findGridCellScript` requires a
|
||||
* `column`, so we pass the first filter key as a placeholder — it only affects the
|
||||
* returned coordinates (which we ignore), not row selection. The matched row
|
||||
* guarantees that key's cell is in the DOM, so no `cell_not_in_dom` for it.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.formNum
|
||||
* @param {string} [args.gridSelector] - CSS selector for the target grid (same grid the caller edits)
|
||||
* @param {object} args.filter - `{ col: value }` (one or more columns)
|
||||
* @param {string} [args.gridName] - for diagnostics in error messages
|
||||
* @param {boolean|number} [args.scroll] - reveal-loop beyond the DOM window (true = 50 PageDowns, number = limit)
|
||||
* @returns {Promise<number>} resolved row index
|
||||
*/
|
||||
export async function resolveRowIndexByFilter({ formNum, gridSelector, filter, gridName, scroll }) {
|
||||
assertNotPictureFilter(filter);
|
||||
const target = { row: filter, column: Object.keys(filter)[0] };
|
||||
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
if (cell?.error === 'row_not_found' && scroll) {
|
||||
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
|
||||
}
|
||||
if (cell?.error) throw cellError(cell, target, gridName, scroll, 'fillTableRow');
|
||||
return cell.rowIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a cell in a form grid by (row, column). Called from core/click.mjs.
|
||||
*
|
||||
* @param {object} target - { row: number|{col:value}, column: string }
|
||||
* @param {object} ctx
|
||||
* @param {number} ctx.formNum
|
||||
* @param {string} ctx.gridSelector - CSS selector for the target grid
|
||||
* @param {string} [ctx.gridName] - for diagnostics
|
||||
* @param {string} [ctx.modifier] - 'ctrl' | 'shift' for multi-select
|
||||
* @param {boolean} [ctx.dblclick]
|
||||
* @param {boolean|number} [ctx.scroll] - true = up to 50 PageDowns, number = exact limit
|
||||
*/
|
||||
export async function clickGridCell(target, ctx) {
|
||||
const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx;
|
||||
|
||||
if (target?.row && typeof target.row === 'object') assertNotPictureFilter(target.row);
|
||||
|
||||
// 1. Try to find the cell in current DOM window.
|
||||
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
|
||||
// 2. Reveal loop: only for filter-based row search with scroll opt-in.
|
||||
if (cell?.error === 'row_not_found' && scroll && target.row && typeof target.row === 'object') {
|
||||
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
|
||||
}
|
||||
|
||||
if (cell?.error) throw cellError(cell, target, gridName, scroll);
|
||||
|
||||
// 3. Horizontal scroll if cell is off-viewport.
|
||||
if (!cell.visible) {
|
||||
await scrollGridToCell({ formNum, gridSelector, target, cell });
|
||||
cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
if (cell?.error) {
|
||||
throw new Error(`clickElement: cell vanished after horizontal scroll: ${cell.error}`);
|
||||
}
|
||||
if (!cell.visible) {
|
||||
// Scroll loop bailed out before reaching the target. Don't silently click
|
||||
// at off-screen coordinates — that would report a false success.
|
||||
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
|
||||
throw new Error(`clickElement: horizontal scroll could not reach column "${cell.columnText}"${ctxMsg} (cell still at x=${cell.cellX}, viewport ends at ${cell.gridRight}).`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Click.
|
||||
await modifierClick(cell.x, cell.y, modifier, { dbl: !!dblclick });
|
||||
await waitForStable();
|
||||
return returnFormState({
|
||||
clicked: {
|
||||
kind: 'gridCell',
|
||||
row: target.row,
|
||||
column: cell.columnText,
|
||||
...(dblclick ? { dblclick: true } : {}),
|
||||
...(modifier ? { modifier } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cellError(cell, target, gridName, scroll, who = 'clickElement') {
|
||||
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
|
||||
if (cell.error === 'row_not_found') {
|
||||
const hint = scroll
|
||||
? ' (reveal-loop exhausted)'
|
||||
: ' — pass { scroll: true } to scan beyond the current DOM window';
|
||||
return new Error(`${who}: row matching ${JSON.stringify(target.row)} not found${ctxMsg}${hint}.`);
|
||||
}
|
||||
if (cell.error === 'column_not_found' || cell.error === 'filter_column_not_found') {
|
||||
return new Error(`${who}: column "${cell.column}" not found${ctxMsg}. Available: ${(cell.available || []).join(', ')}`);
|
||||
}
|
||||
if (cell.error === 'row_out_of_range') {
|
||||
return new Error(`${who}: row index ${cell.row} out of range${ctxMsg} (loaded: ${cell.loaded}). Note: row index is into current DOM window, not absolute — long lists are virtualized.`);
|
||||
}
|
||||
return new Error(`${who}: cannot resolve cell ${JSON.stringify(target)}${ctxMsg}: ${cell.error}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press PageDown in a loop, scanning DOM each iteration for the target row.
|
||||
* Bail when the row is found, snapshots stop changing (end of list), or limit hit.
|
||||
* page.mouse.click on a safe cell first — PageDown needs keyboard focus on gridBody.
|
||||
*/
|
||||
async function revealAndFindCell({ formNum, gridSelector, target, scroll }) {
|
||||
const limit = typeof scroll === 'number' ? scroll : REVEAL_DEFAULT_LIMIT;
|
||||
|
||||
const focusPt = await page.evaluate(findFocusCellScript(gridSelector));
|
||||
if (!focusPt) return { error: 'no_focusable_cell' };
|
||||
await page.mouse.click(focusPt.x, focusPt.y);
|
||||
await page.waitForTimeout(FOCUS_WAIT_MS);
|
||||
// Click on a Number/Date cell auto-enters edit mode in 1С; PageDown there
|
||||
// is a no-op. Exit edit mode before driving the reveal loop.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
let prevSnap = await page.evaluate(snapshotGridScript(gridSelector));
|
||||
for (let i = 0; i < limit; i++) {
|
||||
await page.keyboard.press('PageDown');
|
||||
await page.waitForTimeout(PD_WAIT_MS);
|
||||
|
||||
const cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
if (!cell?.error) return cell;
|
||||
|
||||
const snap = await page.evaluate(snapshotGridScript(gridSelector));
|
||||
// Reached the end of the list. Primary signal: nothing remains below
|
||||
// (`hasBelow === false`) — the reliable cross-grid-type signal. Content
|
||||
// stability is only a fallback when hasBelow is unknown: it compares the
|
||||
// full-row text (snapshotGridScript joins every cell), so a low-cardinality
|
||||
// first column (e.g. all "Товар 0X") can't look "stable" mid-scroll.
|
||||
const reachedEnd = snap && (
|
||||
snap.hasBelow === false
|
||||
|| (snap.hasBelow == null
|
||||
&& snap.firstText === prevSnap?.firstText
|
||||
&& snap.lastText === prevSnap?.lastText
|
||||
&& snap.selIdx === prevSnap?.selIdx
|
||||
&& snap.lineCount === prevSnap?.lineCount)
|
||||
);
|
||||
if (reachedEnd) return { error: 'row_not_found', filter: target.row };
|
||||
prevSnap = snap;
|
||||
}
|
||||
return { error: 'row_not_found', filter: target.row };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the grid horizontally so the target cell falls inside the viewport.
|
||||
* Focuses an edge cell in the target row (rightmost-visible for ArrowRight,
|
||||
* leftmost-visible for ArrowLeft) so the next arrow key immediately scrolls.
|
||||
*
|
||||
* Frozen columns (gridBoxFix) are excluded from focus candidates — they don't
|
||||
* drive the scrollable viewport. The DOM script handles that detail.
|
||||
*/
|
||||
async function scrollGridToCell({ formNum, gridSelector, target, cell }) {
|
||||
const direction = cell.cellX > cell.gridRight ? 'ArrowRight'
|
||||
: cell.cellRight < cell.gridX ? 'ArrowLeft'
|
||||
: (cell.cellRight > cell.gridRight ? 'ArrowRight' : 'ArrowLeft');
|
||||
|
||||
const focusPt = await page.evaluate(
|
||||
findFocusCellScript(gridSelector, { rowIdx: cell.rowIdx, direction })
|
||||
);
|
||||
if (!focusPt) throw new Error('clickElement: no visible cell to focus for horizontal scroll');
|
||||
await page.mouse.click(focusPt.x, focusPt.y);
|
||||
await page.waitForTimeout(FOCUS_WAIT_MS);
|
||||
// Click on a Number/Date cell auto-enters edit mode in 1С; arrow keys there
|
||||
// navigate text inside the input rather than scrolling the viewport. Exit first.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
await scrollHorizontallyByKey({
|
||||
page,
|
||||
direction,
|
||||
isFullyVisible: async () => {
|
||||
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
return !!c && !c.error && c.visible;
|
||||
},
|
||||
getCenterX: async () => {
|
||||
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||
return c && !c.error ? c.x : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// web-test table/click-row v1.0 — click handlers for grid row targets: gridGroup, gridTreeNode, gridRow.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// All handlers are called by core/click.mjs dispatcher after target is found.
|
||||
// Each takes (target, ctx) where ctx = { formNum, modifier, dblclick, toggle, expand, ... }
|
||||
// and returns a form state with `clicked: { kind, name, ... }`.
|
||||
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { modifierClick, returnFormState } from '../core/helpers.mjs';
|
||||
import { getGridToggleIcon, shouldClickToggle } from './grid-toggle.mjs';
|
||||
|
||||
/**
|
||||
* Click handler for gridGroup / gridParent targets (hierarchy mode).
|
||||
* With `expand`/`toggle` — click the level-indicator icon to expand/collapse the group.
|
||||
* Without — dblclick the row to enter the group / go up to parent.
|
||||
*/
|
||||
export async function clickGridGroupTarget(target, ctx) {
|
||||
const { formNum, modifier, toggle, expand } = ctx;
|
||||
if (expand != null || toggle) {
|
||||
// Expand/collapse group — click the triangle icon (.gridListH/.gridListV).
|
||||
// expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click.
|
||||
const levelIconInfo = await getGridToggleIcon(target, formNum, {
|
||||
iconSelector: '.gridListH, .gridListV',
|
||||
isExpandedExpr: "icon.classList.contains('gridListV')",
|
||||
});
|
||||
const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle);
|
||||
if (shouldClick) {
|
||||
if (levelIconInfo) {
|
||||
await modifierClick(levelIconInfo.x, levelIconInfo.y, modifier);
|
||||
} else {
|
||||
// Fallback: dblclick (standard hierarchy navigation)
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||
}
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({
|
||||
clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
|
||||
hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.',
|
||||
});
|
||||
}
|
||||
// Default: dblclick to enter group / go up to parent
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for gridTreeNode targets (tree-style grid).
|
||||
* With `expand`/`toggle` — click the tree icon to expand/collapse.
|
||||
* Without — single-click to select the row (no expand).
|
||||
*/
|
||||
export async function clickGridTreeNodeTarget(target, ctx) {
|
||||
const { formNum, modifier, toggle, expand } = ctx;
|
||||
if (expand != null || toggle) {
|
||||
// Expand/collapse tree node — click the tree icon [tree="true"].
|
||||
const treeIconInfo = await getGridToggleIcon(target, formNum, {
|
||||
iconSelector: '.gridBoxImg [tree="true"]',
|
||||
isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")',
|
||||
});
|
||||
const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle);
|
||||
if (shouldClick) {
|
||||
if (treeIconInfo) {
|
||||
await modifierClick(treeIconInfo.x, treeIconInfo.y, modifier);
|
||||
} else {
|
||||
// Fallback: dblclick on row (works for trees without clickable +/- icons)
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||
}
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({
|
||||
clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
|
||||
hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.',
|
||||
});
|
||||
}
|
||||
// Default: select row (click text, no expand/collapse)
|
||||
await modifierClick(target.x, target.y, modifier);
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({
|
||||
clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) },
|
||||
hint: 'Row selected. Use { expand: true } to expand/collapse.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for gridRow targets (flat list row).
|
||||
* Single click selects the row; `dblclick: true` opens the item.
|
||||
*/
|
||||
export async function clickGridRowTarget(target, ctx) {
|
||||
const { modifier, dblclick } = ctx;
|
||||
await modifierClick(target.x, target.y, modifier, { dbl: !!dblclick });
|
||||
await waitForStable();
|
||||
return returnFormState({
|
||||
clicked: { kind: 'gridRow', name: target.name, ...(dblclick ? { dblclick: true } : {}), ...(modifier ? { modifier } : {}) },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// web-test table/filter v1.19 — filterList / unfilterList — simple search + advanced-column filter badges.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, readSubmenuScript,
|
||||
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
|
||||
findFirstGridCellCoordsScript, findColumnFirstCellCoordsScript,
|
||||
readFieldSelectorInfoScript, pickFieldInSelectorDropdownScript,
|
||||
readFilterDialogInfoScript, findFilterBadgeCloseScript, findFirstFilterBadgeCloseScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import { safeClick, returnFormState } from '../core/helpers.mjs';
|
||||
import { selectValue, fillReferenceField } from '../forms/select-value.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
import { getFormState } from '../forms/state.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
|
||||
/**
|
||||
* Filter the current list by field value, or search via search bar.
|
||||
*
|
||||
* Without field: simple search via the search bar (filters by all columns, no badge).
|
||||
* With field: advanced search — clicks target column cell to auto-populate FieldSelector,
|
||||
* opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge.
|
||||
* Handles text, reference (with Tab autocomplete), and date fields automatically.
|
||||
* Multiple filters can be chained by calling filterList multiple times.
|
||||
*
|
||||
* @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016")
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата")
|
||||
* @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact)
|
||||
*/
|
||||
export async function filterList(text, { field, exact } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('filterList: no form found');
|
||||
|
||||
if (!field) {
|
||||
// --- Simple search: fill search input + Enter ---
|
||||
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
|
||||
|
||||
if (searchInfo) {
|
||||
await page.click(`[id="${searchInfo.id}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForStable(formNum);
|
||||
|
||||
return returnFormState({ filtered: { type: 'search', text } });
|
||||
}
|
||||
|
||||
// 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(findFirstGridCellCoordsScript(formNum));
|
||||
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);
|
||||
field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected)
|
||||
}
|
||||
|
||||
// --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти ---
|
||||
// Clicking a cell in the target column makes it active, so when Alt+F opens the
|
||||
// advanced search dialog, FieldSelector is auto-populated with the correct field name.
|
||||
// This avoids changing FieldSelector programmatically (which can cause errors).
|
||||
const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim());
|
||||
|
||||
// 1. Click a cell in the target column to activate it (auto-populates FieldSelector).
|
||||
// If the column isn't visible in the grid, click any cell and use DLB fallback later.
|
||||
let needDlb = false;
|
||||
const gridEl = await page.evaluate(findColumnFirstCellCoordsScript(formNum, field));
|
||||
if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`);
|
||||
needDlb = !!gridEl.needDlb;
|
||||
await page.mouse.click(gridEl.x, gridEl.y);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 2. Open advanced search dialog via Alt+F (with fallback to Еще menu)
|
||||
await page.keyboard.press('Alt+f');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
let dialogForm = await page.evaluate(detectFormScript());
|
||||
if (dialogForm === formNum) {
|
||||
// Alt+F didn't open dialog — fallback to Еще → Расширенный поиск
|
||||
await clickElement('Еще');
|
||||
await page.waitForTimeout(500);
|
||||
const menu = await page.evaluate(readSubmenuScript());
|
||||
const searchItem = Array.isArray(menu) && menu.find(i =>
|
||||
i.name.replace(/ /g, ' ').toLowerCase().includes('расширенный поиск'));
|
||||
if (!searchItem) {
|
||||
await page.keyboard.press('Escape');
|
||||
throw new Error('filterList: advanced search dialog could not be opened');
|
||||
}
|
||||
await page.mouse.click(searchItem.x, searchItem.y);
|
||||
await page.waitForTimeout(2000);
|
||||
dialogForm = await page.evaluate(detectFormScript());
|
||||
if (dialogForm === formNum) {
|
||||
throw new Error('filterList: advanced search dialog did not open');
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown
|
||||
// Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field)
|
||||
if (needDlb && field) {
|
||||
const fsInfo = await page.evaluate(readFieldSelectorInfoScript(dialogForm));
|
||||
|
||||
if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) {
|
||||
await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const ddResult = await page.evaluate(pickFieldInSelectorDropdownScript(field));
|
||||
|
||||
if (ddResult.error) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`);
|
||||
}
|
||||
await page.mouse.click(ddResult.x, ddResult.y);
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Read dialog state and fill Pattern
|
||||
// Detect field type by Pattern's sibling buttons:
|
||||
// - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value)
|
||||
// - iDLB on Pattern → reference field (paste + Tab for autocomplete)
|
||||
// - neither → plain text field (just paste)
|
||||
const dialogInfo = await page.evaluate(readFilterDialogInfoScript(dialogForm));
|
||||
|
||||
if (dialogInfo.isDate) {
|
||||
// Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste)
|
||||
if (isDateValue && dialogInfo.patternValue !== text.trim()) {
|
||||
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Home');
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press('Shift+End');
|
||||
await page.waitForTimeout(100);
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
} else {
|
||||
// Text or reference field: fill Pattern via clipboard paste
|
||||
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
if (dialogInfo.isRef) {
|
||||
// Reference field: Tab triggers autocomplete to resolve text → reference value
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Switch CompareType if exact match requested (text fields only).
|
||||
// Date/number: always exact, CompareType disabled. Reference: default exact (selects ref).
|
||||
if (exact && !dialogInfo.isDate && !dialogInfo.isRef) {
|
||||
const exactRadio = await page.evaluate(findCompareTypeRadioScript(dialogForm, 2));
|
||||
if (exactRadio && !exactRadio.already) {
|
||||
await page.mouse.click(exactRadio.x, exactRadio.y);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked)
|
||||
const findBtnCoords = await page.evaluate(findNamedButtonScript('Найти'));
|
||||
if (findBtnCoords) {
|
||||
await page.mouse.click(findBtnCoords.x, findBtnCoords.y);
|
||||
} else {
|
||||
await clickElement('Найти');
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
|
||||
// Check the specific dialog form — not generic modalSurface — to avoid closing parent modals
|
||||
// (e.g. a selection form that opened this advanced search).
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
|
||||
if (!dialogVisible) break;
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
|
||||
return returnFormState({ filtered: { type: 'advanced', field, text, exact: !!exact } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove active filters/search from the current list.
|
||||
*
|
||||
* Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field).
|
||||
* With field: clicks the × button on the specific filter badge (selective removal).
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×)
|
||||
*/
|
||||
export async function unfilterList({ field } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
const formNum = await page.evaluate(detectFormScript());
|
||||
if (formNum === null) throw new Error('unfilterList: no form found');
|
||||
|
||||
if (field) {
|
||||
// --- Selective: click × on specific filter badge ---
|
||||
const closeBtn = await page.evaluate(findFilterBadgeCloseScript(formNum, field));
|
||||
|
||||
if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`);
|
||||
await page.mouse.click(closeBtn.x, closeBtn.y);
|
||||
await waitForStable(formNum);
|
||||
|
||||
return returnFormState({ unfiltered: { field: closeBtn.field } });
|
||||
}
|
||||
|
||||
// --- Clear ALL filters ---
|
||||
|
||||
// 1. Remove all advanced filter badges (.trainItem × buttons)
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const badge = await page.evaluate(findFirstFilterBadgeCloseScript(formNum));
|
||||
if (!badge) break;
|
||||
await page.mouse.click(badge.x, badge.y);
|
||||
await waitForStable(formNum);
|
||||
}
|
||||
|
||||
// 2. Cancel active search via Ctrl+Q
|
||||
await page.keyboard.press('Control+q');
|
||||
await waitForStable(formNum);
|
||||
|
||||
// 3. Clear simple search field if it has a value
|
||||
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
|
||||
|
||||
if (searchInfo?.value) {
|
||||
await page.click(`[id="${searchInfo.id}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForStable(formNum);
|
||||
}
|
||||
|
||||
return returnFormState({ unfiltered: true });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/
|
||||
// collapse toggles. Used by clickElement's gridGroup/gridParent and
|
||||
// gridTreeNode branches; the actual mouse click stays in the caller because
|
||||
// it depends on the caller-local modifier-key handling.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page } from '../core/state.mjs';
|
||||
|
||||
/**
|
||||
* Locate the toggle icon for the grid row at `target.y`. Inspects the row
|
||||
* under that Y-coordinate inside the resolved grid, returns the icon's
|
||||
* center coordinates and current expanded state — or `null` if no toggle
|
||||
* icon is present (e.g. leaf node or detached row).
|
||||
*
|
||||
* @param {{y:number, gridId?:string}} target
|
||||
* @param {number} formNum
|
||||
* @param {object} opts
|
||||
* @param {string} opts.iconSelector — CSS selector inside .gridLine
|
||||
* (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes)
|
||||
* @param {string} opts.isExpandedExpr — JS expression evaluated in browser
|
||||
* context where `icon` is the matched element; must yield a boolean
|
||||
* (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')")
|
||||
* @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>}
|
||||
*/
|
||||
export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) {
|
||||
return await page.evaluate(`(() => {
|
||||
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||
const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
|
||||
const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
|
||||
const body = grid?.querySelector('.gridBody');
|
||||
if (!body) return null;
|
||||
const targetY = ${target.y};
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
for (const line of lines) {
|
||||
const lr = line.getBoundingClientRect();
|
||||
if (targetY < lr.top || targetY > lr.bottom) continue;
|
||||
const icon = line.querySelector(${JSON.stringify(iconSelector)});
|
||||
if (icon) {
|
||||
const r = icon.getBoundingClientRect();
|
||||
const isExpanded = ${isExpandedExpr};
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard expand/toggle decision: should we click the toggle icon?
|
||||
* - `toggle:true` → always click.
|
||||
* - `expand:true` → click only if not already expanded.
|
||||
* - `expand:false` → click only if currently expanded.
|
||||
* - If no icon found (`iconInfo` is null) → click anyway (caller falls back to dblclick).
|
||||
*
|
||||
* @param {{isExpanded:boolean}|null} iconInfo
|
||||
* @param {boolean|undefined} expand
|
||||
* @param {boolean|undefined} toggle
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldClickToggle(iconInfo, expand, toggle) {
|
||||
return toggle || !iconInfo
|
||||
|| (expand === true && !iconInfo.isExpanded)
|
||||
|| (expand === false && iconInfo.isExpanded);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// web-test table/grid v1.20 — Form-grid operations: read table rows, fill rows, delete rows.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM):
|
||||
// табличные части документов, формы списков, ТЧ настроек и т.п.
|
||||
// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs).
|
||||
|
||||
import { page, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs';
|
||||
import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs';
|
||||
import { isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||
import { dismissPendingErrors } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
|
||||
/** Read structured table data with pagination. Returns columns, rows, total count. */
|
||||
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');
|
||||
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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a row from the current table part.
|
||||
* Single click to select the row, then Delete key to remove it.
|
||||
*
|
||||
* @param {number} row - 0-based row index to delete
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||
* @returns {object} form state with { deleted, rowsBefore, rowsAfter }
|
||||
*/
|
||||
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);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// 2. Find the target row and click to select it
|
||||
const cellCoords = await page.evaluate(findDeleteRowCoordsScript(gridSelector, row));
|
||||
|
||||
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||
|
||||
const rowsBefore = cellCoords.total;
|
||||
|
||||
// Pre-click Escape: leftover edit-mode from a prior fillTableRow Tab-navigation.
|
||||
// Without it the next mouse click may not select the row reliably (the active
|
||||
// edit input intercepts the event timing).
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// Single click to select the row
|
||||
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Post-click Escape: clicking a Number/Date cell auto-enters edit mode in 1С.
|
||||
// Delete in edit mode clears the cell buffer instead of deleting the row, so
|
||||
// we exit edit first. The row remains selected after Escape — Delete acts on it.
|
||||
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// 3. Press Delete to remove the row
|
||||
await page.keyboard.press('Delete');
|
||||
await waitForStable();
|
||||
|
||||
// 4. Count rows after deletion
|
||||
const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector));
|
||||
|
||||
return returnFormState({ deleted: row, rowsBefore, rowsAfter });
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
// web-test table/row-fill v1.23 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import {
|
||||
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
|
||||
} from '../core/state.mjs';
|
||||
import {
|
||||
detectFormScript, resolveGridScript, readTableScript,
|
||||
countGridRowsScript, isTreeGridScript, findGridHeadCenterCoordsScript,
|
||||
getSelectedOrLastRowIndexScript,
|
||||
isNotInListCloudVisibleScript, clickShowAllInNotInListCloudScript,
|
||||
sortFieldKeysByColindexScript, findCellCoordsByFieldsScript,
|
||||
findNextCellCoordsByKeyScript, findCheckboxAtPointScript,
|
||||
findRowCommitClickCoordsScript, getGridEditCheckScript,
|
||||
readActiveGridCellScript, getElementCenterCoordsByIdScript,
|
||||
} from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||
import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs';
|
||||
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||
import {
|
||||
safeClick, findFieldInputId, returnFormState,
|
||||
detectNewForm as helperDetectNewForm,
|
||||
isInputFocused, isInputFocusedInGrid, findOpenPopup,
|
||||
readEdd, isEddVisible, clickEddItemViaDispatch,
|
||||
} from '../core/helpers.mjs';
|
||||
import { clickElement } from '../core/click.mjs';
|
||||
import { resolveRowIndexByFilter } from './click-cell.mjs';
|
||||
import {
|
||||
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
|
||||
fillReferenceField, selectValue,
|
||||
} from '../forms/select-value.mjs';
|
||||
import { pasteText } from '../core/clipboard.mjs';
|
||||
|
||||
/**
|
||||
* Fill a choice cell (_CB iCB, buttonKind==='choice') whose INPUT is already focused.
|
||||
*
|
||||
* Two kinds of cell carry the same choice button and are INDISTINGUISHABLE in the DOM
|
||||
* (both `editInput`, readOnly:false):
|
||||
* (a) editable value cell (Произвольный/примитив, РедактированиеТекста=Истина) — typed text sticks;
|
||||
* (b) pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь) — typed text is rejected.
|
||||
* The only reliable discriminator is behavioral: paste and watch the input value.
|
||||
* stuck → editable cell → leave value in the INPUT (caller's Tab/commit persists it), method 'direct';
|
||||
* rejected → F4 → form: isTypeDialog ? pickFromTypeDialog ('choice') : pickFromSelectionForm ('form').
|
||||
*
|
||||
* Does NOT navigate between cells — caller owns Tab/dblclick/row-commit.
|
||||
*
|
||||
* @param {number} formNum base form number (for new-form detection)
|
||||
* @param {string} text value to fill
|
||||
* @param {Object} [opts]
|
||||
* @param {string|null} [opts.type] explicit type for composite/value-list pick
|
||||
* @param {string} [opts.fieldLabel] field name for diagnostics / selection-form search
|
||||
* @returns {{ ok, method, error?, message?, value? }}
|
||||
*/
|
||||
async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } = {}) {
|
||||
const norm = (s) => normYo((s || '').toLowerCase());
|
||||
const before = await page.evaluate(`document.activeElement?.value || ''`);
|
||||
// Re-fill guard: cell already holds the target (paste wouldn't change it → false "rejected").
|
||||
if (before && norm(before).includes(norm(text))) {
|
||||
return { ok: true, method: 'skip', value: before };
|
||||
}
|
||||
// Paste, then poll. Three outcomes, distinguished BEHAVIORALLY (not by value equality):
|
||||
// (1) EDD autocomplete appears → reference/list cell → pick from the dropdown;
|
||||
// (2) input changes to non-empty, no EDD → editable cell → leave value, method 'direct';
|
||||
// (3) input unchanged (rejected) → НачалоВыбора pick-from-list → F4 selection form.
|
||||
// A value-equality check on `after` is UNRELIABLE: numeric/date masks reformat the pasted
|
||||
// text (grouping nbsp, decimal comma, padding) — e.g. "1234.56" → "1 234,56", "0,000"
|
||||
// baseline. So we test "did the input change to non-empty" + "no autocomplete", never
|
||||
// "does after contain text" (that false-negatives on reformatting → F4 → stray calculator).
|
||||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||
let after = before, changed = false, eddSeen = false;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await page.waitForTimeout(100);
|
||||
if (await isEddVisible()) { eddSeen = true; break; }
|
||||
after = await page.evaluate(`document.activeElement?.value || ''`);
|
||||
if (after !== before && after !== '') changed = true;
|
||||
}
|
||||
|
||||
if (eddSeen) {
|
||||
// Reference/list cell — pick a MATCHING item from the autocomplete. Only accept an
|
||||
// exact (parenthetical-stripped) or substring match; never blind-pick items[0] — for a
|
||||
// non-existent value 1C still lists unrelated entries, and picking the first silently
|
||||
// writes the wrong reference. No match → fall through to the F4 selection form, which
|
||||
// searches the full list and returns not_found if the value is truly absent.
|
||||
const edd = await readEdd();
|
||||
const items = (edd.items || []).map(i => i.name)
|
||||
.filter(i => !/^Создать[\s:]/.test(i) && !/не найдено/i.test(i) && !/показать все/i.test(i));
|
||||
const tgt = norm(text);
|
||||
const pick = items.find(i => norm(i.replace(/\s*\([^)]*\)\s*$/, '')) === tgt)
|
||||
|| items.find(i => norm(i).includes(tgt));
|
||||
if (pick) {
|
||||
await clickEddItemViaDispatch(pick);
|
||||
await waitForStable();
|
||||
return { ok: true, method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') };
|
||||
}
|
||||
// No matching item — dismiss the autocomplete and fall through to the F4 selection form.
|
||||
await page.keyboard.press('Escape'); await page.waitForTimeout(200);
|
||||
} else if (changed) {
|
||||
// Editable cell — value lives in the INPUT; caller's Tab / end-of-row commit persists it.
|
||||
return { ok: true, method: 'direct', value: after };
|
||||
}
|
||||
|
||||
// Text rejected (pick-from-list cell) — nothing typed to clear (field is not text-editable).
|
||||
// Dismiss any autocomplete hint, then open the choice form via F4.
|
||||
if (await isEddVisible()) { await page.keyboard.press('Escape'); await page.waitForTimeout(200); }
|
||||
await page.keyboard.press('F4');
|
||||
let choiceForm = null;
|
||||
for (let cw = 0; cw < 8; cw++) {
|
||||
await page.waitForTimeout(200);
|
||||
choiceForm = await helperDetectNewForm(formNum);
|
||||
if (choiceForm !== null) break;
|
||||
}
|
||||
if (choiceForm === null) {
|
||||
// F4 safety net: on an editable numeric/date cell mis-routed here, F4 opens a
|
||||
// calculator/calendar (NOT a selection form). Close it — never leave the popup open
|
||||
// (it blocks the UI) — and salvage: if the cell now holds a value, count it as 'direct'.
|
||||
if (await findOpenPopup()) {
|
||||
await page.keyboard.press('Escape');
|
||||
for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); if (!(await findOpenPopup())) break; }
|
||||
const nowVal = await page.evaluate(`document.activeElement?.value || ''`);
|
||||
if (nowVal && nowVal !== before) return { ok: true, method: 'direct', value: nowVal };
|
||||
}
|
||||
return { ok: false, error: 'no_selection_form', message: `Cell "${fieldLabel || text}": F4 did not open a choice form` };
|
||||
}
|
||||
if (await isTypeDialog(choiceForm)) {
|
||||
try {
|
||||
await pickFromTypeDialog(choiceForm, type || text);
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'not_found', message: e.message };
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
// A value form opened after the type pick → composite-value cell needs { value, type }.
|
||||
const valForm = await helperDetectNewForm(formNum);
|
||||
if (valForm !== null) {
|
||||
await page.keyboard.press('Escape'); await page.waitForTimeout(300);
|
||||
return { ok: false, error: 'type_required', message: `Cell "${fieldLabel || text}" expects { value, type }` };
|
||||
}
|
||||
return { ok: true, method: 'choice', value: text };
|
||||
}
|
||||
const pr = await pickFromSelectionForm(choiceForm, fieldLabel || text, text, formNum);
|
||||
return pr.ok ? { ok: true, method: 'form' } : { ok: false, error: pr.error, message: pr.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill cells in the current table row via Tab navigation.
|
||||
* Grid cells are only accessible sequentially (Tab) — no random access.
|
||||
*
|
||||
* After "Добавить", 1C enters inline edit mode on the first cell.
|
||||
* All inputs in the row are created hidden (offsetWidth=0); only the active one is visible.
|
||||
* Tab moves through cells in a fixed order determined by the form configuration.
|
||||
*
|
||||
* @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура")
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
|
||||
* @param {number|Object} [options.row] - Edit existing row: 0-based DOM-window index, or
|
||||
* a `{ col: value }` filter (one or more columns, AND-matched) to locate the row by cell values
|
||||
* @param {boolean|number} [options.scroll] - When `row` is a filter, scan beyond the current
|
||||
* DOM window via PageDown (true = up to 50 presses, number = exact limit)
|
||||
* @returns {{ filled[], notFilled[]?, form }}
|
||||
*/
|
||||
export async function fillTableRow(fields, { tab, add, row, table, scroll } = {}) {
|
||||
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) {
|
||||
await clickElement(tab);
|
||||
}
|
||||
|
||||
// 1b. Resolve a { col: value } row filter to a numeric DOM-window index (mirrors
|
||||
// clickElement). After this, `row` is a number and all downstream code/recursion
|
||||
// works unchanged. Filter targets an EXISTING row — incompatible with `add`.
|
||||
if (row != null && typeof row === 'object') {
|
||||
row = await resolveRowIndexByFilter({ formNum, gridSelector, filter: row, gridName: table, scroll });
|
||||
}
|
||||
|
||||
// 2. Add new row if requested
|
||||
let addedRowIdx = -1;
|
||||
if (add) {
|
||||
// Count rows before add — new row will be appended at this index
|
||||
addedRowIdx = await page.evaluate(countGridRowsScript(gridSelector));
|
||||
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);
|
||||
if (await isInputFocusedInGrid()) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. Enter edit mode on existing row by dblclick
|
||||
if (row != null) {
|
||||
// Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right
|
||||
const sortedKeys = await page.evaluate(
|
||||
sortFieldKeysByColindexScript(gridSelector, Object.keys(fields).map(k => k.toLowerCase())));
|
||||
if (sortedKeys) {
|
||||
// Rebuild fields in sorted order
|
||||
const sortedFields = {};
|
||||
for (const kl of sortedKeys) {
|
||||
const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl);
|
||||
if (origKey) sortedFields[origKey] = fields[origKey];
|
||||
}
|
||||
// Add any keys not matched in header (preserve original order for those)
|
||||
for (const k of Object.keys(fields)) {
|
||||
if (!(k in sortedFields)) sortedFields[k] = fields[k];
|
||||
}
|
||||
fields = sortedFields;
|
||||
}
|
||||
|
||||
const cellCoords = await page.evaluate(
|
||||
findCellCoordsByFieldsScript(gridSelector, row, Object.keys(fields).map(k => k.toLowerCase())));
|
||||
|
||||
if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||
|
||||
// Skip if cell already contains the desired value (single-field optimization)
|
||||
const firstKey0 = Object.keys(fields)[0];
|
||||
const rawFirstVal = fields[firstKey0];
|
||||
const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === ''
|
||||
? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal));
|
||||
let firstFieldSkipped = false;
|
||||
if (cellCoords.currentText && firstVal0 &&
|
||||
cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) {
|
||||
firstFieldSkipped = true;
|
||||
if (Object.keys(fields).length === 1) {
|
||||
return returnFormState({ filled: [{ 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);
|
||||
|
||||
// Clear cell via Shift+F4 if value is empty
|
||||
if (firstVal0 === '') {
|
||||
await page.waitForTimeout(500);
|
||||
// Check if click opened a selection form — close it first
|
||||
let openedForm = await helperDetectNewForm(formNum);
|
||||
if (openedForm !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
} else {
|
||||
// No form opened — need to enter edit mode first (dblclick), then close any form that opens
|
||||
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
|
||||
await page.waitForTimeout(500);
|
||||
openedForm = await helperDetectNewForm(formNum);
|
||||
if (openedForm !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }];
|
||||
// If more fields remain, process them on the same row
|
||||
const remaining = { ...fields };
|
||||
delete remaining[firstKey0];
|
||||
if (Object.keys(remaining).length > 0) {
|
||||
const more = await fillTableRow(remaining, { row, table });
|
||||
results.push(...more.filled);
|
||||
}
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
// Check if clicked cell is a checkbox (toggle-on-click, no edit mode)
|
||||
const checkboxInfo = await page.evaluate(findCheckboxAtPointScript(cellCoords.x, cellCoords.y));
|
||||
if (checkboxInfo !== null) {
|
||||
// Checkbox cell found — click directly on the checkbox icon (not cell center)
|
||||
const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim());
|
||||
if (checkboxInfo.checked !== desired) {
|
||||
await page.mouse.click(checkboxInfo.x, checkboxInfo.y);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }];
|
||||
await waitForStable(formNum);
|
||||
// If more fields remain, process them on the same row
|
||||
const remaining = { ...fields };
|
||||
delete remaining[firstKey0];
|
||||
if (Object.keys(remaining).length > 0) {
|
||||
const more = await fillTableRow(remaining, { row, table });
|
||||
results.push(...more.filled);
|
||||
}
|
||||
return returnFormState({ filled: results });
|
||||
}
|
||||
|
||||
let inEdit = false;
|
||||
let directEditForm = null;
|
||||
for (let dw = 0; dw < 4; dw++) {
|
||||
await page.waitForTimeout(150);
|
||||
inEdit = await isInputFocused();
|
||||
if (inEdit) break;
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
if (directEditForm !== null) break;
|
||||
}
|
||||
// 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 isInputFocused();
|
||||
if (inEdit) break;
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
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 isInputFocused();
|
||||
if (inEdit) break;
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
if (directEditForm !== null) break;
|
||||
}
|
||||
}
|
||||
|
||||
// When click entered INPUT mode but no selection form yet — try F4 only for tree grids
|
||||
// (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop)
|
||||
if (inEdit && directEditForm === null) {
|
||||
const isTreeGrid = await page.evaluate(isTreeGridScript(gridSelector));
|
||||
if (isTreeGrid) {
|
||||
await page.keyboard.press('F4');
|
||||
for (let fw = 0; fw < 8; fw++) {
|
||||
await page.waitForTimeout(200);
|
||||
directEditForm = await helperDetectNewForm(formNum);
|
||||
if (directEditForm !== null) break;
|
||||
}
|
||||
// If F4 didn't open a selection form, 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 helperDetectNewForm(formNum);
|
||||
if (selForm === null) {
|
||||
return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
|
||||
}
|
||||
} else {
|
||||
// No type given — treat as a choice cell: the value IS the list item
|
||||
// ("Выбрать тип"). Pick it; if a value form follows, it was genuinely a
|
||||
// composite-value cell that needs {value, type}.
|
||||
try {
|
||||
await pickFromTypeDialog(selForm, info.value);
|
||||
} catch (e) {
|
||||
return { field: key, ok: false, error: 'not_found', message: e.message };
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
const after = await helperDetectNewForm(formNum);
|
||||
if (after !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
return { field: key, ok: false, error: 'type_required', message: `Cell "${key}" expects { value, type }` };
|
||||
}
|
||||
return { field: key, ok: true, method: 'choice' };
|
||||
}
|
||||
}
|
||||
const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
|
||||
return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, ok: false, 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(findNextCellCoordsByKeyScript(gridSelector, row, key));
|
||||
if (!nextCoords) {
|
||||
info.filled = true;
|
||||
results.push({ field: key, ok: false, 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);
|
||||
await page.waitForTimeout(300);
|
||||
// Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator
|
||||
const inInputAfterDblclick = await isInputFocusedInGrid();
|
||||
// Also check if a selection form already appeared
|
||||
let selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm === null && inInputAfterDblclick) {
|
||||
// Choice cell (bare _CB iCB) — editable value (text sticks) or pick-from-list
|
||||
// (text rejected → F4 form). fillChoiceCell discriminates; row commit persists 'direct'.
|
||||
const activeCell = await page.evaluate(readActiveGridCellScript());
|
||||
if (activeCell.buttonKind === 'choice') {
|
||||
const r = await fillChoiceCell(formNum, info.value, { type: info.type, fieldLabel: key });
|
||||
info.filled = true;
|
||||
results.push(r.ok
|
||||
? { field: key, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
|
||||
: { field: key, ok: false, error: r.error, message: r.message });
|
||||
continue;
|
||||
}
|
||||
// Plain text/numeric field — fill via clipboard paste
|
||||
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
|
||||
await page.waitForTimeout(400);
|
||||
// Dismiss EDD autocomplete if it appeared
|
||||
if (await isEddVisible()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
info.filled = true;
|
||||
results.push({ field: key, ok: true, method: 'paste' });
|
||||
continue;
|
||||
}
|
||||
// Poll for selection form (with F4 fallback if dblclick didn't open it)
|
||||
if (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 helperDetectNewForm(formNum);
|
||||
if (selForm !== null) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selForm === null) {
|
||||
info.filled = true;
|
||||
results.push({ field: key, ok: false, 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(findRowCommitClickCoordsScript(gridSelector, row));
|
||||
if (commitCoords) {
|
||||
await page.mouse.click(commitCoords.x, commitCoords.y);
|
||||
} else {
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
return returnFormState({ filled: 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(getGridEditCheckScript());
|
||||
|
||||
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
|
||||
const pending = new Map();
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val === null || val === undefined || val === '') {
|
||||
pending.set(key, { value: '', type: null, filled: false });
|
||||
} else if (val && typeof val === 'object' && 'value' in val) {
|
||||
const innerVal = val.value;
|
||||
pending.set(key, {
|
||||
value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal),
|
||||
type: val.type || null, filled: false
|
||||
});
|
||||
} else {
|
||||
pending.set(key, { value: String(val), type: null, filled: false });
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const MAX_ITER = 40;
|
||||
let prevCellId = null;
|
||||
let nonInputCount = 0;
|
||||
let firstCellId = null;
|
||||
|
||||
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||||
// Read focused element (INPUT or TEXTAREA inside grid = editable cell)
|
||||
const cell = await page.evaluate(readActiveGridCellScript());
|
||||
|
||||
if (cell.tag !== 'INPUT' || !cell.fullName) {
|
||||
// Not in an editable grid cell — Tab past (ERP has DIV focus between cells)
|
||||
nonInputCount++;
|
||||
// If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows
|
||||
const onlyCheckboxLeft = [...pending.values()].every(p => p.filled ||
|
||||
['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim()));
|
||||
if (nonInputCount > 3 || onlyCheckboxLeft) break;
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
continue;
|
||||
}
|
||||
nonInputCount = 0;
|
||||
|
||||
// Track first cell to detect wrap-around (Tab looped back to row start)
|
||||
if (firstCellId === null) firstCellId = cell.id;
|
||||
else if (cell.id === firstCellId) break; // wrapped around — all cells visited
|
||||
|
||||
// Stuck detection: same cell twice in a row → force Tab
|
||||
if (cell.id === prevCellId) {
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
prevCellId = null;
|
||||
continue;
|
||||
}
|
||||
prevCellId = cell.id;
|
||||
|
||||
// Fuzzy match cell name to user field: exact → suffix → includes → no-space includes
|
||||
const cellLower = cell.fullName.toLowerCase();
|
||||
let matchedKey = null;
|
||||
for (const [key, info] of pending) {
|
||||
if (info.filled) continue;
|
||||
const kl = key.toLowerCase();
|
||||
if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) {
|
||||
matchedKey = key;
|
||||
break;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: match by column header text (handles metadata typos in cell id)
|
||||
if (!matchedKey && cell.headerText) {
|
||||
const htLower = cell.headerText.toLowerCase();
|
||||
for (const [key, info] of pending) {
|
||||
if (info.filled) continue;
|
||||
const kl = key.toLowerCase();
|
||||
if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) {
|
||||
matchedKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedKey) {
|
||||
// Skip this cell
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = pending.get(matchedKey);
|
||||
const text = info.value;
|
||||
|
||||
// Clear cell if value is empty (Shift+F4 = native 1C clear)
|
||||
if (text === '') {
|
||||
await page.keyboard.press('Shift+F4');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' });
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If user specified a type, always clear and use type selection flow
|
||||
if (info.type) {
|
||||
await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('F4');
|
||||
// Poll for type dialog form to appear
|
||||
let typeForm = null;
|
||||
for (let tw = 0; tw < 6; tw++) {
|
||||
await page.waitForTimeout(200);
|
||||
typeForm = await helperDetectNewForm(formNum);
|
||||
if (typeForm !== null) break;
|
||||
}
|
||||
if (typeForm !== null && await isTypeDialog(typeForm)) {
|
||||
await pickFromTypeDialog(typeForm, info.type);
|
||||
await waitForStable(typeForm);
|
||||
// After type selection, check if a selection form opened (ref types)
|
||||
const selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm === null) {
|
||||
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||||
let hasPopup = null;
|
||||
for (let pw = 0; pw < 5; pw++) {
|
||||
await page.waitForTimeout(200);
|
||||
hasPopup = await findOpenPopup();
|
||||
if (hasPopup) break;
|
||||
}
|
||||
if (hasPopup) {
|
||||
await page.keyboard.press('Escape');
|
||||
// Poll for popup to disappear
|
||||
for (let dw = 0; dw < 4; dw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (!(await findOpenPopup())) break;
|
||||
}
|
||||
}
|
||||
// Ensure we are in an editable INPUT for this cell
|
||||
const inInput = await isInputFocused({ allowTextarea: true });
|
||||
if (!inInput) {
|
||||
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
|
||||
if (cellRect) {
|
||||
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||||
// Poll for INPUT focus
|
||||
for (let fw = 0; fw < 4; fw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (await isInputFocused({ allowTextarea: true })) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||
await page.waitForTimeout(400);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||||
continue;
|
||||
}
|
||||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
results.push(pickResult.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
continue;
|
||||
}
|
||||
// F4 opened something but not a type dialog — close and report
|
||||
if (typeForm !== null) {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'type_dialog_failed',
|
||||
message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` });
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Choice cell (_CB iCB): either an editable value cell (text sticks → direct input) or a
|
||||
// pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь → text rejected → F4 form).
|
||||
// fillChoiceCell discriminates behaviorally; both kinds are indistinguishable in the DOM.
|
||||
if (cell.buttonKind === 'choice') {
|
||||
const r = await fillChoiceCell(formNum, text, { type: info.type, fieldLabel: matchedKey });
|
||||
info.filled = true;
|
||||
results.push(r.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false, error: r.error, message: r.message });
|
||||
// 'direct' leaves text in the INPUT — caller's Tab (or end-of-row commit on the last field) persists it.
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Fill this cell: clipboard paste (trusted event) ===
|
||||
await page.keyboard.press('Control+A');
|
||||
await pasteText(text);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Check if paste was rejected (composite-type cell blocks text input until type is selected)
|
||||
const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`);
|
||||
if (!inputAfterPaste && text) {
|
||||
// No type specified — can't fill this composite-type cell
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'type_required',
|
||||
message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` });
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for EDD autocomplete (indicates reference field)
|
||||
const edd = await readEdd();
|
||||
const eddItems = edd.visible ? edd.items.map(i => i.name) : null;
|
||||
|
||||
if (eddItems && eddItems.length > 0) {
|
||||
// Reference field with autocomplete — click best match
|
||||
// Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...)
|
||||
// but keep standalone enum values like "Создать" (no space/colon after)
|
||||
const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i));
|
||||
|
||||
if (realItems.length > 0) {
|
||||
const tgt = normYo(text.toLowerCase());
|
||||
let pick = realItems.find(i =>
|
||||
normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt);
|
||||
if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt));
|
||||
|
||||
if (pick) {
|
||||
// Click EDD item via dispatchEvent (bypasses div.surface overlay)
|
||||
await clickEddItemViaDispatch(pick);
|
||||
await waitForStable();
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true,
|
||||
method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') });
|
||||
} else {
|
||||
// EDD listed items but NONE matches the requested value. Do NOT blind-pick the
|
||||
// first item — when the typed text has no hit, 1C still shows unrelated entries
|
||||
// (recent/full list), so items[0] would silently write the wrong reference.
|
||||
// Dismiss, clear the typed text, report not_found.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(200);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'not_found', message: `No match for "${text}" in autocomplete` });
|
||||
}
|
||||
} else {
|
||||
// Only "Создать:" items — value not found in autocomplete
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'not_found', message: `No match for "${text}"` });
|
||||
}
|
||||
|
||||
// Done? If so, don't Tab (avoids creating a new row after last cell)
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
// Tab to move to next cell
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No EDD — press Tab to commit the value
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check for "нет в списке" cloud popup (reference field, value not found)
|
||||
const notInList = await page.evaluate(isNotInListCloudVisibleScript());
|
||||
|
||||
if (notInList) {
|
||||
// Cloud has "Показать все" link — try to open selection form via it
|
||||
const clickedShowAll = await page.evaluate(clickShowAllInNotInListCloudScript());
|
||||
|
||||
if (clickedShowAll) {
|
||||
await waitForStable(formNum);
|
||||
// Check if selection form opened
|
||||
const selForm = await helperDetectNewForm(formNum, { strict: true });
|
||||
|
||||
if (selForm !== null) {
|
||||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
if (pickResult.ok) {
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' });
|
||||
continue;
|
||||
}
|
||||
// Not found in selection form — fall through to clear + skip
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
} else {
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'not_found', message: `Value "${text}" not in list` });
|
||||
}
|
||||
} else {
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'not_found', message: `Value "${text}" not in list` });
|
||||
}
|
||||
|
||||
// 1C won't let us Tab away from an invalid ref value.
|
||||
// Must clear the field first, then Tab to move on.
|
||||
// Escape dismisses the cloud; Ctrl+A + Delete clears the text.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs)
|
||||
const newForm = await helperDetectNewForm(formNum);
|
||||
|
||||
if (newForm !== null) {
|
||||
if (await isTypeDialog(newForm)) {
|
||||
// Composite-type cell — need type to proceed
|
||||
if (info.type) {
|
||||
await pickFromTypeDialog(newForm, info.type);
|
||||
await waitForStable(newForm);
|
||||
// After type selection, the actual selection form should open
|
||||
const selForm = await helperDetectNewForm(formNum);
|
||||
if (selForm === null) {
|
||||
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||||
let hasPopup = null;
|
||||
for (let pw = 0; pw < 5; pw++) {
|
||||
await page.waitForTimeout(200);
|
||||
hasPopup = await findOpenPopup();
|
||||
if (hasPopup) break;
|
||||
}
|
||||
if (hasPopup) {
|
||||
await page.keyboard.press('Escape');
|
||||
for (let dw = 0; dw < 4; dw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (!(await findOpenPopup())) break;
|
||||
}
|
||||
}
|
||||
const inInput = await isInputFocused({ allowTextarea: true });
|
||||
if (!inInput) {
|
||||
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
|
||||
if (cellRect) {
|
||||
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||||
for (let fw = 0; fw < 4; fw++) {
|
||||
await page.waitForTimeout(150);
|
||||
if (await isInputFocused({ allowTextarea: true })) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||
await page.waitForTimeout(400);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||||
continue;
|
||||
}
|
||||
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
results.push(pickResult.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
continue;
|
||||
} else {
|
||||
// No type specified — close dialog, clear cell, report error
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: 'type_required',
|
||||
message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Not a type dialog — normal selection form
|
||||
const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum);
|
||||
info.filled = true;
|
||||
results.push(pickResult.ok
|
||||
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
|
||||
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||
error: pickResult.error, message: pickResult.message });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain field — value committed via Tab
|
||||
info.filled = true;
|
||||
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' });
|
||||
|
||||
// All done?
|
||||
if ([...pending.values()].every(p => p.filled)) break;
|
||||
// Tab already pressed — we're on next cell
|
||||
}
|
||||
|
||||
// Commit the new row: click on the grid header to exit edit mode.
|
||||
// Clicking a different data row would re-enter edit mode on that row.
|
||||
// Without this commit click, 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(findGridHeadCenterCoordsScript(gridSelector));
|
||||
if (commitTarget) {
|
||||
await page.mouse.click(commitTarget.x, commitTarget.y);
|
||||
await page.waitForTimeout(500);
|
||||
} else {
|
||||
// Fallback: Tab out of the last cell to commit the row
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Dismiss any leftover error modals
|
||||
const err = await checkForErrors();
|
||||
if (err?.modal) {
|
||||
try {
|
||||
const btn = await page.$('a.press.pressDefault');
|
||||
if (btn) { await btn.click(); await page.waitForTimeout(500); }
|
||||
} catch { /* OK */ }
|
||||
}
|
||||
|
||||
const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key);
|
||||
|
||||
// Retry unfilled checkbox fields via direct click (Tab skips checkbox cells)
|
||||
if (notFilled.length > 0) {
|
||||
const checkboxFields = {};
|
||||
for (const key of notFilled) {
|
||||
const val = String(pending.get(key).value).toLowerCase().trim();
|
||||
if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) {
|
||||
checkboxFields[key] = pending.get(key).value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(checkboxFields).length > 0) {
|
||||
// Use row index: addedRowIdx (from add mode) or fallback to selected row
|
||||
const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(getSelectedOrLastRowIndexScript(gridSelector))
|
||||
);
|
||||
if (currentRow >= 0) {
|
||||
const more = await fillTableRow(checkboxFields, { row: currentRow, table });
|
||||
results.push(...more.filled);
|
||||
for (const key of Object.keys(checkboxFields)) {
|
||||
const idx = notFilled.indexOf(key);
|
||||
if (idx >= 0) notFilled.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extras = { filled: results };
|
||||
if (notFilled.length > 0) extras.notFilled = notFilled;
|
||||
return returnFormState(extras);
|
||||
|
||||
} catch (e) {
|
||||
if (e.message.startsWith('fillTableRow:')) throw e;
|
||||
throw new Error(`fillTableRow: ${e.message}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user