mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-06 03:08:57 +03:00
feat(web-test): поддержка гридов без шапки (headerless grids)
Единый источник деривации колонок для безголовых гридов (без .gridHead):
synthHeaderlessColumns (HEADERLESS_GRID_FN в _shared.mjs) — привязка по
colindex, имена Колонка{N}/(checkbox)/(picture), расщепление комбинированной
марк-ячейки (галка+текст в одной .gridBox) на (checkbox)+Колонка1 через subTarget.
Ветки head ? существующее : synth добавлены в:
- readTableScript, findGridCellScript, scanGridRowsScript,
findGridHeadCenterCoordsScript (grid.mjs)
- findCellCoordsByFieldsScript, findNextCellCoordsByKeyScript,
sortFieldKeysByColindexScript, readActiveGridCellScript (grid-edit.mjs)
- сводка таблиц в READ_FORM_FN (_shared.mjs)
Гейт — неизменная истинность head, headed-путь нетронут. Регресс 23/23 зелёный.
Новый tests/web-test/21-headerless.test.mjs. Live на БезшапочнаяТаблица +
МножественныйВыбор: readTable/getFormState/fillTableRow(тоггл (checkbox))/clickElement.
Follow-up: правка data-ячейки на безголовом ValueTable (edit-Tab-loop row-fill).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// web-test dom shared v1.1 — embedded JS function constants
|
||||
// web-test dom shared v1.2 — embedded JS function constants
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Shared function strings embedded into page.evaluate() generators.
|
||||
@@ -43,6 +43,68 @@ export const ROW_CLICK_POINT_FN = `function rowClickPoint(line, body) {
|
||||
return { x: Math.round(pick.r.x + Math.min(pick.r.width / 2, 60)), y: Math.round(pick.r.y + pick.r.height / 2) };
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Single source of truth for column derivation on HEADERLESS grids (no `.gridHead`).
|
||||
* 1C still puts `colindex` on body cells, so anchoring works without a header.
|
||||
* Returns ordered descriptors consumed identically by readers (readTable, getFormState)
|
||||
* and resolvers (findCellCoords, findGridCell, scanGridRows) so a synthesized name like
|
||||
* "Колонка1" always maps to the same physical cell on both read and write.
|
||||
*
|
||||
* Descriptor: { name, kind:'data'|'checkbox'|'picture', colindex, subTarget:'checkbox'|'title'|'text'|null }
|
||||
* - colindex — anchor: find the cell via line.children box with matching getAttribute('colindex').
|
||||
* - subTarget — node inside that box: 'checkbox' → .checkbox, 'title' → .gridBoxTitle,
|
||||
* 'text' → .gridBoxText, null → box itself.
|
||||
*
|
||||
* A COMBINED mark-box (one box holding BOTH .checkbox AND non-empty .gridBoxTitle, e.g. the
|
||||
* value-list checkbox mark-lists) is split into TWO logical columns sharing one colindex:
|
||||
* "(checkbox)" (subTarget:checkbox) + "КолонкаN" (subTarget:title). Data columns are numbered
|
||||
* КолонкаN among themselves (checkbox/picture don't consume a number); duplicate
|
||||
* "(checkbox)"/"(picture)" get a " 2", " 3" suffix.
|
||||
*/
|
||||
export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) {
|
||||
function picInfo(cell) {
|
||||
if (!cell) return null;
|
||||
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
||||
const dib = cell.querySelector('.gridBoxImg .dIB');
|
||||
if (!dib) return null;
|
||||
const bg = dib.style.backgroundImage || '';
|
||||
if (!bg.includes('pictureCollection/picture/')) return null;
|
||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||
return { gx: m ? m[1] : '0' };
|
||||
}
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return [];
|
||||
const line = body.querySelector('.gridLine');
|
||||
if (!line) return [];
|
||||
const cols = [];
|
||||
let dataN = 0;
|
||||
const uniq = (base) => {
|
||||
if (!cols.some(c => c.name === base)) return base;
|
||||
let n = 2; while (cols.some(c => c.name === base + ' ' + n)) n++;
|
||||
return base + ' ' + n;
|
||||
};
|
||||
[...line.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const ci = box.getAttribute('colindex');
|
||||
if (ci == null) return;
|
||||
const chk = box.querySelector('.checkbox');
|
||||
const titleEl = box.querySelector('.gridBoxTitle');
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const titleTxt = ((titleEl ? titleEl.innerText : '') || '').trim();
|
||||
if (chk && titleTxt) {
|
||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: 'title' });
|
||||
} else if (chk) {
|
||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
||||
} else if (picInfo(box)) {
|
||||
cols.push({ name: uniq('(picture)'), kind: 'picture', colindex: ci, subTarget: null });
|
||||
} else {
|
||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: textEl ? 'text' : (titleEl ? 'title' : null) });
|
||||
}
|
||||
});
|
||||
return cols;
|
||||
}`;
|
||||
|
||||
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
||||
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
||||
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
||||
@@ -80,7 +142,8 @@ function detectForms() {
|
||||
}`;
|
||||
|
||||
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
|
||||
export const READ_FORM_FN = `function readForm(p) {
|
||||
export const READ_FORM_FN = HEADERLESS_GRID_FN + `
|
||||
function readForm(p) {
|
||||
const result = {};
|
||||
const fields = [];
|
||||
const buttons = [];
|
||||
@@ -317,6 +380,9 @@ export const READ_FORM_FN = `function readForm(p) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (body) {
|
||||
// Headerless grid — synthesize columns by colindex (single source).
|
||||
synthHeaderlessColumns(grid).forEach(c => columns.push({ text: c.name, x: 0, right: 0, y: 0, h: 0 }));
|
||||
}
|
||||
const colNames = columns.map(c => c.text);
|
||||
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations)
|
||||
// web-test dom/grid-edit v1.1 — DOM scripts for row-fill (grid edit-time operations)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
import { HEADERLESS_GRID_FN } from './_shared.mjs';
|
||||
//
|
||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||
// the page (this matches the implicit "current grid" used by row-fill).
|
||||
@@ -24,18 +26,23 @@ function gridResolver(gridSelector) {
|
||||
*/
|
||||
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
|
||||
return `(() => {
|
||||
${HEADERLESS_GRID_FN}
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
if (!head) return null;
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
|
||||
const ci = parseInt(box.getAttribute('colindex') || '-1');
|
||||
if (t) cols.push({ text: t, colindex: ci });
|
||||
});
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
|
||||
const ci = parseInt(box.getAttribute('colindex') || '-1');
|
||||
if (t) cols.push({ text: t, colindex: ci });
|
||||
});
|
||||
} else {
|
||||
// Headerless: synthesized columns (КолонкаN/(checkbox)) ordered by colindex
|
||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ text: c.name.toLowerCase(), colindex: parseInt(c.colindex) }));
|
||||
}
|
||||
const keys = ${JSON.stringify(fieldKeys)};
|
||||
const mapped = keys.map(k => {
|
||||
const exact = cols.find(c => c.text === k);
|
||||
@@ -60,29 +67,34 @@ export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
|
||||
*/
|
||||
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
||||
return `(() => {
|
||||
${HEADERLESS_GRID_FN}
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return { error: 'no_grid_body' };
|
||||
if (!body) return { error: 'no_grid_body' };
|
||||
|
||||
// Read column headers to find target colindex
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
// Read columns to find target colindex (+ subTarget for headerless split mark-boxes)
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||
});
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
|
||||
});
|
||||
} else {
|
||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
|
||||
}
|
||||
|
||||
const keys = ${JSON.stringify(fieldKeys)};
|
||||
let targetColindex = null;
|
||||
let targetColindex = null, targetSub = null;
|
||||
for (const key of keys) {
|
||||
const exact = cols.find(c => c.text === key);
|
||||
if (exact) { targetColindex = exact.colindex; break; }
|
||||
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; break; }
|
||||
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
|
||||
if (inc) { targetColindex = inc.colindex; break; }
|
||||
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; break; }
|
||||
}
|
||||
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
@@ -101,7 +113,12 @@ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
||||
}
|
||||
if (!box) return { error: 'no_cell' };
|
||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
// subTarget-aware cell node: 'checkbox' → box itself (row-fill then uses findCheckboxAtPoint),
|
||||
// 'title' → .gridBoxTitle, else (headed default / 'text') → .gridBoxText || box.
|
||||
let cell;
|
||||
if (targetSub === 'checkbox') cell = box;
|
||||
else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box;
|
||||
else cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||
@@ -117,28 +134,33 @@ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
||||
*/
|
||||
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
||||
return `(() => {
|
||||
${HEADERLESS_GRID_FN}
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return null;
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
if (!body) return null;
|
||||
const cols = [];
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||
});
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const t = box.querySelector('.gridBoxText');
|
||||
const ci = box.getAttribute('colindex');
|
||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
|
||||
});
|
||||
} else {
|
||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
|
||||
}
|
||||
const kl = ${JSON.stringify(key.toLowerCase())};
|
||||
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
|
||||
let targetColindex = null;
|
||||
let targetColindex = null, targetSub = null;
|
||||
const exact = cols.find(c => c.text === kl);
|
||||
if (exact) targetColindex = exact.colindex;
|
||||
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; }
|
||||
else {
|
||||
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|
||||
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
|
||||
if (inc) targetColindex = inc.colindex;
|
||||
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; }
|
||||
}
|
||||
if (targetColindex == null) return null;
|
||||
const rows = [...body.querySelectorAll('.gridLine')];
|
||||
@@ -147,7 +169,10 @@ export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
||||
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||||
if (!box) return null;
|
||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const cell = box.querySelector('.gridBoxText') || box;
|
||||
let cell;
|
||||
if (targetSub === 'checkbox') cell = box;
|
||||
else if (targetSub === 'title') cell = box.querySelector('.gridBoxTitle') || box;
|
||||
else cell = box.querySelector('.gridBoxText') || box;
|
||||
const r = cell.getBoundingClientRect();
|
||||
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||
@@ -234,6 +259,7 @@ export function getGridEditCheckScript() {
|
||||
*/
|
||||
export function readActiveGridCellScript() {
|
||||
return `(() => {
|
||||
${HEADERLESS_GRID_FN}
|
||||
const f = document.activeElement;
|
||||
if (!f) return { tag: 'none' };
|
||||
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
|
||||
@@ -254,6 +280,15 @@ export function readActiveGridCellScript() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!head) {
|
||||
// Headerless: resolve the editing column name from the cell's colindex via synth.
|
||||
let box = f; while (box && !box.classList?.contains('gridBox')) box = box.parentElement;
|
||||
const ci = box?.getAttribute('colindex');
|
||||
if (ci != null) {
|
||||
const sc = synthHeaderlessColumns(grid).find(c => c.kind === 'data' && c.colindex === ci);
|
||||
if (sc) headerText = sc.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
|
||||
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// web-test dom/grid v1.11 — grid resolution + table reading + edit-time helpers
|
||||
// web-test dom/grid v1.12 — grid resolution + table reading + edit-time helpers
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { ROW_CLICK_POINT_FN } from './_shared.mjs';
|
||||
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* Resolve a specific grid by semantic name (table parameter).
|
||||
@@ -94,18 +94,92 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||
return { gx: m ? m[1] : '0' };
|
||||
}
|
||||
${HEADERLESS_GRID_FN}
|
||||
|
||||
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) {
|
||||
// Fallback: innerText-based (for non-standard grids)
|
||||
if (!body) {
|
||||
// Fallback: innerText-based (for non-standard grids without a body)
|
||||
const gText = grid.innerText?.trim() || '';
|
||||
const lines = gText.split('\\n').filter(Boolean);
|
||||
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
|
||||
hint: 'Grid has no gridHead/gridBody structure' };
|
||||
}
|
||||
|
||||
// HEADERLESS grid (body but no .gridHead) — synthesize columns by colindex.
|
||||
// Single source: synthHeaderlessColumns. Headed path below is left untouched.
|
||||
if (!head) {
|
||||
const synth = synthHeaderlessColumns(grid);
|
||||
const colNames = synth.map(c => c.name);
|
||||
const allLines = body.querySelectorAll('.gridLine');
|
||||
const total = allLines.length;
|
||||
const rows = [];
|
||||
const end = Math.min(${offset} + ${maxRows}, total);
|
||||
for (let i = ${offset}; i < end; i++) {
|
||||
const line = allLines[i];
|
||||
if (!line) break;
|
||||
const row = {};
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0);
|
||||
synth.forEach(c => {
|
||||
const box = boxes.find(b => b.getAttribute('colindex') === c.colindex);
|
||||
let val = '';
|
||||
if (box) {
|
||||
if (c.subTarget === 'checkbox') {
|
||||
const chk = box.querySelector('.checkbox');
|
||||
val = chk && chk.classList.contains('select') ? 'true' : 'false';
|
||||
} else if (c.subTarget === 'title') {
|
||||
val = (box.querySelector('.gridBoxTitle')?.innerText || '').trim().replace(/\\n/g, ' ');
|
||||
} else if (c.subTarget === 'text') {
|
||||
val = (box.querySelector('.gridBoxText')?.innerText || '').trim().replace(/\\n/g, ' ');
|
||||
} else {
|
||||
const pic = picInfo(box);
|
||||
val = pic ? 'pic:' + pic.gx : ((box.innerText || '').trim().replace(/\\n/g, ' '));
|
||||
}
|
||||
}
|
||||
row[c.name] = val;
|
||||
});
|
||||
// Row meta — mirrors the headed path (group/parent/tree/level/selected)
|
||||
const imgBox = line.querySelector('.gridBoxImg');
|
||||
if (imgBox) {
|
||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
||||
}
|
||||
const treeBox = line.querySelector('.gridBoxTree');
|
||||
if (treeBox) {
|
||||
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
||||
if (treeIcon) { const bg = treeIcon.style.backgroundImage || ''; row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed'; }
|
||||
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
|
||||
}
|
||||
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
|
||||
rows.push(row);
|
||||
}
|
||||
// hasMore — mirrors the headed path
|
||||
let hasMore;
|
||||
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
||||
if (turnsBox && turnsBox.offsetHeight > 0) {
|
||||
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
|
||||
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
||||
hasMore = { above: [...upBtns].some(b => !b.classList.contains('disabled')),
|
||||
below: [...dnBtns].some(b => !b.classList.contains('disabled')) };
|
||||
} else {
|
||||
const vs = document.getElementById('vertScroll_' + grid.id);
|
||||
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
|
||||
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
|
||||
hasMore = { above: back > 0, below: next > 0 };
|
||||
} else {
|
||||
hasMore = { below: body.scrollHeight > body.clientHeight };
|
||||
}
|
||||
}
|
||||
const isTree = !!body.querySelector('.gridBoxTree');
|
||||
const hasGroups = rows.some(r => r._kind === 'group');
|
||||
const result = { name, columns: colNames, rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
||||
if (isTree) result.viewMode = 'tree';
|
||||
if (hasGroups) result.hierarchical = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract column headers with X-coordinates for alignment
|
||||
const columns = [];
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
@@ -381,7 +455,13 @@ export function findGridHeadCenterCoordsScript(gridSelector) {
|
||||
const grid = ${gridResolver(gridSelector)};
|
||||
if (!grid) return null;
|
||||
const head = grid.querySelector('.gridHead');
|
||||
if (!head) return null;
|
||||
if (!head) {
|
||||
// Headerless editable grid: no header to click for commit-defocus. Click the
|
||||
// thin strip at the grid's very top edge (above the first row) so the active
|
||||
// edit commits without landing on a .gridLine (which would re-enter edit).
|
||||
const gr = grid.getBoundingClientRect();
|
||||
return { x: Math.round(gr.x + gr.width / 2), y: Math.round(gr.y + 1) };
|
||||
}
|
||||
const r = head.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
@@ -429,6 +509,7 @@ export function getSelectedOrLastRowIndexScript(gridSelector) {
|
||||
export function scanGridRowsScript(formNum, search) {
|
||||
return `(() => {
|
||||
${ROW_CLICK_POINT_FN}
|
||||
${HEADERLESS_GRID_FN}
|
||||
const p = 'form${formNum}_';
|
||||
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
|
||||
if (!grid) return null;
|
||||
@@ -454,26 +535,35 @@ export function scanGridRowsScript(formNum, search) {
|
||||
} else if (isObj) {
|
||||
// Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol.
|
||||
const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead');
|
||||
const headers = [...(headLine ? headLine.children : [])]
|
||||
.filter(c => c.offsetWidth > 0)
|
||||
.map(c => {
|
||||
const t = (c.querySelector('.gridBoxText') || c).innerText || '';
|
||||
const title = c.getAttribute('title') || '';
|
||||
const r = c.getBoundingClientRect();
|
||||
return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width };
|
||||
})
|
||||
.filter(h => h.name);
|
||||
let headers;
|
||||
if (headLine) {
|
||||
headers = [...headLine.children]
|
||||
.filter(c => c.offsetWidth > 0)
|
||||
.map(c => {
|
||||
const t = (c.querySelector('.gridBoxText') || c).innerText || '';
|
||||
const title = c.getAttribute('title') || '';
|
||||
const r = c.getBoundingClientRect();
|
||||
return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width };
|
||||
})
|
||||
.filter(h => h.name);
|
||||
} else {
|
||||
// Headerless: synthesized columns anchored by colindex.
|
||||
headers = synthHeaderlessColumns(grid).map(c => ({ name: c.name, text: c.name, title: '', x: 0, right: 0, colindex: c.colindex }));
|
||||
}
|
||||
const resolveCol = name => {
|
||||
const n = norm(name);
|
||||
const cand = h => [h.text, h.title].filter(Boolean);
|
||||
return headers.find(h => cand(h).some(t => norm(t) === n))
|
||||
|| headers.find(h => cand(h).some(t => norm(t).includes(n)));
|
||||
};
|
||||
const cellAtCol = (line, col) => visCells(line).find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= col.x && cx < col.right;
|
||||
});
|
||||
const cellAtCol = (line, col) => {
|
||||
if (col.colindex != null) return visCells(line).find(b => b.getAttribute('colindex') === col.colindex);
|
||||
return visCells(line).find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= col.x && cx < col.right;
|
||||
});
|
||||
};
|
||||
const keys = Object.keys(search);
|
||||
const cols = {};
|
||||
for (const k of keys) {
|
||||
@@ -559,25 +649,33 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!head || !body) return { error: 'no_grid_structure' };
|
||||
if (!body) return { error: 'no_grid_structure' };
|
||||
${HEADERLESS_GRID_FN}
|
||||
const isHeadless = !head;
|
||||
|
||||
// Header X-ranges (mirror of readTableScript logic, simplified). We also
|
||||
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
|
||||
// columns can share X coordinates after horizontal scroll, so cell matching
|
||||
// must respect the frozen/scrollable partition.
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const headers = [...headLine.children]
|
||||
.filter(c => c.offsetWidth > 0)
|
||||
.map(c => {
|
||||
const textEl = c.querySelector('.gridBoxText');
|
||||
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
// Picture/icon columns have no header text — fall back to the title tooltip
|
||||
// (mirrors readTable naming) so they can still be targeted for clicking.
|
||||
const title = (c.getAttribute('title') || '').trim();
|
||||
const r = c.getBoundingClientRect();
|
||||
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
||||
})
|
||||
.filter(h => h.name);
|
||||
let headers;
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
headers = [...headLine.children]
|
||||
.filter(c => c.offsetWidth > 0)
|
||||
.map(c => {
|
||||
const textEl = c.querySelector('.gridBoxText');
|
||||
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
// Picture/icon columns have no header text — fall back to the title tooltip
|
||||
// (mirrors readTable naming) so they can still be targeted for clicking.
|
||||
const title = (c.getAttribute('title') || '').trim();
|
||||
const r = c.getBoundingClientRect();
|
||||
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
||||
})
|
||||
.filter(h => h.name);
|
||||
} else {
|
||||
// Headerless: synthesized columns anchored by colindex (cellAtColX matches by colindex).
|
||||
headers = synthHeaderlessColumns(grid).map(c => ({ text: c.name, title: '', name: c.name, x: 0, right: 0, fixed: false, colindex: c.colindex, subTarget: c.subTarget }));
|
||||
}
|
||||
|
||||
const resolveCol = (name) => {
|
||||
const suffix = ' / ' + name;
|
||||
@@ -598,13 +696,20 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
||||
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
|
||||
// cell may have the same x as a frozen one — without this guard cellAtColX
|
||||
// would silently return the frozen cell for a scrollable header.
|
||||
const cellAtColX = (line, c) => [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
||||
.find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= c.x && cx < c.right;
|
||||
});
|
||||
const cellAtColX = (line, c) => {
|
||||
// Headerless columns carry colindex → match the body cell directly (robust,
|
||||
// and returns the same box for both logical columns of a combined mark-box).
|
||||
if (c.colindex != null) {
|
||||
return [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === c.colindex);
|
||||
}
|
||||
return [...line.children]
|
||||
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
||||
.find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= c.x && cx < c.right;
|
||||
});
|
||||
};
|
||||
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
||||
|
||||
const target = ${JSON.stringify(row)};
|
||||
@@ -644,6 +749,20 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
||||
|
||||
const cell = cellAtColX(line, col);
|
||||
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
|
||||
// Headerless: click coords target the subTarget node (checkbox image / title),
|
||||
// no frozen/scroll partition in these narrow grids → trivially visible.
|
||||
if (isHeadless) {
|
||||
let node = cell;
|
||||
if (col.subTarget === 'checkbox') node = cell.querySelector('.checkbox') || cell;
|
||||
else if (col.subTarget === 'title') node = cell.querySelector('.gridBoxTitle') || cell;
|
||||
else if (col.subTarget === 'text') node = cell.querySelector('.gridBoxText') || cell;
|
||||
const rr = node.getBoundingClientRect();
|
||||
const gb = grid.getBoundingClientRect();
|
||||
return { x: Math.round(rr.x + rr.width / 2), y: Math.round(rr.y + rr.height / 2),
|
||||
cellX: Math.round(rr.x), cellRight: Math.round(rr.x + rr.width),
|
||||
gridX: Math.round(gb.x), gridRight: Math.round(gb.x + gb.width), scrollableLeft: Math.round(gb.x),
|
||||
columnText: col.name, rowIdx, isFixed: false, cellText: cellText(cell), visible: true };
|
||||
}
|
||||
const r = cell.getBoundingClientRect();
|
||||
const gridBox = grid.getBoundingClientRect();
|
||||
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export const name = 'headerless grids: readTable / getFormState / fillTableRow / clickElement без шапки';
|
||||
export const tags = ['headerless', 'table', 'smoke'];
|
||||
export const timeout = 180000;
|
||||
|
||||
// Покрывает поддержку гридов без .gridHead (фаза 2 мультивыбора):
|
||||
// - БезшапочнаяТаблица (ValueTable, отдельная колонка-чекбокс)
|
||||
// - МножественныйВыбор → Через флажки (марк-список, комбинированная ячейка галка+текст)
|
||||
// Деривация колонок едина (synthHeaderlessColumns): Колонка{N} + (checkbox), привязка по colindex.
|
||||
// Правка data-ячейки на безголовом ValueTable — follow-up (edit-Tab-loop row-fill), здесь не тестируется.
|
||||
|
||||
export default async function({
|
||||
navigateLink, clickElement, fillTableRow, readTable, getFormState, wait, assert, step, log, closeForm
|
||||
}) {
|
||||
|
||||
// ── ValueTable (отдельная колонка-чекбокс) ──────────────────────────────────
|
||||
await step('readTable: ValueTable без шапки → Колонка1..3 + (checkbox)', async () => {
|
||||
await navigateLink('Обработка.БезшапочнаяТаблица');
|
||||
await wait(1.2);
|
||||
const t = await readTable({ maxRows: 10 });
|
||||
log(`columns: ${JSON.stringify(t.columns)}`);
|
||||
assert.deepEqual(t.columns, ['Колонка1', 'Колонка2', 'Колонка3', '(checkbox)'],
|
||||
'синтетические колонки по colindex');
|
||||
assert.equal(t.total, 3, '3 строки');
|
||||
assert.equal(t.rows[0]['Колонка1'], 'Позиция 001', 'текст data-колонки');
|
||||
assert.ok(t.rows[0]['(checkbox)'] === 'true' || t.rows[0]['(checkbox)'] === 'false',
|
||||
'(checkbox) читается как true/false');
|
||||
});
|
||||
|
||||
await step('getFormState: сводка таблицы показывает synth-колонки + rowCount', async () => {
|
||||
const fs = await getFormState();
|
||||
const tbl = (fs.tables || []).find(x => (x.columns || []).includes('(checkbox)'));
|
||||
log(`tables: ${JSON.stringify(fs.tables)}`);
|
||||
assert.ok(tbl, 'таблица с (checkbox) в сводке');
|
||||
assert.deepEqual(tbl.columns, ['Колонка1', 'Колонка2', 'Колонка3', '(checkbox)'], 'колонки сводки совпадают с readTable');
|
||||
assert.equal(tbl.rowCount, 3, 'rowCount');
|
||||
});
|
||||
|
||||
await step('fillTableRow: тоггл (checkbox) по индексу строки', async () => {
|
||||
const before = (await readTable({ maxRows: 5 })).rows.map(r => r['(checkbox)']);
|
||||
const target = before[1] === 'true' ? 'false' : 'true'; // строка 1 — переключаем в противоположное
|
||||
const r = await fillTableRow({ '(checkbox)': target }, { row: 1 });
|
||||
log(`filled: ${JSON.stringify(r.filled)}`);
|
||||
assert.ok(r.filled?.[0]?.ok && r.filled[0].method === 'toggle', 'method=toggle, ok');
|
||||
const after = (await readTable({ maxRows: 5 })).rows.map(r => r['(checkbox)']);
|
||||
assert.equal(after[1], target, `строка 1 переключена в ${target}`);
|
||||
});
|
||||
|
||||
await step('fillTableRow: тоггл (checkbox) по фильтру {Колонка1}', async () => {
|
||||
const r = await fillTableRow({ '(checkbox)': 'true' }, { row: { 'Колонка1': 'Позиция 002' } });
|
||||
log(`filled: ${JSON.stringify(r.filled)}`);
|
||||
assert.ok(r.filled?.[0]?.ok, 'ok через row-фильтр {Колонка1}');
|
||||
const t = await readTable({ maxRows: 5 });
|
||||
const row = t.rows.find(x => x['Колонка1'] === 'Позиция 002');
|
||||
assert.equal(row['(checkbox)'], 'true', 'Позиция 002 отмечена');
|
||||
});
|
||||
|
||||
await step('clickElement: клик по ячейке (checkbox) тогглит галку', async () => {
|
||||
const t0 = await readTable({ maxRows: 5 });
|
||||
const r0 = t0.rows.find(x => x['Колонка1'] === 'Позиция 003');
|
||||
const want = r0['(checkbox)'] === 'true' ? 'false' : 'true';
|
||||
await clickElement({ row: { 'Колонка1': 'Позиция 003' }, column: '(checkbox)' });
|
||||
await wait(0.4);
|
||||
const t1 = await readTable({ maxRows: 5 });
|
||||
const r1 = t1.rows.find(x => x['Колонка1'] === 'Позиция 003');
|
||||
assert.equal(r1['(checkbox)'], want, `клик переключил галку Позиция 003 в ${want}`);
|
||||
});
|
||||
|
||||
// ── Марк-список (комбинированная ячейка галка+текст в одной .gridBox) ────────
|
||||
await step('марк-список: readTable → (checkbox) + Колонка1, расщепление одной ячейки', async () => {
|
||||
await navigateLink('Обработка.МножественныйВыбор');
|
||||
await clickElement('Через флажки');
|
||||
await wait(1.2);
|
||||
const t = await readTable({ maxRows: 10 });
|
||||
log(`columns: ${JSON.stringify(t.columns)} rows: ${JSON.stringify(t.rows)}`);
|
||||
assert.deepEqual(t.columns, ['(checkbox)', 'Колонка1'], 'комбинированная ячейка → 2 колонки');
|
||||
assert.ok(t.rows.some(r => r['Колонка1'] === 'Альфа'), 'Альфа в Колонка1');
|
||||
assert.ok(t.rows.every(r => r['(checkbox)'] === 'true' || r['(checkbox)'] === 'false'), '(checkbox) булев');
|
||||
});
|
||||
|
||||
await step('марк-список: fillTableRow отмечает по фильтру {Колонка1: Альфа}', async () => {
|
||||
const r = await fillTableRow({ '(checkbox)': 'true' }, { row: { 'Колонка1': 'Альфа' } });
|
||||
log(`filled: ${JSON.stringify(r.filled)}`);
|
||||
assert.ok(r.filled?.[0]?.ok && r.filled[0].method === 'toggle', 'method=toggle, ok');
|
||||
const t = await readTable({ maxRows: 10 });
|
||||
const alpha = t.rows.find(x => x['Колонка1'] === 'Альфа');
|
||||
assert.equal(alpha['(checkbox)'], 'true', 'Альфа отмечена');
|
||||
});
|
||||
|
||||
await step('cleanup: закрыть форму ввода значений', async () => {
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user