mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-11 16:34:57 +03:00
958 lines
44 KiB
JavaScript
958 lines
44 KiB
JavaScript
// 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}`);
|
|
}
|
|
}
|