skopix 2.0.72 → 2.0.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/dashboard.js +82 -0
- package/package.json +1 -1
- package/web/app/index.html +356 -29
|
@@ -1870,6 +1870,69 @@ export async function dashboardCommand(options) {
|
|
|
1870
1870
|
return;
|
|
1871
1871
|
}
|
|
1872
1872
|
|
|
1873
|
+
// ─── FOLDERS ───────────────────────────────────────────────────────
|
|
1874
|
+
if (pathname === '/api/folders' && method === 'GET') {
|
|
1875
|
+
sendJSON(res, 200, await getFolders());
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
if (pathname === '/api/folders' && method === 'POST') {
|
|
1879
|
+
const { name, type } = JSON.parse(await readBody(req));
|
|
1880
|
+
const folders = await getFolders();
|
|
1881
|
+
if (!folders.find(f => f.name === name && f.type === type)) {
|
|
1882
|
+
folders.push({ name, type, createdAt: new Date().toISOString() });
|
|
1883
|
+
await saveFolders(folders);
|
|
1884
|
+
}
|
|
1885
|
+
sendJSON(res, 200, folders);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
if (pathname === '/api/folders' && method === 'DELETE') {
|
|
1889
|
+
const { name, type } = JSON.parse(await readBody(req));
|
|
1890
|
+
const folders = await getFolders();
|
|
1891
|
+
await saveFolders(folders.filter(f => !(f.name === name && f.type === type)));
|
|
1892
|
+
sendJSON(res, 200, { deleted: true });
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
if (pathname === '/api/folders/rename' && method === 'POST') {
|
|
1896
|
+
const { oldName, newName, type } = JSON.parse(await readBody(req));
|
|
1897
|
+
const folders = await getFolders();
|
|
1898
|
+
const idx = folders.findIndex(f => f.name === oldName && f.type === type);
|
|
1899
|
+
if (idx !== -1) folders[idx].name = newName;
|
|
1900
|
+
await saveFolders(folders);
|
|
1901
|
+
// Update all items that used the old folder name
|
|
1902
|
+
if (type === 'library') {
|
|
1903
|
+
const steps = await listLibrarySteps(suitesDir);
|
|
1904
|
+
steps.forEach(s => { if (s.folder === oldName) s.folder = newName; });
|
|
1905
|
+
await saveLibrarySteps(steps);
|
|
1906
|
+
} else if (type === 'tests') {
|
|
1907
|
+
const allTests = await listAllTests(suitesDir);
|
|
1908
|
+
for (const test of allTests) {
|
|
1909
|
+
if (test.folder === oldName) await updateTest(suitesDir, test.scope, test.id, { ...test, folder: newName });
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
sendJSON(res, 200, { renamed: true });
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
if (pathname === '/api/folders/move-items' && method === 'POST') {
|
|
1916
|
+
const { ids, folder, type, fromFolder } = JSON.parse(await readBody(req));
|
|
1917
|
+
if (type === 'library') {
|
|
1918
|
+
const steps = await listLibrarySteps(suitesDir);
|
|
1919
|
+
steps.forEach(s => {
|
|
1920
|
+
if (ids === '__all_in_folder__' ? s.folder === fromFolder : ids.includes(s.id)) {
|
|
1921
|
+
s.folder = folder || null;
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
await saveLibrarySteps(steps);
|
|
1925
|
+
} else if (type === 'tests') {
|
|
1926
|
+
const allTests = await listAllTests(suitesDir);
|
|
1927
|
+
for (const test of allTests) {
|
|
1928
|
+
const match = ids === '__all_in_folder__' ? test.folder === fromFolder : ids.includes(test.id);
|
|
1929
|
+
if (match) await updateTest(suitesDir, test.scope, test.id, { ...test, folder: folder || null });
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
sendJSON(res, 200, { moved: ids === '__all_in_folder__' ? -1 : ids.length });
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1873
1936
|
// ─── STEP LIBRARY ──────────────────────────────────────────────────
|
|
1874
1937
|
if (pathname.match(/^\/api\/step-library\/[^/]+\/usages$/) && method === 'GET') {
|
|
1875
1938
|
try {
|
|
@@ -3786,6 +3849,24 @@ async function syncIssuesStatus() {
|
|
|
3786
3849
|
// STEP LIBRARY — persistent store of reusable UI interactions
|
|
3787
3850
|
// ═══════════════════════════════════════════════════════════════
|
|
3788
3851
|
|
|
3852
|
+
const FOLDERS_FILE = () => path.join(os.homedir(), '.skopix', 'folders.yaml');
|
|
3853
|
+
|
|
3854
|
+
async function getFolders() {
|
|
3855
|
+
const file = FOLDERS_FILE();
|
|
3856
|
+
if (!await fs.pathExists(file)) return [];
|
|
3857
|
+
try {
|
|
3858
|
+
const content = await fs.readFile(file, 'utf8');
|
|
3859
|
+
const data = yaml.parse(content);
|
|
3860
|
+
return Array.isArray(data) ? data : [];
|
|
3861
|
+
} catch { return []; }
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3864
|
+
async function saveFolders(folders) {
|
|
3865
|
+
const file = FOLDERS_FILE();
|
|
3866
|
+
await fs.ensureDir(path.dirname(file));
|
|
3867
|
+
await fs.writeFile(file, yaml.stringify(folders));
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3789
3870
|
const SAVED_URLS_FILE = () => path.join(os.homedir(), '.skopix', 'saved-urls.yaml');
|
|
3790
3871
|
|
|
3791
3872
|
async function getSavedUrls() {
|
|
@@ -3803,6 +3884,7 @@ async function saveSavedUrls(urls) {
|
|
|
3803
3884
|
await fs.ensureDir(path.dirname(file));
|
|
3804
3885
|
await fs.writeFile(file, yaml.stringify(urls));
|
|
3805
3886
|
}
|
|
3887
|
+
const LIBRARY_FILE = () => path.join(os.homedir(), '.skopix', 'step-library.yaml');
|
|
3806
3888
|
const LIBRARY_PENDING_FILE = () => path.join(os.homedir(), '.skopix', 'step-library-pending.yaml');
|
|
3807
3889
|
|
|
3808
3890
|
async function listPendingSteps() {
|
package/package.json
CHANGED
package/web/app/index.html
CHANGED
|
@@ -1531,22 +1531,36 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
|
|
|
1531
1531
|
</div>
|
|
1532
1532
|
</div>
|
|
1533
1533
|
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1534
|
+
<!-- Two-panel layout: folder tree + tests -->
|
|
1535
|
+
<div style="display:grid;grid-template-columns:220px 1fr;gap:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--surface)">
|
|
1536
|
+
|
|
1537
|
+
<!-- LEFT: Folder tree -->
|
|
1538
|
+
<div style="border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--surface2)">
|
|
1539
|
+
<div style="padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
|
|
1540
|
+
<span style="font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em">FOLDERS</span>
|
|
1541
|
+
<button class="btn btn-ghost" style="padding:2px 6px;font-size:14px;line-height:1" onclick="openCreateFolder('tests')" title="New folder">+</button>
|
|
1542
|
+
</div>
|
|
1543
|
+
<div id="tests-folder-tree" style="overflow-y:auto;flex:1;padding:6px 0"></div>
|
|
1544
|
+
</div>
|
|
1545
|
+
|
|
1546
|
+
<!-- RIGHT: Tests content -->
|
|
1547
|
+
<div style="display:flex;flex-direction:column;overflow:hidden">
|
|
1548
|
+
<div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
1549
|
+
<input class="form-input" type="text" id="tests-search" placeholder="Search by name or goal..." style="flex:1;min-width:180px;padding:6px 10px;font-size:12px" oninput="renderAllTests()">
|
|
1550
|
+
<select class="form-select" id="tests-scope-filter" style="padding:6px 10px;font-size:12px;width:auto" onchange="renderAllTests()">
|
|
1539
1551
|
<option value="">All scopes</option>
|
|
1540
1552
|
</select>
|
|
1541
|
-
<select class="form-select" id="tests-tag-filter" style="padding:
|
|
1553
|
+
<select class="form-select" id="tests-tag-filter" style="padding:6px 10px;font-size:12px;width:auto" onchange="renderAllTests()">
|
|
1542
1554
|
<option value="">All tags</option>
|
|
1543
1555
|
</select>
|
|
1556
|
+
<button class="btn btn-ghost" id="btn-move-tests-folder" style="display:none;font-size:11px;padding:4px 10px" onclick="openMoveToFolder('tests')">
|
|
1557
|
+
Move to folder
|
|
1558
|
+
</button>
|
|
1559
|
+
<div id="tests-count" style="font-family:var(--mono);font-size:11px;color:var(--muted)"></div>
|
|
1544
1560
|
</div>
|
|
1545
|
-
<div id="tests-
|
|
1546
|
-
</div>
|
|
1547
|
-
<div class="card-body" style="padding:0">
|
|
1548
|
-
<div id="all-tests-container"><!-- populated --></div>
|
|
1561
|
+
<div id="all-tests-container" style="overflow-y:auto;flex:1"><!-- populated --></div>
|
|
1549
1562
|
</div>
|
|
1563
|
+
|
|
1550
1564
|
</div>
|
|
1551
1565
|
</div>
|
|
1552
1566
|
|
|
@@ -1610,18 +1624,33 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
|
|
|
1610
1624
|
</div>
|
|
1611
1625
|
</div>
|
|
1612
1626
|
|
|
1613
|
-
<!--
|
|
1614
|
-
<div style="display:
|
|
1615
|
-
<input class="form-input" id="library-search" type="text" placeholder="Search elements..." oninput="filterLibrarySteps()" style="flex:1">
|
|
1616
|
-
<select class="form-select" id="library-filter-tag" onchange="filterLibrarySteps()" style="width:140px">
|
|
1617
|
-
<option value="">All tags</option>
|
|
1618
|
-
</select>
|
|
1619
|
-
</div>
|
|
1627
|
+
<!-- Two-panel layout: folder tree + content -->
|
|
1628
|
+
<div style="display:grid;grid-template-columns:220px 1fr;gap:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--surface)">
|
|
1620
1629
|
|
|
1621
|
-
|
|
1622
|
-
<div
|
|
1623
|
-
<div
|
|
1630
|
+
<!-- LEFT: Folder tree -->
|
|
1631
|
+
<div style="border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--surface2)">
|
|
1632
|
+
<div style="padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
|
|
1633
|
+
<span style="font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em">FOLDERS</span>
|
|
1634
|
+
<button class="btn btn-ghost" style="padding:2px 6px;font-size:14px;line-height:1" onclick="openCreateFolder('library')" title="New folder">+</button>
|
|
1635
|
+
</div>
|
|
1636
|
+
<div id="library-folder-tree" style="overflow-y:auto;flex:1;padding:6px 0"></div>
|
|
1624
1637
|
</div>
|
|
1638
|
+
|
|
1639
|
+
<!-- RIGHT: Steps content -->
|
|
1640
|
+
<div style="display:flex;flex-direction:column;overflow:hidden">
|
|
1641
|
+
<!-- Search + filter + bulk actions -->
|
|
1642
|
+
<div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:center">
|
|
1643
|
+
<input class="form-input" id="library-search" type="text" placeholder="Search elements..." oninput="filterLibrarySteps()" style="flex:1;font-size:12px">
|
|
1644
|
+
<select class="form-select" id="library-filter-tag" onchange="filterLibrarySteps()" style="width:130px;font-size:12px">
|
|
1645
|
+
<option value="">All tags</option>
|
|
1646
|
+
</select>
|
|
1647
|
+
<button class="btn btn-ghost" id="btn-move-to-folder" style="display:none;font-size:11px;padding:4px 10px" onclick="openMoveToFolder('library')">
|
|
1648
|
+
Move to folder
|
|
1649
|
+
</button>
|
|
1650
|
+
</div>
|
|
1651
|
+
<div id="library-steps-container" style="overflow-y:auto;flex:1"><!-- populated --></div>
|
|
1652
|
+
</div>
|
|
1653
|
+
|
|
1625
1654
|
</div>
|
|
1626
1655
|
</div>
|
|
1627
1656
|
|
|
@@ -1828,6 +1857,47 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
|
|
|
1828
1857
|
</div>
|
|
1829
1858
|
</div>
|
|
1830
1859
|
|
|
1860
|
+
<!-- CREATE FOLDER MODAL -->
|
|
1861
|
+
<div class="modal-overlay" id="create-folder-modal" style="display:none" onclick="if(event.target===this)closeCreateFolder()">
|
|
1862
|
+
<div class="modal" style="max-width:400px;width:100%">
|
|
1863
|
+
<div class="modal-header">
|
|
1864
|
+
<div class="modal-title">New folder</div>
|
|
1865
|
+
<button class="modal-close" onclick="closeCreateFolder()">✕</button>
|
|
1866
|
+
</div>
|
|
1867
|
+
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
|
|
1868
|
+
<input type="hidden" id="create-folder-type">
|
|
1869
|
+
<div class="form-field">
|
|
1870
|
+
<label class="form-label">Folder name</label>
|
|
1871
|
+
<input class="form-input" id="create-folder-name" type="text" placeholder="e.g. Login, Charts/Bar Charts" onkeydown="if(event.key==='Enter')confirmCreateFolder()">
|
|
1872
|
+
<div class="form-help">Use / for nested folders e.g. "Charts/Bar Charts"</div>
|
|
1873
|
+
</div>
|
|
1874
|
+
</div>
|
|
1875
|
+
<div class="modal-footer">
|
|
1876
|
+
<button class="btn btn-ghost" onclick="closeCreateFolder()">Cancel</button>
|
|
1877
|
+
<button class="btn btn-primary" onclick="confirmCreateFolder()">Create</button>
|
|
1878
|
+
</div>
|
|
1879
|
+
</div>
|
|
1880
|
+
</div>
|
|
1881
|
+
|
|
1882
|
+
<!-- MOVE TO FOLDER MODAL -->
|
|
1883
|
+
<div class="modal-overlay" id="move-folder-modal" style="display:none" onclick="if(event.target===this)closeMoveToFolder()">
|
|
1884
|
+
<div class="modal" style="max-width:400px;width:100%">
|
|
1885
|
+
<div class="modal-header">
|
|
1886
|
+
<div class="modal-title" id="move-folder-title">Move to folder</div>
|
|
1887
|
+
<button class="modal-close" onclick="closeMoveToFolder()">✕</button>
|
|
1888
|
+
</div>
|
|
1889
|
+
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px">
|
|
1890
|
+
<input type="hidden" id="move-folder-type">
|
|
1891
|
+
<div id="move-folder-list" style="display:flex;flex-direction:column;gap:6px;max-height:300px;overflow-y:auto"></div>
|
|
1892
|
+
<div style="border-top:1px solid var(--border);padding-top:10px;margin-top:4px">
|
|
1893
|
+
<button class="btn btn-ghost" style="width:100%;justify-content:center;color:var(--muted)" onclick="confirmMoveToFolder(null)">
|
|
1894
|
+
Remove from folder (move to root)
|
|
1895
|
+
</button>
|
|
1896
|
+
</div>
|
|
1897
|
+
</div>
|
|
1898
|
+
</div>
|
|
1899
|
+
</div>
|
|
1900
|
+
|
|
1831
1901
|
<!-- USAGES POPOVER -->
|
|
1832
1902
|
<div id="lib-usages-popover" style="display:none;position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:0;min-width:220px;max-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.4)">
|
|
1833
1903
|
<div style="padding:10px 14px;border-bottom:1px solid var(--border);font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:0.1em;display:flex;justify-content:space-between;align-items:center">
|
|
@@ -4084,6 +4154,9 @@ function renderAllTests() {
|
|
|
4084
4154
|
if ([...tagFilter.options].some(o => o.value === currentTag)) tagFilter.value = currentTag;
|
|
4085
4155
|
}
|
|
4086
4156
|
|
|
4157
|
+
// Render folder tree
|
|
4158
|
+
renderFolderTree('tests-folder-tree', 'tests', selectedTestsFolder);
|
|
4159
|
+
|
|
4087
4160
|
// Apply filters
|
|
4088
4161
|
const search = (document.getElementById('tests-search')?.value || '').toLowerCase();
|
|
4089
4162
|
const scope = document.getElementById('tests-scope-filter')?.value || '';
|
|
@@ -4096,6 +4169,7 @@ function renderAllTests() {
|
|
|
4096
4169
|
const haystack = ((t.name || '') + ' ' + (t.goal || '')).toLowerCase();
|
|
4097
4170
|
if (!haystack.includes(search)) return false;
|
|
4098
4171
|
}
|
|
4172
|
+
if (selectedTestsFolder !== null && t.folder !== selectedTestsFolder) return false;
|
|
4099
4173
|
return true;
|
|
4100
4174
|
});
|
|
4101
4175
|
filtered = sortTests(filtered);
|
|
@@ -4115,11 +4189,14 @@ function renderAllTests() {
|
|
|
4115
4189
|
const stepCount = isRec ? (t.steps || []).length : 0;
|
|
4116
4190
|
const reusableBadge = isRec && t.reusable ? '<span style="font-family:var(--mono);font-size:10px;background:rgba(245,158,11,0.1);color:#f59e0b;border:1px solid rgba(245,158,11,0.2);border-radius:4px;padding:2px 6px;margin-right:5px">REUSABLE</span>' : '';
|
|
4117
4191
|
const setupBadge = isRec && t.setup ? '<span style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-left:4px">via ' + escapeHtml(t.setup) + '</span>' : '';
|
|
4192
|
+
const folderBadge = t.folder ? '<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:rgba(34,211,238,0.1);border-radius:4px;color:#22d3ee;margin-right:4px">📁 ' + escapeHtml(t.folder) + '</span>' : '';
|
|
4118
4193
|
const subtitle = isRec
|
|
4119
4194
|
? '<span style="font-family:var(--mono);font-size:10px;background:rgba(6,182,212,0.1);color:var(--cyan);border:1px solid rgba(6,182,212,0.2);border-radius:4px;padding:2px 7px;margin-right:6px">RECORDED</span>' + reusableBadge + '<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">' + stepCount + ' steps' + setupBadge + '</span>'
|
|
4120
4195
|
: '<span style="font-family:var(--mono);font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:380px;display:inline-block">' + escapeHtml(t.goal || '') + '</span>';
|
|
4121
|
-
return '<tr>'
|
|
4122
|
-
+ '<td
|
|
4196
|
+
return '<tr draggable="true" ondragstart="onItemDragStart(event,\'' + escapeAttr(t.id) + '\',\'tests\')">'
|
|
4197
|
+
+ '<td style="padding:10px 8px;color:var(--muted2);cursor:grab;font-size:14px;width:20px">⠿</td>'
|
|
4198
|
+
+ '<td style="padding:10px 8px;width:20px"><input type="checkbox" class="test-checkbox" data-id="' + escapeAttr(t.id) + '" onchange="onTestCheck()" style="width:14px;height:14px"></td>'
|
|
4199
|
+
+ '<td><div style="font-weight:600;color:var(--white);margin-bottom:4px">' + escapeHtml(t.name) + '</div><div>' + folderBadge + subtitle + '</div></td>'
|
|
4123
4200
|
+ '<td><span class="scope-pill ' + (t.scopeIsSaved ? 'saved' : 'suite') + '">' + escapeHtml(t.scopeName) + '</span></td>'
|
|
4124
4201
|
+ '<td>' + (t.credentials ? '<span class="suite-tag-pill" style="color:var(--purple);background:var(--purple-dim);border-color:rgba(124,58,237,0.3)"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:3px"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>' + escapeHtml(t.credentials) + '</span>' : '<span style="color:var(--muted2);font-family:var(--mono);font-size:11px">—</span>') + '</td>'
|
|
4125
4202
|
+ '<td><div style="display:flex;gap:4px;flex-wrap:wrap">' + ((t.tags || []).map(tg => '<span class="suite-tag-pill">' + escapeHtml(tg) + '</span>').join('') || '<span style="color:var(--muted2);font-family:var(--mono);font-size:11px">—</span>') + '</div></td>'
|
|
@@ -4148,6 +4225,8 @@ function renderAllTests() {
|
|
|
4148
4225
|
container.innerHTML = `
|
|
4149
4226
|
<table class="tests-table">
|
|
4150
4227
|
<thead><tr>
|
|
4228
|
+
<th style="width:20px"></th>
|
|
4229
|
+
<th style="width:20px"></th>
|
|
4151
4230
|
<th style="cursor:pointer;user-select:none" onclick="setTestsSort('name')">Test ${getSortIndicator('name')}</th>
|
|
4152
4231
|
<th style="cursor:pointer;user-select:none" onclick="setTestsSort('scope')">Scope ${getSortIndicator('scope')}</th>
|
|
4153
4232
|
<th style="cursor:pointer;user-select:none" onclick="setTestsSort('credentials')">Credentials ${getSortIndicator('credentials')}</th>
|
|
@@ -4526,7 +4605,7 @@ const _origSwitchView2 = switchView;
|
|
|
4526
4605
|
switchView = function(name) {
|
|
4527
4606
|
_origSwitchView2(name);
|
|
4528
4607
|
if (name === 'all-tests') {
|
|
4529
|
-
fetchAllTests().then(renderAllTests);
|
|
4608
|
+
fetchAllTests().then(() => { fetchFolders().then(() => renderAllTests()); });
|
|
4530
4609
|
}
|
|
4531
4610
|
if (name === 'suite-runs') {
|
|
4532
4611
|
fetchSuiteRuns().then(renderSuiteRuns);
|
|
@@ -5606,8 +5685,247 @@ async function saveOllamaConfig() {
|
|
|
5606
5685
|
}
|
|
5607
5686
|
}
|
|
5608
5687
|
|
|
5688
|
+
function getSelectedTestIds() {
|
|
5689
|
+
return Array.from(document.querySelectorAll('.test-checkbox:checked')).map(cb => cb.dataset.id);
|
|
5690
|
+
}
|
|
5691
|
+
|
|
5692
|
+
function onTestCheck() {
|
|
5693
|
+
const checked = document.querySelectorAll('.test-checkbox:checked');
|
|
5694
|
+
const moveBtn = document.getElementById('btn-move-tests-folder');
|
|
5695
|
+
if (moveBtn) moveBtn.style.display = checked.length >= 1 ? '' : 'none';
|
|
5696
|
+
}
|
|
5697
|
+
|
|
5698
|
+
// ── FOLDERS ──────────────────────────────────────────────────────────────────
|
|
5699
|
+
let foldersCache = [];
|
|
5700
|
+
let selectedLibraryFolder = null; // null = All
|
|
5701
|
+
let selectedTestsFolder = null; // null = All
|
|
5702
|
+
|
|
5703
|
+
async function fetchFolders() {
|
|
5704
|
+
try {
|
|
5705
|
+
const res = await fetch(API_BASE + '/api/folders');
|
|
5706
|
+
if (!res.ok) return [];
|
|
5707
|
+
foldersCache = await res.json();
|
|
5708
|
+
return foldersCache;
|
|
5709
|
+
} catch { return []; }
|
|
5710
|
+
}
|
|
5711
|
+
|
|
5712
|
+
// Build nested folder structure from flat list
|
|
5713
|
+
function buildFolderTree(folders, type) {
|
|
5714
|
+
const nodes = {};
|
|
5715
|
+
const roots = [];
|
|
5716
|
+
folders.filter(f => f.type === type).forEach(f => {
|
|
5717
|
+
const parts = f.name.split('/');
|
|
5718
|
+
let path = '';
|
|
5719
|
+
parts.forEach((part, i) => {
|
|
5720
|
+
const parentPath = path;
|
|
5721
|
+
path = path ? path + '/' + part : part;
|
|
5722
|
+
if (!nodes[path]) {
|
|
5723
|
+
nodes[path] = { name: part, fullPath: path, children: [], parentPath };
|
|
5724
|
+
if (i === 0) roots.push(nodes[path]);
|
|
5725
|
+
else if (nodes[parentPath]) nodes[parentPath].children.push(nodes[path]);
|
|
5726
|
+
}
|
|
5727
|
+
});
|
|
5728
|
+
});
|
|
5729
|
+
return roots;
|
|
5730
|
+
}
|
|
5731
|
+
|
|
5732
|
+
function renderFolderTree(containerId, type, selectedFolder, onSelect) {
|
|
5733
|
+
const container = document.getElementById(containerId);
|
|
5734
|
+
if (!container) return;
|
|
5735
|
+
const roots = buildFolderTree(foldersCache, type);
|
|
5736
|
+
|
|
5737
|
+
function renderNode(node, depth = 0) {
|
|
5738
|
+
const isSelected = selectedFolder === node.fullPath;
|
|
5739
|
+
return `<div>
|
|
5740
|
+
<div class="folder-item ${isSelected ? 'folder-item--active' : ''}"
|
|
5741
|
+
style="padding:7px 14px 7px ${14 + depth * 16}px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;color:${isSelected ? 'var(--cyan)' : 'var(--text)'};background:${isSelected ? 'rgba(34,211,238,0.08)' : ''};transition:background 0.1s"
|
|
5742
|
+
data-folder="${escapeAttr(node.fullPath)}" data-type="${type}"
|
|
5743
|
+
onmouseover="if(!this.classList.contains('folder-item--active'))this.style.background='var(--surface2)'"
|
|
5744
|
+
onmouseout="if(!this.classList.contains('folder-item--active'))this.style.background=''"
|
|
5745
|
+
ondragover="event.preventDefault();this.style.background='rgba(34,211,238,0.15)'"
|
|
5746
|
+
ondragleave="this.style.background=''"
|
|
5747
|
+
ondrop="onFolderDrop(event,'${escapeAttr(node.fullPath)}','${type}')">
|
|
5748
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="${isSelected ? 'rgba(34,211,238,0.3)' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
|
5749
|
+
<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(node.name)}</span>
|
|
5750
|
+
<span class="folder-actions" style="display:none;gap:4px">
|
|
5751
|
+
<span data-rename-folder="${escapeAttr(node.fullPath)}" data-type="${type}" style="opacity:0.5;cursor:pointer;font-size:11px" title="Rename">✎</span>
|
|
5752
|
+
<span data-delete-folder="${escapeAttr(node.fullPath)}" data-type="${type}" style="opacity:0.5;cursor:pointer;font-size:11px;color:var(--red)" title="Delete">✕</span>
|
|
5753
|
+
</span>
|
|
5754
|
+
</div>
|
|
5755
|
+
${node.children.map(c => renderNode(c, depth + 1)).join('')}
|
|
5756
|
+
</div>`;
|
|
5757
|
+
}
|
|
5758
|
+
|
|
5759
|
+
const allActive = selectedFolder === null;
|
|
5760
|
+
container.innerHTML = `
|
|
5761
|
+
<div style="padding:7px 14px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;color:${allActive ? 'var(--cyan)' : 'var(--muted)'};background:${allActive ? 'rgba(34,211,238,0.08)' : ''}"
|
|
5762
|
+
data-folder="" data-type="${type}"
|
|
5763
|
+
onmouseover="if(${!allActive})this.style.background='var(--surface2)'" onmouseout="if(${!allActive})this.style.background=''"
|
|
5764
|
+
ondragover="event.preventDefault();this.style.background='rgba(34,211,238,0.15)'"
|
|
5765
|
+
ondragleave="this.style.background=''"
|
|
5766
|
+
ondrop="onFolderDrop(event,'','${type}')">
|
|
5767
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/></svg>
|
|
5768
|
+
All
|
|
5769
|
+
</div>
|
|
5770
|
+
${roots.map(n => renderNode(n)).join('')}`;
|
|
5771
|
+
|
|
5772
|
+
// Show/hide actions on hover
|
|
5773
|
+
container.querySelectorAll('.folder-item').forEach(el => {
|
|
5774
|
+
el.addEventListener('mouseenter', () => { el.querySelector('.folder-actions').style.display = 'flex'; });
|
|
5775
|
+
el.addEventListener('mouseleave', () => { el.querySelector('.folder-actions').style.display = 'none'; });
|
|
5776
|
+
});
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5779
|
+
// Folder click delegation
|
|
5780
|
+
document.addEventListener('click', (e) => {
|
|
5781
|
+
const folderItem = e.target.closest('[data-folder][data-type]');
|
|
5782
|
+
const renameBtn = e.target.closest('[data-rename-folder]');
|
|
5783
|
+
const deleteBtn = e.target.closest('[data-delete-folder]');
|
|
5784
|
+
|
|
5785
|
+
if (renameBtn) {
|
|
5786
|
+
e.stopPropagation();
|
|
5787
|
+
const name = renameBtn.dataset.renameFolder;
|
|
5788
|
+
const type = renameBtn.dataset.type;
|
|
5789
|
+
const newName = prompt('Rename folder:', name);
|
|
5790
|
+
if (newName && newName !== name) renameFolder(name, newName, type);
|
|
5791
|
+
return;
|
|
5792
|
+
}
|
|
5793
|
+
if (deleteBtn) {
|
|
5794
|
+
e.stopPropagation();
|
|
5795
|
+
const name = deleteBtn.dataset.deleteFolder;
|
|
5796
|
+
const type = deleteBtn.dataset.type;
|
|
5797
|
+
showConfirm('Delete folder?', `Delete "${name}"? Items inside will be moved to root.`, () => deleteFolder(name, type));
|
|
5798
|
+
return;
|
|
5799
|
+
}
|
|
5800
|
+
if (folderItem && !renameBtn && !deleteBtn) {
|
|
5801
|
+
const folder = folderItem.dataset.folder || null;
|
|
5802
|
+
const type = folderItem.dataset.type;
|
|
5803
|
+
if (type === 'library') { selectedLibraryFolder = folder; loadLibraryView(); }
|
|
5804
|
+
else if (type === 'tests') { selectedTestsFolder = folder; fetchAllTests().then(renderAllTests); }
|
|
5805
|
+
}
|
|
5806
|
+
});
|
|
5807
|
+
|
|
5808
|
+
// Drag and drop
|
|
5809
|
+
function onItemDragStart(e, id, type) {
|
|
5810
|
+
e.dataTransfer.setData('text/plain', JSON.stringify({ id, type }));
|
|
5811
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
5812
|
+
}
|
|
5813
|
+
|
|
5814
|
+
async function onFolderDrop(e, folderPath, type) {
|
|
5815
|
+
e.preventDefault();
|
|
5816
|
+
e.currentTarget.style.background = '';
|
|
5817
|
+
try {
|
|
5818
|
+
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
|
5819
|
+
if (data.type !== type) return;
|
|
5820
|
+
await fetch(API_BASE + '/api/folders/move-items', {
|
|
5821
|
+
method: 'POST',
|
|
5822
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5823
|
+
body: JSON.stringify({ ids: [data.id], folder: folderPath || null, type }),
|
|
5824
|
+
});
|
|
5825
|
+
if (type === 'library') loadLibraryView();
|
|
5826
|
+
else fetchAllTests().then(renderAllTests);
|
|
5827
|
+
} catch {}
|
|
5828
|
+
}
|
|
5829
|
+
|
|
5830
|
+
// Create folder
|
|
5831
|
+
function openCreateFolder(type) {
|
|
5832
|
+
document.getElementById('create-folder-type').value = type;
|
|
5833
|
+
document.getElementById('create-folder-name').value = '';
|
|
5834
|
+
document.getElementById('create-folder-modal').style.display = 'flex';
|
|
5835
|
+
setTimeout(() => document.getElementById('create-folder-name').focus(), 100);
|
|
5836
|
+
}
|
|
5837
|
+
|
|
5838
|
+
function closeCreateFolder() {
|
|
5839
|
+
document.getElementById('create-folder-modal').style.display = 'none';
|
|
5840
|
+
}
|
|
5841
|
+
|
|
5842
|
+
async function confirmCreateFolder() {
|
|
5843
|
+
const type = document.getElementById('create-folder-type').value;
|
|
5844
|
+
const name = document.getElementById('create-folder-name').value.trim();
|
|
5845
|
+
if (!name) { showToast('Enter a folder name'); return; }
|
|
5846
|
+
await fetch(API_BASE + '/api/folders', {
|
|
5847
|
+
method: 'POST',
|
|
5848
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5849
|
+
body: JSON.stringify({ name, type }),
|
|
5850
|
+
});
|
|
5851
|
+
foldersCache = await (await fetch(API_BASE + '/api/folders')).json();
|
|
5852
|
+
closeCreateFolder();
|
|
5853
|
+
if (type === 'library') loadLibraryView();
|
|
5854
|
+
else { renderFolderTree('tests-folder-tree', 'tests', selectedTestsFolder); }
|
|
5855
|
+
showToast(`Folder "${name}" created`);
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5858
|
+
// Rename / delete folder
|
|
5859
|
+
async function renameFolder(oldName, newName, type) {
|
|
5860
|
+
await fetch(API_BASE + '/api/folders/rename', {
|
|
5861
|
+
method: 'POST',
|
|
5862
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5863
|
+
body: JSON.stringify({ oldName, newName, type }),
|
|
5864
|
+
});
|
|
5865
|
+
foldersCache = await (await fetch(API_BASE + '/api/folders')).json();
|
|
5866
|
+
if (type === 'library') { if (selectedLibraryFolder === oldName) selectedLibraryFolder = newName; loadLibraryView(); }
|
|
5867
|
+
else { if (selectedTestsFolder === oldName) selectedTestsFolder = newName; fetchAllTests().then(renderAllTests); }
|
|
5868
|
+
}
|
|
5869
|
+
|
|
5870
|
+
async function deleteFolder(name, type) {
|
|
5871
|
+
// Move items in this folder to root first
|
|
5872
|
+
await fetch(API_BASE + '/api/folders/move-items', {
|
|
5873
|
+
method: 'POST',
|
|
5874
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5875
|
+
body: JSON.stringify({ ids: '__all_in_folder__', folder: null, type, fromFolder: name }),
|
|
5876
|
+
});
|
|
5877
|
+
await fetch(API_BASE + '/api/folders', {
|
|
5878
|
+
method: 'DELETE',
|
|
5879
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5880
|
+
body: JSON.stringify({ name, type }),
|
|
5881
|
+
});
|
|
5882
|
+
foldersCache = await (await fetch(API_BASE + '/api/folders')).json();
|
|
5883
|
+
if (type === 'library') { if (selectedLibraryFolder === name) selectedLibraryFolder = null; loadLibraryView(); }
|
|
5884
|
+
else { if (selectedTestsFolder === name) selectedTestsFolder = null; fetchAllTests().then(renderAllTests); }
|
|
5885
|
+
}
|
|
5886
|
+
|
|
5887
|
+
// Move to folder modal
|
|
5888
|
+
let moveFolderIds = [];
|
|
5889
|
+
function openMoveToFolder(type) {
|
|
5890
|
+
const ids = type === 'library' ? getSelectedStepIds() : getSelectedTestIds();
|
|
5891
|
+
if (!ids.length) return;
|
|
5892
|
+
moveFolderIds = ids;
|
|
5893
|
+
document.getElementById('move-folder-type').value = type;
|
|
5894
|
+
document.getElementById('move-folder-title').textContent = `Move ${ids.length} item${ids.length > 1 ? 's' : ''} to folder`;
|
|
5895
|
+
const folders = foldersCache.filter(f => f.type === type);
|
|
5896
|
+
const list = document.getElementById('move-folder-list');
|
|
5897
|
+
if (!folders.length) {
|
|
5898
|
+
list.innerHTML = `<div style="font-family:var(--mono);font-size:12px;color:var(--muted);text-align:center;padding:20px">No folders yet — create one first</div>`;
|
|
5899
|
+
} else {
|
|
5900
|
+
list.innerHTML = folders.map(f => `
|
|
5901
|
+
<button class="btn btn-ghost" style="width:100%;justify-content:flex-start;gap:8px;padding:8px 12px" onclick="confirmMoveToFolder('${escapeAttr(f.name)}')">
|
|
5902
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
|
5903
|
+
${escapeHtml(f.name)}
|
|
5904
|
+
</button>`).join('');
|
|
5905
|
+
}
|
|
5906
|
+
document.getElementById('move-folder-modal').style.display = 'flex';
|
|
5907
|
+
}
|
|
5908
|
+
|
|
5909
|
+
function closeMoveToFolder() {
|
|
5910
|
+
document.getElementById('move-folder-modal').style.display = 'none';
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5913
|
+
async function confirmMoveToFolder(folderName) {
|
|
5914
|
+
const type = document.getElementById('move-folder-type').value;
|
|
5915
|
+
closeMoveToFolder();
|
|
5916
|
+
await fetch(API_BASE + '/api/folders/move-items', {
|
|
5917
|
+
method: 'POST',
|
|
5918
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5919
|
+
body: JSON.stringify({ ids: moveFolderIds, folder: folderName, type }),
|
|
5920
|
+
});
|
|
5921
|
+
showToast(folderName ? `Moved to "${folderName}"` : 'Moved to root');
|
|
5922
|
+
if (type === 'library') loadLibraryView();
|
|
5923
|
+
else fetchAllTests().then(renderAllTests);
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5609
5926
|
// ── SAVED URLS ───────────────────────────────────────────────────────────────
|
|
5610
5927
|
let savedUrlsCache = [];
|
|
5928
|
+
let libraryStepsCache = [];
|
|
5611
5929
|
|
|
5612
5930
|
async function fetchSavedUrls() {
|
|
5613
5931
|
try {
|
|
@@ -5743,10 +6061,12 @@ function renderPendingSteps(steps) {
|
|
|
5743
6061
|
}
|
|
5744
6062
|
|
|
5745
6063
|
async function loadLibraryView() {
|
|
5746
|
-
const [steps, pending] = await Promise.all([
|
|
6064
|
+
const [steps, pending, folders] = await Promise.all([
|
|
5747
6065
|
fetchLibrarySteps().catch(() => []),
|
|
5748
|
-
fetchPendingSteps().catch(() => [])
|
|
6066
|
+
fetchPendingSteps().catch(() => []),
|
|
6067
|
+
fetchFolders().catch(() => [])
|
|
5749
6068
|
]);
|
|
6069
|
+
renderFolderTree('library-folder-tree', 'library', selectedLibraryFolder);
|
|
5750
6070
|
renderPendingSteps(pending);
|
|
5751
6071
|
populateLibraryTagFilter();
|
|
5752
6072
|
renderLibrarySteps(steps);
|
|
@@ -5879,13 +6199,17 @@ function renderLibrarySteps(steps) {
|
|
|
5879
6199
|
const warning = getSelectorWarning(sel);
|
|
5880
6200
|
const actionColors = { click:'#22d3ee', type:'#a78bfa', check:'#34d399', assert:'#fb923c', select:'#f472b6', scroll:'#6b7280' };
|
|
5881
6201
|
return `
|
|
5882
|
-
<tr data-step-id="${escapeAttr(s.id)}" style="border-bottom:1px solid var(--border);transition:background 0.15s${fragile?';background:rgba(245,158,11,0.04)':''}" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='${fragile?'rgba(245,158,11,0.04)':''}'">
|
|
5883
|
-
<td style="padding:12px 16px">
|
|
6202
|
+
<tr data-step-id="${escapeAttr(s.id)}" draggable="true" ondragstart="onItemDragStart(event,'${escapeAttr(s.id)}','library')" style="border-bottom:1px solid var(--border);transition:background 0.15s${fragile?';background:rgba(245,158,11,0.04)':''}" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='${fragile?'rgba(245,158,11,0.04)':''}'" >
|
|
6203
|
+
<td style="padding:12px 8px 12px 16px;color:var(--muted2);cursor:grab;font-size:14px">⠿</td>
|
|
6204
|
+
<td style="padding:12px 8px">
|
|
5884
6205
|
<input type="checkbox" class="lib-step-checkbox" data-id="${escapeAttr(s.id)}" onchange="onLibStepCheck()" style="width:14px;height:14px">
|
|
5885
6206
|
</td>
|
|
5886
6207
|
<td style="padding:12px 20px">
|
|
5887
6208
|
<div style="font-size:13px;color:var(--text)">${escapeHtml(s.name||'')}</div>
|
|
5888
|
-
|
|
6209
|
+
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:3px">
|
|
6210
|
+
${s.folder ? `<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:rgba(34,211,238,0.1);border-radius:4px;color:#22d3ee">📁 ${escapeHtml(s.folder)}</span>` : ''}
|
|
6211
|
+
${s.tags && s.tags.length ? s.tags.map(t=>`<span style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--surface2);border-radius:4px;color:var(--muted)">${escapeHtml(t)}</span>`).join('') : ''}
|
|
6212
|
+
</div>
|
|
5889
6213
|
</td>
|
|
5890
6214
|
<td style="padding:12px 16px;max-width:300px">
|
|
5891
6215
|
<div style="display:flex;align-items:center;gap:6px">
|
|
@@ -5918,8 +6242,10 @@ function onLibStepCheck() {
|
|
|
5918
6242
|
const checked = document.querySelectorAll('.lib-step-checkbox:checked');
|
|
5919
6243
|
const mergeBtn = document.getElementById('btn-merge-steps');
|
|
5920
6244
|
const deleteBtn = document.getElementById('btn-delete-selected');
|
|
6245
|
+
const moveBtn = document.getElementById('btn-move-to-folder');
|
|
5921
6246
|
if (mergeBtn) mergeBtn.style.display = checked.length >= 2 ? '' : 'none';
|
|
5922
6247
|
if (deleteBtn) deleteBtn.style.display = checked.length >= 1 ? '' : 'none';
|
|
6248
|
+
if (moveBtn) moveBtn.style.display = checked.length >= 1 ? '' : 'none';
|
|
5923
6249
|
}
|
|
5924
6250
|
|
|
5925
6251
|
function toggleSelectAllSteps(checked) {
|
|
@@ -6028,7 +6354,8 @@ function filterLibrarySteps() {
|
|
|
6028
6354
|
const filtered = libraryStepsCache.filter(s => {
|
|
6029
6355
|
const matchQ = !q || (s.name||'').toLowerCase().includes(q) || (s.selector||'').toLowerCase().includes(q) || (s.description||'').toLowerCase().includes(q);
|
|
6030
6356
|
const matchTag = !tag || (s.tags||[]).includes(tag);
|
|
6031
|
-
|
|
6357
|
+
const matchFolder = selectedLibraryFolder === null || s.folder === selectedLibraryFolder;
|
|
6358
|
+
return matchQ && matchTag && matchFolder;
|
|
6032
6359
|
});
|
|
6033
6360
|
renderLibrarySteps(filtered);
|
|
6034
6361
|
}
|