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
- Ctrl/Cmd+S saves (native browser save dialog suppressed) - Ctrl/Cmd+P publishes; Ctrl/Cmd+Shift+V opens the Versions panel - '?' toggles a shortcuts overlay (Esc/click closes) - plain typing in inputs never triggers actions (modifiers required; '?' only outside editing targets) - jsdom regression tests run the real client script with dispatched KeyboardEvents; the client is evaluated once per suite because each eval would stack another keydown listener on the shared document Closes MITHOME-75
388 lines
14 KiB
JavaScript
388 lines
14 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) {
|
||
// Refresh the optimistic-lock fingerprint with the server-computed hash of
|
||
// the saved content, so the user's own subsequent saves don't trip 409.
|
||
if (json.contentHash) CONTENT_HASH = json.contentHash;
|
||
status.textContent = '✅ Mentve!';
|
||
status.style.color = '#10b981';
|
||
} else {
|
||
status.textContent = '❌ Hiba: ' + json.error;
|
||
status.style.color = '#f87171';
|
||
}
|
||
status.style.visibility = 'visible';
|
||
setTimeout(() => status.style.visibility = 'hidden', 3000);
|
||
}
|
||
|
||
async function publish() {
|
||
const btn = document.getElementById('publishBtn');
|
||
const status = document.getElementById('saveStatus');
|
||
|
||
// Save first
|
||
await save();
|
||
|
||
// WHY: lock the button width and remember the label so the running state
|
||
// neither resizes the bottom bar nor permanently swaps the env-specific label.
|
||
const originalLabel = btn.textContent;
|
||
btn.style.minWidth = btn.offsetWidth + 'px';
|
||
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 = originalLabel;
|
||
btn.style.minWidth = '';
|
||
btn.disabled = false;
|
||
status.style.visibility = 'visible';
|
||
setTimeout(() => status.style.visibility = 'hidden', 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);
|
||
|
||
// ── Keyboard shortcuts ───────────────────────────────────────────────────────
|
||
// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help
|
||
// Plain typing in inputs never triggers actions — the handler requires the
|
||
// modifier key (or, for '?', a non-editing target).
|
||
|
||
function showShortcutsOverlay() {
|
||
const existing = document.getElementById('shortcuts-overlay');
|
||
if (existing) { existing.remove(); return; }
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'shortcuts-overlay';
|
||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;';
|
||
overlay.innerHTML = `
|
||
<div style="background:#1a2035;border:1px solid #2d3748;border-radius:14px;padding:28px 32px;max-width:420px;width:100%;font-size:14px;line-height:2;color:#e2e8f0;">
|
||
<h2 style="font-size:16px;color:#93c5fd;margin-bottom:12px;">⌨️ Gyorsbillentyűk</h2>
|
||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + S</kbd> — Mentés</div>
|
||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + P</kbd> — Publikálás</div>
|
||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + Shift + V</kbd> — Verziók</div>
|
||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">?</kbd> — ez a súgó (Esc: bezárás)</div>
|
||
</div>`;
|
||
overlay.addEventListener('click', () => overlay.remove());
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
// Esc closes the shortcut overlay if open
|
||
if (e.key === 'Escape') {
|
||
const overlay = document.getElementById('shortcuts-overlay');
|
||
if (overlay) { overlay.remove(); e.preventDefault(); }
|
||
return;
|
||
}
|
||
const mod = e.ctrlKey || e.metaKey;
|
||
if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
|
||
e.preventDefault();
|
||
save();
|
||
return;
|
||
}
|
||
if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) {
|
||
e.preventDefault();
|
||
publish();
|
||
return;
|
||
}
|
||
if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) {
|
||
e.preventDefault();
|
||
window.open('/versions?file=' + encodeURIComponent(FILE), '_blank');
|
||
return;
|
||
}
|
||
if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') {
|
||
const target = e.target;
|
||
const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
|
||
if (!isEditing) {
|
||
e.preventDefault();
|
||
showShortcutsOverlay();
|
||
}
|
||
}
|
||
});
|