diff --git a/public/components/cms.html b/public/components/cms.html index cb829666..96f45174 100644 --- a/public/components/cms.html +++ b/public/components/cms.html @@ -20,10 +20,10 @@

Categories

-
- - -
+
+ + +
diff --git a/public/css/styles.css b/public/css/styles.css index 0bf15b47..e71c7f83 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -684,8 +684,9 @@ textarea.full-input{resize:vertical;} .cms-cat-item:hover{background:var(--g50);} .cms-cat-item .cms-cat-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .cms-cat-item .cms-cat-count{font-size:11px;color:var(--g400);margin:0 6px;} -.cms-add-cat{display:flex;gap:4px;} -.cms-btn-add{width:32px;height:32px;border:1px solid var(--g300);border-radius:6px;background:white;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--blue);font-size:12px;} +.cms-add-cat{display:flex;gap:4px;margin:0;} +.cms-add-cat .cms-input-sm{flex:1;min-width:0;width:0;} +.cms-btn-add{flex-shrink:0;min-width:44px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;background:white;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--blue);font-size:12px;} .cms-btn-add:hover{background:var(--blue-light);} /* Input small */ diff --git a/public/js/learningHub.js b/public/js/learningHub.js index a4b70e00..46e6c66f 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -107,7 +107,6 @@ import { createWebdavController } from './learningHub/webdavController.js'; // ── CMS events ───────────────────────────── if (e.target.closest('#btn-lh-refresh-content')) { cms.loadContent(); cms.loadStats(); return; } - if (e.target.closest('#btn-lh-add-cat')) { cms.addCategory(); return; } if (e.target.closest('#btn-lh-new-content')) { openEditor(null, 'article'); return; } if (e.target.closest('#btn-lh-new-quiz')) { openEditor(null, 'quiz'); return; } if (e.target.closest('#btn-lh-new-pearl')) { openEditor(null, 'pearl'); return; } @@ -177,6 +176,13 @@ import { createWebdavController } from './learningHub/webdavController.js'; if (rmOpt) { var row = rmOpt.closest('.lh-option-row'); if (row) { destroyOptionEditor(row); row.remove(); } return; } }); + // Native form submission handles both the visible Add button and Enter. + document.addEventListener('submit', function(e) { + if (e.target.id !== 'lh-cms-add-category') return; + e.preventDefault(); + cms.addCategory(); + }); + document.addEventListener('input', function(e) { cms.handleFilterInput(e); }); document.addEventListener('change', function(e) { cms.handleFilterChange(e); }); diff --git a/test/learning-hub-manual-category.test.js b/test/learning-hub-manual-category.test.js new file mode 100644 index 00000000..3d4aa86d --- /dev/null +++ b/test/learning-hub-manual-category.test.js @@ -0,0 +1,81 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { JSDOM } = require('jsdom'); + +const root = process.env.PEDAI_CMS_TEST_ROOT || path.join(__dirname, '..'); +const read = file => fs.readFileSync(path.join(root, file), 'utf8'); +const tick = () => new Promise(resolve => setImmediate(resolve)); + +test('actual CMS category form creates manually once, supports native submit and preserves failed drafts', async () => { + const dom = new JSDOM('' + read('public/components/cms.html') + ''); + const keys = ['window', 'document', 'fetch', 'getAuthHeaders', 'showToast']; + const previous = Object.fromEntries(keys.map(key => [key, global[key]])); + const calls = [], categories = [], notices = []; + let fail = false; + global.window = dom.window; + global.document = dom.window.document; + global.getAuthHeaders = () => ({ 'Content-Type': 'application/json' }); + global.showToast = (message, type) => notices.push({ message, type }); + global.fetch = async (url, options = {}) => { + calls.push({ url, options }); + assert.ok(!url.includes('ai-generate'), 'Manual categories must not call AI'); + if (options.method === 'POST') { + assert.equal(url, '/api/admin/learning/categories'); + const payload = JSON.parse(options.body); + if (fail) return { json: async () => ({ success: false, error: 'Synthetic rejection' }) }; + categories.push({ id: categories.length + 1, name: payload.name, slug: 'category-' + (categories.length + 1), content_count: 0 }); + return { json: async () => ({ success: true, id: categories.length }) }; + } + assert.ok(['/api/admin/learning/categories', '/api/learning/categories'].includes(url), url); + return { json: async () => ({ success: true, categories }) }; + }; + try { + const input = document.getElementById('lh-cms-cat-name'); + const button = document.getElementById('btn-lh-add-cat'); + const form = input.closest('form'); + assert.ok(form, 'Category entry needs native form submission for Enter'); + assert.equal(form.id, 'lh-cms-add-category'); + assert.equal(button.type, 'submit'); + assert.equal(button.textContent.trim(), 'Add', 'Creation remains discoverable without icon fonts'); + assert.equal(input.getAttribute('aria-label'), 'New category name'); + assert.match(window.getComputedStyle(input).minWidth, /^0(px)?$/, 'Input must shrink inside the sidebar'); + assert.equal(window.getComputedStyle(button).flexShrink, '0', 'The Add control must remain visible'); + + await import(pathToFileURL(path.join(root, 'public/js/learningHub.js')).href); + const prevented = []; + document.addEventListener('submit', event => prevented.push(event.defaultPrevented)); + input.value = ' Manual category '; + button.click(); + await tick(); await tick(); + assert.equal(calls.filter(c => c.options.method === 'POST').length, 1); + assert.equal(categories[0].name, 'Manual category'); + assert.equal(input.value, ''); + assert.match(document.getElementById('lh-cms-categories').textContent, /Manual category/); + + input.value = 'Keyboard category'; + form.requestSubmit(); // Same submit path as the browser's implicit Enter action. + await tick(); await tick(); + assert.equal(categories.length, 2); + assert.equal(calls.filter(c => c.options.method === 'POST').length, 2); + + input.value = ' '; + form.requestSubmit(); + await tick(); + assert.equal(calls.filter(c => c.options.method === 'POST').length, 2); + assert.equal(notices.at(-1).message, 'Enter category name'); + + fail = true; + input.value = 'Keep this draft'; + button.click(); + await tick(); await tick(); + assert.equal(input.value, 'Keep this draft'); + assert.equal(notices.at(-1).message, 'Synthetic rejection'); + assert.deepEqual(prevented, [true, true, true, true], 'Every path prevents native page navigation'); + } finally { + for (const key of keys) global[key] = previous[key]; + dom.window.close(); + } +});