1248 lines
46 KiB
HTML
1248 lines
46 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Test Case Editor • PBX Automation</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||
<style>
|
||
/* =========================================
|
||
AURA THEME by Dalton Menezes
|
||
https://github.com/daltonmenezes/aura-theme
|
||
========================================= */
|
||
:root {
|
||
/* Backgrounds */
|
||
--bg: #21202e;
|
||
--bg-secondary: #1c1b29;
|
||
--card: #27263a;
|
||
--card-hover: #2a293a;
|
||
--input: #1f1e2e;
|
||
|
||
/* Borders & Lines */
|
||
--border: #3a3849;
|
||
--border-light: #4a4859;
|
||
|
||
/* Text */
|
||
--text: #edecee;
|
||
--text-muted: #a0a0b0;
|
||
--text-secondary: #8a8899;
|
||
|
||
/* Accents (Aura signature) */
|
||
--accent: #a277ff; /* Purple */
|
||
--accent-hover: #b38aff;
|
||
--accent-warm: #ffca85; /* Warm orange/yellow */
|
||
--green: #6cc76f;
|
||
--red: #ff6767;
|
||
--orange: #ffca85;
|
||
}
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
.card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.row-hover:hover {
|
||
background: var(--card-hover);
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: .6px;
|
||
text-transform: uppercase;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.modal {
|
||
animation: fadeInScale .15s ease-out;
|
||
}
|
||
|
||
@keyframes fadeInScale {
|
||
from { opacity: 0; transform: scale(0.96); }
|
||
to { opacity: 1; transform: scale(1); }
|
||
}
|
||
|
||
.editable-list li {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.editable-list input {
|
||
flex: 1;
|
||
background: var(--input);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.editable-list button {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.editable-list button:hover {
|
||
color: var(--red);
|
||
}
|
||
|
||
.dep-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.dep-row input {
|
||
background: var(--input);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 5px 8px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* Status colors */
|
||
.status-automated {
|
||
background: #1a3c2e;
|
||
color: var(--green);
|
||
}
|
||
|
||
.status-partial {
|
||
background: #3f2f1f;
|
||
color: var(--orange);
|
||
}
|
||
|
||
.status-manual {
|
||
background: #3f1f28;
|
||
color: var(--red);
|
||
}
|
||
|
||
.dirty {
|
||
color: var(--orange);
|
||
}
|
||
|
||
/* Aura accent buttons */
|
||
.btn-accent {
|
||
background: var(--input);
|
||
color: var(--accent);
|
||
border: 1px solid var(--accent);
|
||
}
|
||
|
||
.btn-accent:hover {
|
||
background: #2f2a3f;
|
||
}
|
||
|
||
/* Component styles - Aura Theme */
|
||
.group-card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.group-card:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.group-card.active {
|
||
background: var(--card);
|
||
border: 2px solid var(--accent);
|
||
box-shadow: 0 0 0 1px rgba(162, 119, 255, 0.3);
|
||
}
|
||
|
||
.module-card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.module-card:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.module-card.active {
|
||
background: var(--card);
|
||
border: 2px solid var(--accent);
|
||
box-shadow: 0 0 0 1px rgba(162, 119, 255, 0.3);
|
||
}
|
||
|
||
.nav-button {
|
||
background: var(--input);
|
||
color: var(--accent);
|
||
border: 1px solid var(--accent);
|
||
}
|
||
|
||
.nav-button:hover {
|
||
background: #2f2a3f;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="text-sm">
|
||
<div class="max-w-[1600px] mx-auto">
|
||
<!-- Header -->
|
||
<div class="bg-[var(--bg-secondary)] border-b border-[var(--border)] px-8 py-5 flex items-center justify-between">
|
||
<div>
|
||
<h1 onclick="resetToHome()"
|
||
class="text-2xl font-bold flex items-center gap-3 cursor-pointer hover:text-[var(--accent)] transition-colors">
|
||
<i class="fa-solid fa-robot text-[var(--accent)]"></i>
|
||
Autotest
|
||
</h1>
|
||
<p class="text-[#a0a0b0] text-xs mt-0.5">Test Case Manager & Planning Tool</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<div id="status" class="text-xs px-3 py-1 rounded bg-[var(--card)] text-[var(--accent-warm)] font-medium">
|
||
<span id="count">0</span> test cases loaded
|
||
</div>
|
||
|
||
<button onclick="saveAll()"
|
||
class="h-10 px-4 nav-button rounded-lg font-semibold flex items-center gap-2 text-sm">
|
||
<i class="fa-solid fa-save"></i>
|
||
<span>Save</span>
|
||
</button>
|
||
|
||
<!-- Unified Plans button (Create + Load) -->
|
||
<button onclick="openPlansModal()"
|
||
class="h-10 px-4 nav-button rounded-lg font-semibold flex items-center gap-2 text-sm">
|
||
<i class="fa-solid fa-list-check"></i>
|
||
<span>Plans</span>
|
||
</button>
|
||
|
||
<button onclick="reloadData()"
|
||
class="h-10 px-4 bg-[var(--card)] hover:bg-[var(--border)] rounded-lg text-sm flex items-center gap-2">
|
||
<i class="fa-solid fa-sync"></i>
|
||
<span>Reload</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<!-- Groups Overview (high-level bird's eye view) -->
|
||
<div class="px-8 pt-2">
|
||
<div class="flex items-center justify-between mb-2 px-1">
|
||
<div class="text-sm font-semibold text-[#a0a0b0]">Groups Overview</div>
|
||
<button onclick="resetModuleView()" class="text-xs text-[#a0a0b0] hover:text-[#edecee]">Reset view</button>
|
||
</div>
|
||
<div id="groupsOverview" class="min-h-[120px]"></div>
|
||
</div>
|
||
|
||
<!-- Table -->
|
||
<div class="px-8 pt-4 pb-12">
|
||
<!-- Selection Toolbar -->
|
||
<div class="flex items-center gap-2 mb-2 px-1 text-xs">
|
||
<span class="text-[#64748b]">Selection:</span>
|
||
<button onclick="selectOnlyManualAndPartial()" class="px-2 py-1 bg-[#27263a] hover:bg-[#3f2f1f] rounded text-[#ffca85]">Manual + Partial</button>
|
||
<button onclick="selectAllCore()" class="px-2 py-1 bg-[#27263a] hover:bg-[#2f2a3f] rounded">All Core</button>
|
||
<button onclick="selectAllPbx()" class="px-2 py-1 bg-[#27263a] hover:bg-[#2f2a3f] rounded">All PBX</button>
|
||
<button onclick="clearSelection()" class="px-2 py-1 bg-[#27263a] hover:bg-[#3f1f28] rounded text-[#ff6767]">Clear</button>
|
||
<button onclick="selectOnlyFullyAutomated()" class="px-2 py-1 bg-[#27263a] hover:bg-[#3f2f1f] rounded text-[#ffca85]">Only Fully Automated</button>
|
||
<span class="ml-3 text-amber-300 font-medium" id="selectionCount"></span>
|
||
</div>
|
||
|
||
<div class="card rounded-xl overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="bg-[var(--input)] text-[var(--text-secondary)] text-xs">
|
||
<th class="w-8 py-3 px-2"></th>
|
||
<th class="w-10 py-3 px-2 text-left">#</th>
|
||
<th class="py-3 px-3 text-left">Test ID</th>
|
||
<th class="w-16 py-3 px-2 text-left">Module</th>
|
||
<th class="w-16 py-3 px-2 text-left">Action</th>
|
||
<th class="py-3 px-3 text-left">Description</th>
|
||
<th class="w-24 py-3 px-2 text-left">Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tableBody" class="divide-y divide-[#334155]"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="noResults" class="hidden text-center py-8 text-[#64748b]">No tests match the current filters.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- EDIT MODAL -->
|
||
<div id="editModal" class="hidden fixed inset-0 bg-black/70 flex items-center justify-center z-50" onclick="if (event.target.id === 'editModal') closeModal()">
|
||
<div class="modal bg-[var(--card)] border border-[var(--border)] rounded-2xl w-full max-w-4xl max-h-[92vh] flex flex-col mx-4" onclick="event.stopImmediatePropagation()">
|
||
<!-- Modal Header -->
|
||
<div class="px-6 py-4 border-b border-[var(--border)] flex items-center justify-between bg-[var(--input)] rounded-t-2xl">
|
||
<div>
|
||
<div id="modalId" class="font-mono text-sm text-[#94a3b8]"></div>
|
||
<div id="modalGroup" class="text-xs text-[#64748b]"></div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<select id="modalStatus" class="bg-[var(--input)] border border-[var(--border)] text-sm rounded px-3 py-1">
|
||
<option value="automated">Automated</option>
|
||
<option value="partial">Partial</option>
|
||
<option value="manual">Manual</option>
|
||
</select>
|
||
<button onclick="resetToSuggested()"
|
||
class="text-xs px-3 py-1 bg-[var(--input)] text-[var(--accent)] border border-[var(--accent)] hover:bg-[#2f2a3f] rounded flex items-center gap-1 whitespace-nowrap">
|
||
<i class="fa-solid fa-magic"></i>
|
||
<span>Reset to suggested</span>
|
||
</button>
|
||
<button onclick="closeModal()" class="text-[#64748b] hover:text-white px-2 text-xl">×</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-6 overflow-auto flex-1 space-y-6 text-sm">
|
||
<!-- Description -->
|
||
<div>
|
||
<div class="section-title mb-1.5">Description</div>
|
||
<textarea id="modalDescription" rows="2"
|
||
class="w-full bg-[var(--input)] border border-[var(--border)] rounded-lg p-3 text-sm"></textarea>
|
||
</div>
|
||
|
||
<!-- Steps -->
|
||
<div>
|
||
<div class="flex justify-between items-center mb-1.5">
|
||
<div class="section-title">Test Steps</div>
|
||
<button onclick="addRichStep()" class="text-xs px-2.5 py-0.5 bg-[var(--border)] hover:bg-[var(--border-light)] rounded">+ Add step</button>
|
||
</div>
|
||
<ul id="listSteps" class="editable-list text-sm"></ul>
|
||
</div>
|
||
|
||
<!-- Automatable -->
|
||
<div>
|
||
<div class="flex justify-between items-center mb-1.5">
|
||
<div class="section-title text-emerald-400">Automatable</div>
|
||
<button onclick="addListItem('automatable')" class="text-xs px-2.5 py-0.5 bg-emerald-900/60 hover:bg-emerald-800 rounded">+ Add</button>
|
||
</div>
|
||
<ul id="listAutomatable" class="editable-list text-sm"></ul>
|
||
</div>
|
||
|
||
<!-- Not Yet -->
|
||
<div>
|
||
<div class="flex justify-between items-center mb-1.5">
|
||
<div class="section-title text-red-400">Not Automatable Yet / Manual</div>
|
||
<button onclick="addListItem('notYet')" class="text-xs px-2.5 py-0.5 bg-red-900/60 hover:bg-red-800 rounded">+ Add</button>
|
||
</div>
|
||
<ul id="listNotYet" class="editable-list text-sm"></ul>
|
||
</div>
|
||
|
||
<!-- Code Generation (for automated tests) -->
|
||
<div>
|
||
<div class="section-title mb-1.5 text-[#a277ff]">Code Generation (Automated)</div>
|
||
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<input type="checkbox" id="fullyAutomated" class="w-4 h-4">
|
||
<label for="fullyAutomated" class="text-sm">Mark as <strong>fullyAutomated</strong> (ready for code generation)</label>
|
||
</div>
|
||
|
||
<div class="mb-2">
|
||
<div class="text-xs text-[#64748b] mb-1">Implementation Notes (for generator / LLM)</div>
|
||
<textarea id="implementationNotes" rows="2"
|
||
class="w-full bg-[var(--input)] border border-[var(--border)] rounded p-2 text-sm"
|
||
placeholder="Hints for the code generator..."></textarea>
|
||
</div>
|
||
|
||
<!-- codeSteps editor -->
|
||
<div>
|
||
<div class="flex justify-between items-center mb-1">
|
||
<div class="text-xs text-[#64748b]">Code Steps (for generator)</div>
|
||
<button onclick="addCodeStep()" class="text-xs px-2 py-0.5 bg-[var(--border)] hover:bg-[var(--border-light)] rounded text-[var(--accent-warm)]">+ Add Step</button>
|
||
</div>
|
||
<div id="codeStepsContainer" class="space-y-2 text-sm"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dependencies -->
|
||
<div>
|
||
<div class="flex justify-between items-center mb-1.5">
|
||
<div class="section-title">Dependencies & Prerequisites</div>
|
||
<button onclick="addDependency()" class="text-xs px-2.5 py-0.5 bg-[var(--border)] hover:bg-[var(--border-light)] rounded">+ Add dependency</button>
|
||
</div>
|
||
<div id="depContainer" class="space-y-1"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="px-6 py-4 border-t border-[var(--border)] bg-[var(--input)] rounded-b-2xl flex justify-end gap-3">
|
||
<button onclick="closeModal()" class="px-5 py-2 text-sm rounded-lg bg-[var(--border)] hover:bg-[var(--border-light)]">Cancel</button>
|
||
<button onclick="saveCurrentTest()" class="px-6 py-2 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 font-semibold">Save Changes to Test</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let allTests = [];
|
||
let filteredTests = [];
|
||
let currentTest = null;
|
||
let currentIndex = -1;
|
||
let isDirty = false;
|
||
let selectedIds = new Set(); // For Generate Test Plan selection
|
||
|
||
async function loadData() {
|
||
try {
|
||
const res = await fetch('/api/testcases');
|
||
allTests = await res.json();
|
||
renderTable();
|
||
renderGroupsOverview();
|
||
updateSelectionCount();
|
||
document.getElementById('count').textContent = allTests.length;
|
||
} catch (e) {
|
||
alert('Could not load test cases. Make sure the server is running and data file exists.');
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
let currentModuleView = null; // null | 'core' | 'pbx'
|
||
let currentGroupView = null; // null | groupName (only valid when currentModuleView is set)
|
||
|
||
function renderGroupsOverview() {
|
||
const container = document.getElementById('groupsOverview');
|
||
if (!container) return;
|
||
|
||
const byModule = { core: {}, pbx: {} };
|
||
|
||
allTests.forEach(t => {
|
||
const mod = t.module || 'unknown';
|
||
const grp = t.group || 'unknown';
|
||
if (!byModule[mod]) byModule[mod] = {};
|
||
if (!byModule[mod][grp]) {
|
||
byModule[mod][grp] = { total: 0, automated: 0, partial: 0, manual: 0 };
|
||
}
|
||
const g = byModule[mod][grp];
|
||
g.total++;
|
||
const status = t.status || 'manual';
|
||
if (status === 'automated') g.automated++;
|
||
else if (status === 'partial') g.partial++;
|
||
else g.manual++;
|
||
});
|
||
|
||
let html = '';
|
||
|
||
if (!currentModuleView) {
|
||
// Level 1: Choose module (bird's eye)
|
||
html += `
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
${['core', 'pbx'].map(mod => {
|
||
const groups = byModule[mod] || {};
|
||
const totalTests = Object.values(groups).reduce((sum, g) => sum + g.total, 0);
|
||
const totalAutomated = Object.values(groups).reduce((sum, g) => sum + g.automated, 0);
|
||
const autoPct = totalTests > 0 ? Math.round((totalAutomated / totalTests) * 100) : 0;
|
||
|
||
const isActiveModule = currentModuleView === mod;
|
||
return `
|
||
<div onclick="selectModule('${mod}')"
|
||
class="module-card ${isActiveModule ? 'active' : ''} rounded-2xl p-6 cursor-pointer transition-all hover:scale-[1.01]">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<div class="text-2xl font-bold">${mod.toUpperCase()}</div>
|
||
<div class="text-[#64748b] mt-1">${Object.keys(groups).length} groups • ${totalTests} test cases</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="text-3xl font-semibold" style="color:#4ade80">${autoPct}<span class="text-base align-super">%</span></div>
|
||
<div class="text-xs text-[#64748b]">automated</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
} else {
|
||
// Level 2: Show groups for the selected module
|
||
const mod = currentModuleView;
|
||
const groups = byModule[mod] || {};
|
||
const groupNames = Object.keys(groups).sort();
|
||
|
||
html += `
|
||
<div class="mb-2 flex items-center gap-2 text-sm">
|
||
<button onclick="resetModuleView()" class="text-[var(--accent)] hover:underline">← Back to modules</button>
|
||
<span class="text-[var(--text-muted)]">/</span>
|
||
<span class="font-semibold text-[var(--accent)]">${mod.toUpperCase()}</span>
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||
`;
|
||
|
||
groupNames.forEach(grp => {
|
||
const stats = groups[grp];
|
||
const autoPct = stats.total > 0 ? Math.round((stats.automated / stats.total) * 100) : 0;
|
||
const isActiveGroup = currentGroupView === grp;
|
||
|
||
html += `
|
||
<div onclick="filterByGroupAndModule('${mod}', '${grp}')"
|
||
class="group-card ${isActiveGroup ? 'active' : ''} rounded-xl p-3 cursor-pointer transition-colors">
|
||
<div class="flex justify-between items-start">
|
||
<div>
|
||
<div class="font-semibold text-sm">${grp}</div>
|
||
<div class="text-[11px] text-[var(--text-muted)] mt-0.5">${stats.total} tests</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="text-xs font-mono" style="color:#4ade80">${stats.automated}</div>
|
||
<div class="text-xs font-mono" style="color:#fbbf24">${stats.partial}</div>
|
||
<div class="text-xs font-mono" style="color:#f87171">${stats.manual}</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 h-1.5 bg-[var(--input)] rounded-full overflow-hidden">
|
||
<div class="h-1.5 bg-emerald-500" style="width:${autoPct}%"></div>
|
||
</div>
|
||
<div class="text-[10px] text-right mt-0.5 text-[var(--text-muted)]">${autoPct}% automated</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += `</div>`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function selectModule(mod) {
|
||
currentModuleView = mod;
|
||
currentGroupView = null; // mostrar todos los del módulo
|
||
renderGroupsOverview();
|
||
renderTable();
|
||
}
|
||
|
||
function resetModuleView() {
|
||
currentModuleView = null;
|
||
currentGroupView = null;
|
||
renderGroupsOverview();
|
||
renderTable();
|
||
}
|
||
|
||
function resetToHome() {
|
||
currentModuleView = null;
|
||
currentGroupView = null;
|
||
renderGroupsOverview();
|
||
renderTable();
|
||
// Scroll to top
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
function filterByGroupAndModule(module, groupName) {
|
||
const wasAlreadySelected = (currentModuleView === module && currentGroupView === groupName);
|
||
|
||
if (wasAlreadySelected) {
|
||
// Deselecting the group → show all of the parent module, no scroll
|
||
currentGroupView = null;
|
||
} else {
|
||
currentModuleView = module;
|
||
currentGroupView = groupName;
|
||
}
|
||
|
||
renderGroupsOverview();
|
||
renderTable();
|
||
|
||
// Only scroll when selecting a new group (not when deselecting)
|
||
if (!wasAlreadySelected) {
|
||
document.querySelector('#tableBody')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
function getFiltered() {
|
||
return allTests.filter(t => {
|
||
if (currentModuleView && t.module !== currentModuleView) return false;
|
||
if (currentGroupView && t.group !== currentGroupView) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function renderTable() {
|
||
filteredTests = getFiltered();
|
||
const tbody = document.getElementById('tableBody');
|
||
const noRes = document.getElementById('noResults');
|
||
|
||
if (filteredTests.length === 0) {
|
||
tbody.innerHTML = '';
|
||
noRes.classList.remove('hidden');
|
||
return;
|
||
}
|
||
noRes.classList.add('hidden');
|
||
|
||
// Group by 'group' field (e.g. core-repository, pbx-calls, etc.)
|
||
const grouped = {};
|
||
filteredTests.forEach(t => {
|
||
if (!grouped[t.group]) grouped[t.group] = [];
|
||
grouped[t.group].push(t);
|
||
});
|
||
|
||
const groupNames = Object.keys(grouped).sort();
|
||
|
||
let html = '';
|
||
|
||
groupNames.forEach(groupName => {
|
||
const testsInGroup = grouped[groupName];
|
||
const allSelectedInGroup = testsInGroup.every(t => selectedIds.has(t.id));
|
||
|
||
// Group header
|
||
html += `
|
||
<tr class="bg-[var(--input)]">
|
||
<td colspan="7" style="padding: 5px 10px; border-bottom: 1px solid #334155;">
|
||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; color: #a277ff; font-weight: 600; font-size: 13px;">
|
||
<input type="checkbox" ${allSelectedInGroup ? 'checked' : ''}
|
||
onclick="event.stopImmediatePropagation(); toggleGroupSelection('${groupName}', this.checked)">
|
||
<span>${groupName}</span>
|
||
<span style="font-size: 11px; color: #64748b; font-weight: normal;">(${testsInGroup.length} tests)</span>
|
||
</label>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
|
||
testsInGroup.forEach((t, idx) => {
|
||
const statusClass = `status-${t.status}`;
|
||
const shortId = t.id.length > 52 ? t.id.slice(0, 49) + '…' : t.id;
|
||
const isChecked = selectedIds.has(t.id) ? 'checked' : '';
|
||
|
||
html += `
|
||
<tr class="row-hover">
|
||
<td class="py-1.5 px-2">
|
||
<input type="checkbox" ${isChecked}
|
||
onclick="event.stopImmediatePropagation(); toggleSelection('${t.id}', this.checked)">
|
||
</td>
|
||
<td class="py-1.5 px-2 text-[#64748b] cursor-pointer text-xs" onclick="openEditor(${allTests.indexOf(t)})">${idx + 1}</td>
|
||
<td class="py-1.5 px-2 font-mono text-xs text-[#94a3b8] cursor-pointer" onclick="openEditor(${allTests.indexOf(t)})">${shortId}</td>
|
||
<td class="py-1.5 px-2 cursor-pointer" onclick="openEditor(${allTests.indexOf(t)})">
|
||
<span class="px-2 py-0.5 text-xs rounded ${t.module === 'core' ? 'bg-blue-900 text-blue-300' : 'bg-violet-900 text-violet-300'}">${t.module}</span>
|
||
</td>
|
||
<td class="py-1.5 px-2 text-[#94a3b8] cursor-pointer text-xs" onclick="openEditor(${allTests.indexOf(t)})">${t.action}</td>
|
||
<td class="py-1.5 px-2 text-[#e2e8f0] cursor-pointer text-sm" onclick="openEditor(${allTests.indexOf(t)})">${t.description}</td>
|
||
<td class="py-1.5 px-2 cursor-pointer" onclick="openEditor(${allTests.indexOf(t)})">
|
||
<span class="px-2.5 py-0.5 text-xs font-semibold rounded-full ${statusClass}">${t.status}</span>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
});
|
||
|
||
tbody.innerHTML = html;
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function resetFilters() {
|
||
currentModuleView = null;
|
||
currentGroupView = null;
|
||
renderGroupsOverview();
|
||
renderTable();
|
||
}
|
||
|
||
function toggleSelection(id, checked) {
|
||
if (checked) {
|
||
selectedIds.add(id);
|
||
} else {
|
||
selectedIds.delete(id);
|
||
}
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function toggleGroupSelection(groupName, checked) {
|
||
const testsInGroup = allTests.filter(t => t.group === groupName);
|
||
testsInGroup.forEach(t => {
|
||
if (checked) {
|
||
selectedIds.add(t.id);
|
||
} else {
|
||
selectedIds.delete(t.id);
|
||
}
|
||
});
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function toggleGroupSelection(groupName, checked) {
|
||
const testsInGroup = allTests.filter(t => t.group === groupName);
|
||
|
||
testsInGroup.forEach(t => {
|
||
if (checked) {
|
||
selectedIds.add(t.id);
|
||
} else {
|
||
selectedIds.delete(t.id);
|
||
}
|
||
});
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
// Bulk selection helpers
|
||
function selectOnlyManualAndPartial() {
|
||
selectedIds.clear();
|
||
allTests.forEach(t => {
|
||
if (t.status === 'manual' || t.status === 'partial') selectedIds.add(t.id);
|
||
});
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function selectAllCore() {
|
||
selectedIds.clear();
|
||
allTests.forEach(t => {
|
||
if (t.module === 'core') selectedIds.add(t.id);
|
||
});
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function selectAllPbx() {
|
||
selectedIds.clear();
|
||
allTests.forEach(t => {
|
||
if (t.module === 'pbx') selectedIds.add(t.id);
|
||
});
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function selectOnlyFullyAutomated() {
|
||
selectedIds.clear();
|
||
allTests.forEach(t => {
|
||
if (t.fullyAutomated === true) selectedIds.add(t.id);
|
||
});
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedIds.clear();
|
||
renderTable();
|
||
updateSelectionCount();
|
||
}
|
||
|
||
function updateSelectionCount() {
|
||
const el = document.getElementById('selectionCount');
|
||
if (el) {
|
||
el.textContent = selectedIds.size > 0 ? `${selectedIds.size} selected` : '';
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// Unified Plans modal (Create + Load)
|
||
async function openPlansModal() {
|
||
const modal = document.createElement('div');
|
||
modal.id = 'plansModal';
|
||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9999';
|
||
|
||
const hasSelection = selectedIds.size > 0;
|
||
|
||
modal.innerHTML = `
|
||
<div style="background:#1e2937; border:1px solid #334155; border-radius:12px; width:90%; max-width:620px; padding:24px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||
<h3 style="margin:0; color:var(--accent); font-size:18px;">Test Plans</h3>
|
||
<button onclick="closePlansModal()" style="background:none; border:none; color:#64748b; font-size:22px; cursor:pointer; line-height:1;">×</button>
|
||
</div>
|
||
|
||
<!-- Create new plan -->
|
||
<div style="margin-bottom:24px;">
|
||
<div style="font-size:13px; color:#94a3b8; margin-bottom:6px;">Create new plan from selection</div>
|
||
<div style="display:flex; gap:8px;">
|
||
<input id="newPlanName" type="text" value="Manual Test Plan"
|
||
style="flex:1; background:#0f172a; border:1px solid #334155; color:#e2e8f0; padding:8px 12px; border-radius:6px;"
|
||
${!hasSelection ? 'disabled' : ''}>
|
||
<button onclick="createPlanFromModal()"
|
||
style="padding:8px 16px; background:var(--accent); color:var(--bg); border:none; border-radius:6px; font-weight:600; cursor:pointer;"
|
||
${!hasSelection ? 'disabled style="opacity:0.5; cursor:not-allowed;"' : ''}>
|
||
Create
|
||
</button>
|
||
</div>
|
||
${!hasSelection ? '<div style="font-size:12px; color:#f87171; margin-top:4px;">Select one or more test cases first</div>' : ''}
|
||
</div>
|
||
|
||
<!-- Load existing plans -->
|
||
<div>
|
||
<div style="font-size:13px; color:#94a3b8; margin-bottom:8px;">Load existing plan</div>
|
||
<div id="plansList" style="max-height:320px; overflow:auto; border:1px solid #334155; border-radius:8px;">
|
||
<div style="padding:16px; color:#64748b; text-align:center;">Loading plans...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// Load existing plans
|
||
try {
|
||
const res = await fetch('/api/test-plans');
|
||
const plans = await res.json();
|
||
const container = document.getElementById('plansList');
|
||
|
||
if (!plans || plans.length === 0) {
|
||
container.innerHTML = `<div style="padding:16px; color:#64748b; text-align:center;">No saved plans yet.</div>`;
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
plans.forEach(p => {
|
||
const date = new Date(p.updatedAt || p.createdAt).toLocaleString();
|
||
html += `
|
||
<div onclick="openPlan('${p.id}'); closePlansModal();"
|
||
style="padding:12px 16px; border-bottom:1px solid #334155; cursor:pointer; display:flex; justify-content:space-between; align-items:center;">
|
||
<div>
|
||
<strong>${p.name}</strong><br>
|
||
<small style="color:#64748b">${p.testCount} tests • ${date}</small>
|
||
</div>
|
||
<div style="display:flex; align-items:center; gap:8px;">
|
||
<div style="color:var(--accent); font-size:13px;">Open →</div>
|
||
<button onclick="event.stopImmediatePropagation(); deletePlan('${p.id}', this);"
|
||
style="background:none; border:none; color:#f87171; cursor:pointer; padding:4px 6px; font-size:14px;"
|
||
title="Delete plan">
|
||
<i class="fa-solid fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
container.innerHTML = html;
|
||
|
||
} catch (e) {
|
||
document.getElementById('plansList').innerHTML =
|
||
`<div style="padding:16px; color:#f87171;">Error loading plans.</div>`;
|
||
}
|
||
}
|
||
|
||
function closePlansModal() {
|
||
const m = document.getElementById('plansModal');
|
||
if (m) m.remove();
|
||
}
|
||
|
||
async function deletePlan(planId, buttonElement) {
|
||
if (!confirm('Are you sure you want to delete this plan? This cannot be undone.')) {
|
||
return;
|
||
}
|
||
|
||
const row = buttonElement.closest('div[onclick]');
|
||
if (row) row.style.opacity = '0.5';
|
||
|
||
try {
|
||
const res = await fetch(`/api/test-plans/${planId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
// Remove the row from the list
|
||
if (row) row.remove();
|
||
} else {
|
||
alert('Failed to delete plan: ' + (data.error || 'Unknown error'));
|
||
if (row) row.style.opacity = '1';
|
||
}
|
||
} catch (e) {
|
||
alert('Error deleting plan: ' + e.message);
|
||
if (row) row.style.opacity = '1';
|
||
}
|
||
}
|
||
|
||
async function createPlanFromModal() {
|
||
const nameInput = document.getElementById('newPlanName');
|
||
const name = nameInput ? nameInput.value.trim() : 'Manual Test Plan';
|
||
if (!name) return;
|
||
|
||
const ids = Array.from(selectedIds);
|
||
|
||
try {
|
||
const res = await fetch('/api/test-plans', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ids, name })
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success && data.planId) {
|
||
closePlansModal();
|
||
window.open(`/tester-plan.html?plan=${data.planId}`, '_blank');
|
||
alert(`✅ Test Plan created!\n\nOpened in a new tab.`);
|
||
} else {
|
||
alert('Error creating plan: ' + (data.error || 'Unknown error'));
|
||
}
|
||
} catch (e) {
|
||
alert('Failed to create Test Plan: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function openPlan(planId) {
|
||
window.open(`/tester-plan.html?plan=${planId}`, '_blank');
|
||
closePlansModal();
|
||
}
|
||
|
||
function openEditor(globalIndex) {
|
||
currentIndex = globalIndex;
|
||
currentTest = JSON.parse(JSON.stringify(allTests[globalIndex])); // deep clone
|
||
|
||
document.getElementById('modalId').textContent = currentTest.id;
|
||
document.getElementById('modalGroup').textContent = `${currentTest.group} • ${currentTest.file}`;
|
||
document.getElementById('modalDescription').value = currentTest.description || '';
|
||
document.getElementById('modalStatus').value = currentTest.status;
|
||
|
||
// Code Generation fields (Phase 1)
|
||
const fullyAutomatedEl = document.getElementById('fullyAutomated');
|
||
fullyAutomatedEl.checked = !!currentTest.fullyAutomated;
|
||
|
||
const notesEl = document.getElementById('implementationNotes');
|
||
notesEl.value = currentTest.implementationNotes || '';
|
||
|
||
// Code Steps
|
||
renderCodeSteps(currentTest.codeSteps || []);
|
||
|
||
// Render lists
|
||
renderRichSteps(currentTest.steps || []);
|
||
renderList('listAutomatable', currentTest.automatable || []);
|
||
renderList('listNotYet', currentTest.notYet || []);
|
||
renderDependencies(currentTest.dependencies || []);
|
||
|
||
document.getElementById('editModal').classList.remove('hidden');
|
||
document.getElementById('editModal').classList.add('flex');
|
||
}
|
||
|
||
function renderList(containerId, items) {
|
||
const ul = document.getElementById(containerId);
|
||
ul.innerHTML = items.map((item, idx) => `
|
||
<li>
|
||
<input value="${item.replace(/"/g, '"')}" oninput="updateListItem('${containerId}', ${idx}, this.value)">
|
||
<button onclick="removeListItem('${containerId}', ${idx})"><i class="fa-solid fa-trash text-xs"></i></button>
|
||
</li>
|
||
`).join('');
|
||
ul.dataset.items = JSON.stringify(items);
|
||
}
|
||
|
||
function updateListItem(containerId, idx, value) {
|
||
const ul = document.getElementById(containerId);
|
||
const items = JSON.parse(ul.dataset.items || '[]');
|
||
items[idx] = value;
|
||
ul.dataset.items = JSON.stringify(items);
|
||
}
|
||
|
||
function removeListItem(containerId, idx) {
|
||
const ul = document.getElementById(containerId);
|
||
const items = JSON.parse(ul.dataset.items || '[]');
|
||
items.splice(idx, 1);
|
||
renderList(containerId, items);
|
||
}
|
||
|
||
function addListItem(type) {
|
||
const map = { steps: 'listSteps', automatable: 'listAutomatable', notYet: 'listNotYet' };
|
||
const id = map[type];
|
||
const ul = document.getElementById(id);
|
||
const items = JSON.parse(ul.dataset.items || '[]');
|
||
items.push('');
|
||
renderList(id, items);
|
||
}
|
||
|
||
// === Simplified step editor (automation decision moved to testcase level) ===
|
||
function renderRichSteps(steps) {
|
||
const ul = document.getElementById('listSteps');
|
||
ul.innerHTML = '';
|
||
|
||
const rich = steps.map(s => {
|
||
if (typeof s === 'string') {
|
||
return { action: s, expected: '', notes: '' };
|
||
}
|
||
return {
|
||
action: s.action || s,
|
||
expected: s.expected || '',
|
||
notes: s.notes || ''
|
||
};
|
||
});
|
||
|
||
rich.forEach((step, idx) => {
|
||
const li = document.createElement('li');
|
||
li.style.flexDirection = 'column';
|
||
li.style.alignItems = 'stretch';
|
||
li.style.gap = '4px';
|
||
li.innerHTML = `
|
||
<div style="display:flex; gap:8px; align-items:center;">
|
||
<input value="${(step.action || '').replace(/"/g, '"')}" placeholder="Action" style="flex:1" oninput="updateRichStep(${idx}, 'action', this.value)">
|
||
<button onclick="removeRichStep(${idx})" style="color:#f87171;"><i class="fa-solid fa-trash text-xs"></i></button>
|
||
</div>
|
||
<input value="${(step.expected || '').replace(/"/g, '"')}" placeholder="Expected result (optional)" style="font-size:12px; margin-top:2px;" oninput="updateRichStep(${idx}, 'expected', this.value)">
|
||
<input value="${(step.notes || '').replace(/"/g, '"')}" placeholder="Notes (optional)" style="font-size:12px; margin-top:2px; color:#94a3b8;" oninput="updateRichStep(${idx}, 'notes', this.value)">
|
||
`;
|
||
ul.appendChild(li);
|
||
});
|
||
|
||
ul.dataset.richSteps = JSON.stringify(rich);
|
||
}
|
||
|
||
function updateRichStep(idx, field, value) {
|
||
const ul = document.getElementById('listSteps');
|
||
const steps = JSON.parse(ul.dataset.richSteps || '[]');
|
||
if (!steps[idx]) steps[idx] = {};
|
||
steps[idx][field] = value;
|
||
}
|
||
|
||
function removeRichStep(idx) {
|
||
const ul = document.getElementById('listSteps');
|
||
const steps = JSON.parse(ul.dataset.richSteps || '[]');
|
||
steps.splice(idx, 1);
|
||
renderRichSteps(steps);
|
||
}
|
||
|
||
function addRichStep() {
|
||
const ul = document.getElementById('listSteps');
|
||
const steps = JSON.parse(ul.dataset.richSteps || '[]');
|
||
steps.push({ action: '', expected: '', notes: '' });
|
||
renderRichSteps(steps);
|
||
}
|
||
|
||
function renderDependencies(deps) {
|
||
const container = document.getElementById('depContainer');
|
||
container.innerHTML = deps.map((d, i) => `
|
||
<div class="dep-row">
|
||
<input value="${d.label || ''}" placeholder="Label" style="width:160px" oninput="updateDep(${i}, 'label', this.value)">
|
||
<input value="${d.value || ''}" placeholder="Value / URL / Requirement" style="flex:1" oninput="updateDep(${i}, 'value', this.value)">
|
||
<button onclick="removeDep(${i})" class="px-2 text-red-400"><i class="fa-solid fa-trash"></i></button>
|
||
</div>
|
||
`).join('');
|
||
container.dataset.deps = JSON.stringify(deps);
|
||
}
|
||
|
||
function updateDep(idx, field, val) {
|
||
const container = document.getElementById('depContainer');
|
||
const deps = JSON.parse(container.dataset.deps || '[]');
|
||
deps[idx][field] = val;
|
||
container.dataset.deps = JSON.stringify(deps);
|
||
}
|
||
|
||
function removeDep(idx) {
|
||
const container = document.getElementById('depContainer');
|
||
const deps = JSON.parse(container.dataset.deps || '[]');
|
||
deps.splice(idx, 1);
|
||
renderDependencies(deps);
|
||
}
|
||
|
||
function addDependency() {
|
||
const container = document.getElementById('depContainer');
|
||
const deps = JSON.parse(container.dataset.deps || '[]');
|
||
deps.push({ label: '', value: '' });
|
||
renderDependencies(deps);
|
||
}
|
||
|
||
// === Code Steps Editor (for automated generation) ===
|
||
|
||
function renderCodeSteps(codeSteps = []) {
|
||
const container = document.getElementById('codeStepsContainer');
|
||
container.innerHTML = '';
|
||
|
||
codeSteps.forEach((step, index) => {
|
||
const div = document.createElement('div');
|
||
div.className = 'flex gap-2 items-center bg-[var(--input)] p-2 rounded';
|
||
const paramsValue = step.params ? JSON.stringify(step.params) : '';
|
||
div.innerHTML = `
|
||
<input placeholder="Step Name" value="${step.stepName || ''}" class="flex-1 text-xs" oninput="updateCodeStep(${index}, 'stepName', this.value)">
|
||
<input placeholder="Action Key" value="${step.actionKey || ''}" class="flex-1 text-xs" oninput="updateCodeStep(${index}, 'actionKey', this.value)">
|
||
<input placeholder='Params (JSON)' value='${paramsValue}' class="flex-1 text-xs" oninput="updateCodeStepParams(${index}, this.value)">
|
||
<button onclick="removeCodeStep(${index})" class="text-red-400 hover:text-red-500 px-1">×</button>
|
||
`;
|
||
container.appendChild(div);
|
||
});
|
||
|
||
container.dataset.codeSteps = JSON.stringify(codeSteps);
|
||
}
|
||
|
||
function updateCodeStep(index, field, value) {
|
||
const container = document.getElementById('codeStepsContainer');
|
||
const steps = JSON.parse(container.dataset.codeSteps || '[]');
|
||
if (!steps[index]) steps[index] = {};
|
||
steps[index][field] = value;
|
||
container.dataset.codeSteps = JSON.stringify(steps);
|
||
}
|
||
|
||
function updateCodeStepParams(index, value) {
|
||
const container = document.getElementById('codeStepsContainer');
|
||
const steps = JSON.parse(container.dataset.codeSteps || '[]');
|
||
if (!steps[index]) steps[index] = {};
|
||
try {
|
||
steps[index].params = value ? JSON.parse(value) : undefined;
|
||
} catch (e) {
|
||
// keep previous value if JSON is invalid
|
||
}
|
||
container.dataset.codeSteps = JSON.stringify(steps);
|
||
}
|
||
|
||
function removeCodeStep(index) {
|
||
const container = document.getElementById('codeStepsContainer');
|
||
const steps = JSON.parse(container.dataset.codeSteps || '[]');
|
||
steps.splice(index, 1);
|
||
renderCodeSteps(steps);
|
||
}
|
||
|
||
function addCodeStep() {
|
||
const container = document.getElementById('codeStepsContainer');
|
||
const steps = JSON.parse(container.dataset.codeSteps || '[]');
|
||
steps.push({ stepName: '', actionKey: '' });
|
||
renderCodeSteps(steps);
|
||
}
|
||
|
||
async function resetToSuggested() {
|
||
if (!currentTest) return;
|
||
|
||
// Build a minimal test object from current modal values
|
||
const tempTest = {
|
||
id: currentTest.id,
|
||
group: currentTest.group,
|
||
module: currentTest.module,
|
||
action: currentTest.action,
|
||
description: document.getElementById('modalDescription').value.trim(),
|
||
status: document.getElementById('modalStatus').value,
|
||
manualReason: currentTest.manualReason,
|
||
partialNotes: currentTest.partialNotes
|
||
};
|
||
|
||
try {
|
||
const res = await fetch('/api/enrich', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(tempTest)
|
||
});
|
||
const rich = await res.json();
|
||
|
||
// Repopulate lists with suggested values
|
||
currentTest.steps = rich.steps || [];
|
||
currentTest.automatable = rich.automatable || [];
|
||
currentTest.notYet = rich.notYet || [];
|
||
currentTest.dependencies = rich.dependencies || [];
|
||
|
||
renderList('listSteps', currentTest.steps);
|
||
renderList('listAutomatable', currentTest.automatable);
|
||
renderList('listNotYet', currentTest.notYet);
|
||
renderDependencies(currentTest.dependencies);
|
||
|
||
} catch (e) {
|
||
alert('Could not fetch suggestions from server');
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
function saveCurrentTest() {
|
||
if (currentIndex < 0) return;
|
||
|
||
// Collect values
|
||
currentTest.description = document.getElementById('modalDescription').value.trim();
|
||
currentTest.status = document.getElementById('modalStatus').value;
|
||
|
||
// Code Generation fields (Phase 1)
|
||
const fullyAutomatedEl = document.getElementById('fullyAutomated');
|
||
currentTest.fullyAutomated = fullyAutomatedEl.checked;
|
||
|
||
const notesEl = document.getElementById('implementationNotes');
|
||
currentTest.implementationNotes = notesEl.value.trim() || undefined;
|
||
|
||
// Code Steps
|
||
const codeStepsContainer = document.getElementById('codeStepsContainer');
|
||
currentTest.codeSteps = JSON.parse(codeStepsContainer.dataset.codeSteps || '[]')
|
||
.filter(s => s.stepName || s.actionKey);
|
||
|
||
// Collect steps (now simplified: action + expected + optional notes)
|
||
const stepsUl = document.getElementById('listSteps');
|
||
const richFromUI = JSON.parse(stepsUl.dataset.richSteps || '[]');
|
||
currentTest.steps = richFromUI.length > 0 ? richFromUI : (JSON.parse(stepsUl.dataset.items || '[]').filter(Boolean));
|
||
|
||
const autoUl = document.getElementById('listAutomatable');
|
||
currentTest.automatable = JSON.parse(autoUl.dataset.items || '[]').filter(Boolean);
|
||
|
||
const notUl = document.getElementById('listNotYet');
|
||
currentTest.notYet = JSON.parse(notUl.dataset.items || '[]').filter(Boolean);
|
||
|
||
const depContainer = document.getElementById('depContainer');
|
||
currentTest.dependencies = JSON.parse(depContainer.dataset.deps || '[]')
|
||
.filter(d => d.label || d.value);
|
||
|
||
// Update master array
|
||
allTests[currentIndex] = currentTest;
|
||
isDirty = true;
|
||
|
||
closeModal();
|
||
renderTable();
|
||
|
||
document.getElementById('status').classList.add('dirty');
|
||
document.getElementById('status').innerHTML = '<span class="text-amber-400">Unsaved changes</span>';
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('editModal').classList.remove('flex');
|
||
document.getElementById('editModal').classList.add('hidden');
|
||
currentTest = null;
|
||
currentIndex = -1;
|
||
}
|
||
|
||
async function saveAll() {
|
||
const saveBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Save'));
|
||
const originalText = saveBtn ? saveBtn.innerHTML : '';
|
||
|
||
if (saveBtn) {
|
||
saveBtn.disabled = true;
|
||
saveBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> <span>Saving...</span>`;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/testcases', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(allTests)
|
||
});
|
||
|
||
const json = await res.json();
|
||
|
||
if (json.success) {
|
||
isDirty = false;
|
||
document.getElementById('status').classList.remove('dirty');
|
||
document.getElementById('status').innerHTML = `<span id="count">${allTests.length}</span> test cases saved`;
|
||
|
||
// Show temporary success feedback
|
||
if (saveBtn) {
|
||
saveBtn.innerHTML = `<i class="fa-solid fa-check"></i> <span>Saved!</span>`;
|
||
saveBtn.style.borderColor = '#6cc76f';
|
||
saveBtn.style.color = '#6cc76f';
|
||
}
|
||
|
||
setTimeout(() => {
|
||
if (saveBtn) {
|
||
saveBtn.innerHTML = originalText;
|
||
saveBtn.style.borderColor = '';
|
||
saveBtn.style.color = '';
|
||
saveBtn.disabled = false;
|
||
}
|
||
}, 1800);
|
||
} else {
|
||
throw new Error(json.error || 'Unknown error');
|
||
}
|
||
} catch (e) {
|
||
if (saveBtn) {
|
||
saveBtn.innerHTML = `<i class="fa-solid fa-exclamation-triangle"></i> <span>Error</span>`;
|
||
saveBtn.style.borderColor = '#ff6767';
|
||
saveBtn.style.color = '#ff6767';
|
||
}
|
||
alert('Error saving data: ' + e.message);
|
||
|
||
setTimeout(() => {
|
||
if (saveBtn) {
|
||
saveBtn.innerHTML = originalText;
|
||
saveBtn.style.borderColor = '';
|
||
saveBtn.style.color = '';
|
||
saveBtn.disabled = false;
|
||
}
|
||
}, 2000);
|
||
}
|
||
}
|
||
|
||
async function reloadData() {
|
||
if (isDirty && !confirm('You have unsaved changes. Reload anyway?')) return;
|
||
await loadData();
|
||
isDirty = false;
|
||
document.getElementById('status').classList.remove('dirty');
|
||
document.getElementById('status').innerHTML = `<span id="count">${allTests.length}</span> test cases loaded`;
|
||
}
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
const modal = document.getElementById('editModal');
|
||
if (!modal.classList.contains('hidden')) closeModal();
|
||
}
|
||
if (e.metaKey && e.key === 's') {
|
||
e.preventDefault();
|
||
saveAll();
|
||
}
|
||
});
|
||
|
||
// Init
|
||
window.onload = () => {
|
||
loadData();
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|