CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
The ❌ delete buttons called wrap.closest('.array-items') AFTER remove();
a detached node has no ancestors, so closest() returned null and
reindexItems() silently skipped. Remaining items kept their old indices,
collect() produced sparse arrays (null holes) and saves failed schema
validation, e.g. '$.details.services[1].specs.items[0]: string érték
szükséges'. Capture the container before remove() for both str-item and
obj-card delete handlers.
Regression test runs the real browser script in jsdom and clicks the
actual delete buttons (nested string array + object card reindexing).
Closes MITHOME-67
324 lines
11 KiB
JavaScript
324 lines
11 KiB
JavaScript
// Browser-side script of the Content Editor editor page.
|
||
// Inlined into the HTML template at render time by content-editor.js.
|
||
// Test coverage: scripts/test-content-editor-serializer.js runs this exact code.
|
||
|
||
// ── Render ──────────────────────────────────────────────────────────────────
|
||
|
||
function render(obj, container) {
|
||
container.innerHTML = '';
|
||
renderObject(obj, container, '');
|
||
}
|
||
|
||
function renderObject(obj, container, prefix) {
|
||
for (const [key, val] of Object.entries(obj)) {
|
||
const path = prefix ? prefix + '.' + key : key;
|
||
if (Array.isArray(val)) {
|
||
renderArray(key, val, container, path);
|
||
} else if (typeof val === 'object' && val !== null) {
|
||
renderObject(val, container, path);
|
||
} else {
|
||
renderPrimitive(path, val, container);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderPrimitive(path, val, container) {
|
||
const isLong = String(val).length > 80 || String(val).includes('<');
|
||
const div = document.createElement('div');
|
||
div.className = 'field';
|
||
const type = val === null ? 'null' : typeof val;
|
||
let control;
|
||
if (type === 'boolean') {
|
||
control = `<input type="checkbox" data-path="${path}" data-type="boolean" ${val ? 'checked' : ''}>`;
|
||
} else if (type === 'number') {
|
||
control = `<input type="number" data-path="${path}" data-type="number" value="${esc(val)}">`;
|
||
} else {
|
||
control = isLong
|
||
? `<textarea data-path="${path}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
|
||
: `<input type="text" data-path="${path}" data-type="${type}" value="${esc(val ?? '')}">`;
|
||
}
|
||
div.innerHTML = `
|
||
<label>${path}</label>
|
||
${control}
|
||
`;
|
||
container.appendChild(div);
|
||
}
|
||
|
||
function renderArray(key, arr, container, path) {
|
||
const section = document.createElement('div');
|
||
section.className = 'array-section';
|
||
section.dataset.arrayPath = path;
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'array-label';
|
||
label.textContent = path;
|
||
section.appendChild(label);
|
||
|
||
const items = document.createElement('div');
|
||
items.className = 'array-items';
|
||
items.dataset.arrayItems = path;
|
||
section.appendChild(items);
|
||
|
||
arr.forEach((item, i) => {
|
||
if (typeof item === 'object' && item !== null) {
|
||
items.appendChild(makeObjCard(item, i, path));
|
||
} else {
|
||
items.appendChild(makeStrItem(item, i, path));
|
||
}
|
||
});
|
||
|
||
// Template for adding new items
|
||
const sample = arr.length > 0 ? arr[arr.length - 1] : '';
|
||
const isObj = typeof sample === 'object' && sample !== null;
|
||
|
||
const addBtn = document.createElement('button');
|
||
addBtn.className = 'btn-add';
|
||
addBtn.textContent = '➕ Új elem hozzáadása';
|
||
addBtn.onclick = () => {
|
||
const idx = items.children.length;
|
||
if (isObj) {
|
||
const blank = blankLike(sample);
|
||
items.appendChild(makeObjCard(blank, idx, path));
|
||
} else {
|
||
items.appendChild(makeStrItem('', idx, path));
|
||
}
|
||
reindexItems(items);
|
||
};
|
||
section.appendChild(addBtn);
|
||
container.appendChild(section);
|
||
}
|
||
|
||
function blankLike(value) {
|
||
if (Array.isArray(value)) return [];
|
||
if (value && typeof value === 'object') {
|
||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
|
||
}
|
||
if (typeof value === 'boolean') return false;
|
||
if (typeof value === 'number') return 0;
|
||
return '';
|
||
}
|
||
|
||
function makeStrItem(val, idx, path) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'str-item';
|
||
const type = val === null ? 'null' : typeof val;
|
||
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
|
||
ta.dataset.path = path + '[' + idx + ']';
|
||
ta.dataset.type = type;
|
||
if (type === 'boolean') {
|
||
ta.type = 'checkbox';
|
||
ta.checked = val;
|
||
} else {
|
||
ta.value = val ?? '';
|
||
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
|
||
}
|
||
const del = document.createElement('button');
|
||
del.className = 'btn-del';
|
||
del.textContent = '❌';
|
||
del.title = 'Törlés';
|
||
del.onclick = () => {
|
||
// WHY: capture the container BEFORE removing — a detached node has no
|
||
// ancestors, so closest() would return null and reindexing would silently
|
||
// not run (sparse arrays → schema errors on save).
|
||
const container = wrap.closest('.array-items');
|
||
wrap.remove();
|
||
reindexItems(container);
|
||
};
|
||
wrap.appendChild(ta);
|
||
wrap.appendChild(del);
|
||
return wrap;
|
||
}
|
||
|
||
function makeObjCard(obj, idx, path) {
|
||
const card = document.createElement('div');
|
||
card.className = 'obj-card';
|
||
const hdr = document.createElement('div');
|
||
hdr.className = 'card-header';
|
||
hdr.textContent = path + '[' + idx + ']';
|
||
card.appendChild(hdr);
|
||
|
||
renderObject(obj, card, path + '[' + idx + ']');
|
||
|
||
const del = document.createElement('button');
|
||
del.className = 'btn-del-card';
|
||
del.textContent = '❌ Törlés';
|
||
del.onclick = () => {
|
||
// Same as above: capture before detaching, or reindexing is skipped.
|
||
const container = card.closest('.array-items');
|
||
card.remove();
|
||
reindexItems(container);
|
||
};
|
||
card.appendChild(del);
|
||
return card;
|
||
}
|
||
|
||
function reindexItems(itemsEl) {
|
||
if (!itemsEl) return;
|
||
const path = itemsEl.dataset.arrayItems;
|
||
// WHY: rewrite only the index that directly follows THIS array's own path prefix.
|
||
// A generic "replace first [n]" rule corrupts nested arrays (e.g. deleting from
|
||
// services[1].specs.items rewrites the OUTER services index and scatters paths
|
||
// across services[0..n], producing sparse arrays and schema errors).
|
||
const prefix = path + '[';
|
||
Array.from(itemsEl.children).forEach((child, i) => {
|
||
child.querySelectorAll('[data-path]').forEach(el => {
|
||
const old = el.dataset.path;
|
||
if (typeof old !== 'string' || !old.startsWith(prefix)) return;
|
||
const rest = old.slice(prefix.length);
|
||
const bracketEnd = rest.indexOf(']');
|
||
const suffix = bracketEnd === -1 ? '' : rest.slice(bracketEnd);
|
||
el.dataset.path = prefix + i + suffix;
|
||
});
|
||
// Update card header
|
||
const hdr = child.querySelector('.card-header');
|
||
if (hdr) hdr.textContent = path + '[' + i + ']';
|
||
});
|
||
}
|
||
|
||
// ── Collect & Save ───────────────────────────────────────────────────────────
|
||
|
||
function collect() {
|
||
const result = JSON.parse(JSON.stringify(DATA)); // deep clone as base
|
||
// Wipe all arrays so we rebuild them from DOM
|
||
clearArrays(result);
|
||
|
||
document.querySelectorAll('[data-path]').forEach(el => {
|
||
setPath(result, el.dataset.path, readValue(el));
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function readValue(el) {
|
||
switch (el.dataset.type) {
|
||
case 'boolean': return el.checked;
|
||
case 'number': return Number(el.value);
|
||
case 'null': return el.value === '' ? null : el.value;
|
||
default: return el.value;
|
||
}
|
||
}
|
||
|
||
function clearArrays(obj) {
|
||
for (const k of Object.keys(obj)) {
|
||
if (Array.isArray(obj[k])) obj[k] = [];
|
||
else if (typeof obj[k] === 'object' && obj[k] !== null) clearArrays(obj[k]);
|
||
}
|
||
}
|
||
|
||
function setPath(obj, path, value) {
|
||
const parts = parsePath(path);
|
||
let cur = obj;
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
const part = parts[i];
|
||
if (cur[part] === undefined || cur[part] === null) {
|
||
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
|
||
}
|
||
cur = cur[part];
|
||
}
|
||
cur[parts[parts.length - 1]] = value;
|
||
}
|
||
|
||
function parsePath(path) {
|
||
const parts = [];
|
||
let token = '';
|
||
let inIndex = false;
|
||
for (const char of path) {
|
||
if (char === '.') {
|
||
if (!inIndex && token) parts.push(token);
|
||
token = '';
|
||
} else if (char === '[') {
|
||
if (token) parts.push(token);
|
||
token = '';
|
||
inIndex = true;
|
||
} else if (char === ']') {
|
||
parts.push(Number(token));
|
||
token = '';
|
||
inIndex = false;
|
||
} else {
|
||
token += char;
|
||
}
|
||
}
|
||
if (token) parts.push(token);
|
||
return parts;
|
||
}
|
||
|
||
async function save() {
|
||
const data = collect();
|
||
const res = await fetch('/save?file=' + FILE, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
|
||
body: JSON.stringify(data, null, 2)
|
||
});
|
||
if (res.status === 401) { location.href = '/login'; return; }
|
||
if (res.status === 409) {
|
||
if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) {
|
||
location.reload();
|
||
}
|
||
return;
|
||
}
|
||
const json = await res.json();
|
||
const status = document.getElementById('saveStatus');
|
||
if (json.ok) {
|
||
status.textContent = '✅ Mentve!';
|
||
status.style.color = '#10b981';
|
||
} else {
|
||
status.textContent = '❌ Hiba: ' + json.error;
|
||
status.style.color = '#f87171';
|
||
}
|
||
status.style.display = 'inline';
|
||
setTimeout(() => status.style.display = 'none', 3000);
|
||
}
|
||
|
||
async function publish() {
|
||
const btn = document.getElementById('publishBtn');
|
||
const status = document.getElementById('saveStatus');
|
||
|
||
// Save first
|
||
await save();
|
||
|
||
btn.textContent = '⏳ Élesítés folyamatban...';
|
||
btn.disabled = true;
|
||
|
||
try {
|
||
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
|
||
if (res.status === 401) { location.href = '/login'; return; }
|
||
const json = await res.json();
|
||
|
||
if (json.ok) {
|
||
status.textContent = '🚀 Sikeresen elküldve a szerverre!';
|
||
status.style.color = '#10b981';
|
||
} else {
|
||
status.textContent = '❌ Hiba az élesítésnél: ' + json.error;
|
||
status.style.color = '#f87171';
|
||
}
|
||
} catch (e) {
|
||
status.textContent = '❌ Hálózati hiba';
|
||
status.style.color = '#f87171';
|
||
}
|
||
|
||
btn.textContent = '🚀 Publikálás & Élesítés';
|
||
btn.disabled = false;
|
||
status.style.display = 'inline';
|
||
setTimeout(() => status.style.display = 'none', 5000);
|
||
}
|
||
|
||
async function logout() {
|
||
if (!confirm('Biztosan ki szeretnél lépni?')) return;
|
||
try {
|
||
// Invalidates the server-side session cookie (Basic Auth cache is not
|
||
// affected — the login page is public, no 401-overwrite is needed).
|
||
await fetch('/logout', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
|
||
} catch (e) { /* network error — continue to the login page */ }
|
||
location.href = '/login';
|
||
}
|
||
|
||
function esc(v) {
|
||
return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// Boot
|
||
render(DATA, document.getElementById('editor'));
|
||
|
||
// Auto-dismiss toast
|
||
const toast = document.querySelector('.toast');
|
||
if (toast) setTimeout(() => toast.remove(), 3500);
|