diff --git a/public/js/learningHub.js b/public/js/learningHub.js index b6b27b7..a4b70e0 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -10,6 +10,7 @@ import { } from './learningHub/tiptapEditor.js'; import { esc, sanitizeHtml } from './learningHub/sanitize.js'; import { deleteJson, getJson, sendJson } from './learningHub/api.js'; +import { createAiPanelController } from './learningHub/aiPanelController.js'; import { renderOptionRowShell, renderQuestionBlockShell @@ -44,6 +45,12 @@ import { createWebdavController } from './learningHub/webdavController.js'; showToast: function(message, type) { showToast(message, type); } }); var webdav = createWebdavController({}); + var aiPanel = createAiPanelController({ + browseWebdav: function(path) { webdav.browse(path); }, + getEditorType: function() { return (document.getElementById('lh-cms-edit-type') || {}).value; }, + getWebdavPath: function() { return webdav.getCurrentPath(); }, + setWebdavPath: function(path) { webdav.setCurrentPath(path); } + }); // ── Load when tab activated ──────────────────────────────── document.addEventListener('tabChanged', function(e) { @@ -117,8 +124,8 @@ import { createWebdavController } from './learningHub/webdavController.js'; if (e.target.closest('#btn-lh-download-pptx')) { slides.downloadPptx(); return; } // AI panel - if (e.target.closest('#btn-lh-ai-open')) { openAiPanel(); return; } - if (e.target.closest('#btn-lh-ai-close')) { closeAiPanel(); return; } + if (e.target.closest('#btn-lh-ai-open')) { aiPanel.open(); return; } + if (e.target.closest('#btn-lh-ai-close')) { aiPanel.close(); return; } if (e.target.closest('#btn-lh-ai-generate')) { runAiGenerate(); return; } if (e.target.closest('#btn-lh-ai-refine-body')) { toggleRefineBar(); return; } if (e.target.closest('#btn-lh-refine-submit')) { submitRefineBody(); return; } @@ -128,7 +135,7 @@ import { createWebdavController } from './learningHub/webdavController.js'; // AI tabs var aiTab = e.target.closest('.lh-ai-tab'); - if (aiTab && aiTab.dataset.aitab) { switchAiTab(aiTab.dataset.aitab); return; } + if (aiTab && aiTab.dataset.aitab) { aiPanel.switchTab(aiTab.dataset.aitab); return; } // WebDAV list items var wdItem = e.target.closest('.lh-webdav-item'); @@ -438,104 +445,6 @@ import { createWebdavController } from './learningHub/webdavController.js'; // AI GENERATE PANEL // ============================================================ - function openAiPanel() { - var panel = document.getElementById('lh-ai-panel'); - if (!panel) return; - panel.classList.remove('hidden'); - populateAiModelSelect(); - - // Sync content type selector with the editor's current type - var editorType = (document.getElementById('lh-cms-edit-type') || {}).value || 'article'; - var ctype = document.getElementById('lh-ai-ctype'); - if (ctype) ctype.value = editorType; - updateAiOptions(editorType); - - // Hide WebDAV tab if Nextcloud not configured - getJson('/api/auth/me') - .then(function(d) { - var tab = document.getElementById('lh-ai-tab-webdav'); - if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none'; - if (d.user && d.user.webdav_learning_path) webdav.setCurrentPath(d.user.webdav_learning_path || '/'); - }); - - // Wire ctype change once (use a flag so it doesn't stack on re-opens) - var ctypeEl = document.getElementById('lh-ai-ctype'); - var toggleEl = document.getElementById('lh-ai-q-toggle'); - if (ctypeEl && !ctypeEl._wired) { - ctypeEl.addEventListener('change', function() { updateAiOptions(ctypeEl.value); }); - ctypeEl._wired = true; - } - if (toggleEl && !toggleEl._wired) { - toggleEl.addEventListener('change', function() { updateAiOptions((document.getElementById('lh-ai-ctype') || {}).value); }); - toggleEl._wired = true; - } - - panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - - function updateAiOptions(type) { - var wordsField = document.getElementById('lh-ai-words-field'); - var slidesField = document.getElementById('lh-ai-slides-field'); - var quizRow = document.getElementById('lh-ai-quiz-row'); - var cardTitle = document.getElementById('lh-ai-quiz-card-title'); - var toggleLabel = document.getElementById('lh-ai-q-toggle-label'); - var toggleCb = document.getElementById('lh-ai-q-toggle'); - var countWrap = document.getElementById('lh-ai-q-count-wrap'); - - // Use style.display directly — classList.toggle('hidden') doesn't work because - // .lh-ai-opt-field { display:flex } is defined later in CSS and overrides .hidden - if (wordsField) wordsField.style.display = (type === 'quiz' || type === 'presentation') ? 'none' : 'flex'; - if (slidesField) slidesField.style.display = (type === 'presentation') ? 'flex' : 'none'; - - if (type === 'quiz') { - // Quiz: questions are the whole point — show the card prominently, no toggle needed - if (quizRow) quizRow.style.display = 'block'; - if (cardTitle) cardTitle.textContent = 'Quiz Questions'; - if (toggleLabel) toggleLabel.style.display = 'none'; - if (toggleCb) { toggleCb.checked = true; toggleCb.disabled = true; } - if (countWrap) countWrap.style.display = 'flex'; - } else if (type === 'presentation') { - // Presentation: optional questions (same as article) - if (quizRow) quizRow.style.display = 'block'; - if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)'; - if (toggleLabel) toggleLabel.style.display = 'flex'; - if (toggleCb) toggleCb.disabled = false; - var checkedP = toggleCb ? toggleCb.checked : false; - if (countWrap) countWrap.style.display = checkedP ? 'flex' : 'none'; - } else { - // Article / pearl: optional questions via toggle - if (quizRow) quizRow.style.display = 'block'; - if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)'; - if (toggleLabel) toggleLabel.style.display = 'flex'; - if (toggleCb) toggleCb.disabled = false; - var checked = toggleCb ? toggleCb.checked : false; - if (countWrap) countWrap.style.display = checked ? 'flex' : 'none'; - } - } - - function closeAiPanel() { - var panel = document.getElementById('lh-ai-panel'); - if (panel) panel.classList.add('hidden'); - } - - function populateAiModelSelect() { - var sel = document.getElementById('lh-ai-model'); - if (!sel || sel.options.length > 1) return; - if (typeof window._buildModelOptions === 'function') { - window._buildModelOptions(sel); - } - } - - function switchAiTab(tabName) { - document.querySelectorAll('.lh-ai-tab').forEach(function(t) { t.classList.toggle('active', t.dataset.aitab === tabName); }); - document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); }); - var tp = document.getElementById('lh-ai-tp-' + tabName); - if (tp) tp.classList.remove('hidden'); - if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) { - webdav.browse(webdav.getCurrentPath()); - } - } - // ── Generate ──────────────────────────────────────────────── function runAiGenerate() { var activeTab = document.querySelector('.lh-ai-tab.active'); @@ -596,7 +505,7 @@ import { createWebdavController } from './learningHub/webdavController.js'; // Presentation returns marpMarkdown directly; others return content{} var payload = data.contentType === 'presentation' ? data : data.content; return applyAiContent(payload, contentType).then(function() { - closeAiPanel(); + aiPanel.close(); showToast('Content generated! Review and save.', 'success'); }); }) diff --git a/public/js/learningHub/aiPanelController.js b/public/js/learningHub/aiPanelController.js new file mode 100644 index 0000000..b5a6bf8 --- /dev/null +++ b/public/js/learningHub/aiPanelController.js @@ -0,0 +1,103 @@ +import { getJson } from './api.js'; + +export function createAiPanelController(deps) { + function open() { + var panel = document.getElementById('lh-ai-panel'); + if (!panel) return; + panel.classList.remove('hidden'); + populateModelSelect(); + + var editorType = deps.getEditorType() || 'article'; + var ctype = document.getElementById('lh-ai-ctype'); + if (ctype) ctype.value = editorType; + updateOptions(editorType); + + getJson('/api/auth/me') + .then(function(d) { + var tab = document.getElementById('lh-ai-tab-webdav'); + if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none'; + if (d.user && d.user.webdav_learning_path) deps.setWebdavPath(d.user.webdav_learning_path || '/'); + }); + + wireOptionControls(); + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + function close() { + var panel = document.getElementById('lh-ai-panel'); + if (panel) panel.classList.add('hidden'); + } + + function populateModelSelect() { + var sel = document.getElementById('lh-ai-model'); + if (!sel || sel.options.length > 1) return; + if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel); + } + + function switchTab(tabName) { + document.querySelectorAll('.lh-ai-tab').forEach(function(t) { + t.classList.toggle('active', t.dataset.aitab === tabName); + }); + document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); }); + var tp = document.getElementById('lh-ai-tp-' + tabName); + if (tp) tp.classList.remove('hidden'); + if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) { + deps.browseWebdav(deps.getWebdavPath()); + } + } + + function updateOptions(type) { + applyAiOptionState(type, { + wordsField: document.getElementById('lh-ai-words-field'), + slidesField: document.getElementById('lh-ai-slides-field'), + quizRow: document.getElementById('lh-ai-quiz-row'), + cardTitle: document.getElementById('lh-ai-quiz-card-title'), + toggleLabel: document.getElementById('lh-ai-q-toggle-label'), + toggleCb: document.getElementById('lh-ai-q-toggle'), + countWrap: document.getElementById('lh-ai-q-count-wrap') + }); + } + + function wireOptionControls() { + var ctypeEl = document.getElementById('lh-ai-ctype'); + var toggleEl = document.getElementById('lh-ai-q-toggle'); + if (ctypeEl && !ctypeEl._wired) { + ctypeEl.addEventListener('change', function() { updateOptions(ctypeEl.value); }); + ctypeEl._wired = true; + } + if (toggleEl && !toggleEl._wired) { + toggleEl.addEventListener('change', function() { + updateOptions((document.getElementById('lh-ai-ctype') || {}).value); + }); + toggleEl._wired = true; + } + } + + return { + close: close, + open: open, + switchTab: switchTab, + updateOptions: updateOptions + }; +} + +export function applyAiOptionState(type, els) { + if (els.wordsField) els.wordsField.style.display = (type === 'quiz' || type === 'presentation') ? 'none' : 'flex'; + if (els.slidesField) els.slidesField.style.display = type === 'presentation' ? 'flex' : 'none'; + + if (type === 'quiz') { + if (els.quizRow) els.quizRow.style.display = 'block'; + if (els.cardTitle) els.cardTitle.textContent = 'Quiz Questions'; + if (els.toggleLabel) els.toggleLabel.style.display = 'none'; + if (els.toggleCb) { els.toggleCb.checked = true; els.toggleCb.disabled = true; } + if (els.countWrap) els.countWrap.style.display = 'flex'; + return; + } + + if (els.quizRow) els.quizRow.style.display = 'block'; + if (els.cardTitle) els.cardTitle.textContent = 'Quiz Questions (optional)'; + if (els.toggleLabel) els.toggleLabel.style.display = 'flex'; + if (els.toggleCb) els.toggleCb.disabled = false; + var checked = els.toggleCb ? els.toggleCb.checked : false; + if (els.countWrap) els.countWrap.style.display = checked ? 'flex' : 'none'; +} diff --git a/test/learning-hub-ai-panel-controller.test.js b/test/learning-hub-ai-panel-controller.test.js new file mode 100644 index 0000000..7446765 --- /dev/null +++ b/test/learning-hub-ai-panel-controller.test.js @@ -0,0 +1,156 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { JSDOM } = require('jsdom'); + +async function loadAiPanelController() { + return import(pathToFileURL(path.join(__dirname, '..', 'public/js/learningHub/aiPanelController.js')).href); +} + +function makeDom() { + return new JSDOM(` + + + +
+
+
+
+ +
+ + +
+ +
`); +} + +function withGlobals(dom, fn) { + const previousDocument = global.document; + const previousWindow = global.window; + const previousFetch = global.fetch; + const previousGetAuthHeaders = global.getAuthHeaders; + + global.document = dom.window.document; + global.window = dom.window; + global.getAuthHeaders = function() { return {}; }; + + return Promise.resolve() + .then(fn) + .finally(function() { + global.document = previousDocument; + global.window = previousWindow; + global.fetch = previousFetch; + global.getAuthHeaders = previousGetAuthHeaders; + }); +} + +test('Learning Hub AI panel option state matches content type semantics', async () => { + const { applyAiOptionState } = await loadAiPanelController(); + const els = { + wordsField: { style: {} }, + slidesField: { style: {} }, + quizRow: { style: {} }, + cardTitle: { textContent: '' }, + toggleLabel: { style: {} }, + toggleCb: { checked: false, disabled: false }, + countWrap: { style: {} } + }; + + applyAiOptionState('article', els); + assert.equal(els.wordsField.style.display, 'flex'); + assert.equal(els.slidesField.style.display, 'none'); + assert.equal(els.cardTitle.textContent, 'Quiz Questions (optional)'); + assert.equal(els.countWrap.style.display, 'none'); + + els.toggleCb.checked = true; + applyAiOptionState('presentation', els); + assert.equal(els.wordsField.style.display, 'none'); + assert.equal(els.slidesField.style.display, 'flex'); + assert.equal(els.toggleCb.disabled, false); + assert.equal(els.countWrap.style.display, 'flex'); + + els.toggleCb.checked = false; + applyAiOptionState('quiz', els); + assert.equal(els.wordsField.style.display, 'none'); + assert.equal(els.slidesField.style.display, 'none'); + assert.equal(els.cardTitle.textContent, 'Quiz Questions'); + assert.equal(els.toggleLabel.style.display, 'none'); + assert.equal(els.toggleCb.checked, true); + assert.equal(els.toggleCb.disabled, true); + assert.equal(els.countWrap.style.display, 'flex'); +}); + +test('Learning Hub AI panel opens with defaults, models, auth state, and stable handlers', async () => { + const dom = makeDom(); + await withGlobals(dom, async function() { + const { createAiPanelController } = await loadAiPanelController(); + let modelBuilds = 0; + let fetches = 0; + const paths = []; + const panel = document.getElementById('lh-ai-panel'); + panel.scrollIntoView = function() {}; + window._buildModelOptions = function(sel) { + modelBuilds += 1; + const opt = document.createElement('option'); + opt.value = 'model-a'; + opt.textContent = 'Model A'; + sel.appendChild(opt); + }; + global.fetch = function(url) { + fetches += 1; + assert.equal(url, '/api/auth/me'); + return Promise.resolve({ json: function() { return Promise.resolve({ user: { nextcloud_url: 'https://cloud.example', webdav_learning_path: '/Learning' } }); } }); + }; + + const controller = createAiPanelController({ + browseWebdav: function() {}, + getEditorType: function() { return 'presentation'; }, + getWebdavPath: function() { return '/Learning'; }, + setWebdavPath: function(pathValue) { paths.push(pathValue); } + }); + + controller.open(); + controller.open(); + await new Promise(function(resolve) { setImmediate(resolve); }); + + assert.equal(panel.classList.contains('hidden'), false); + assert.equal(document.getElementById('lh-ai-ctype').value, 'presentation'); + assert.equal(document.getElementById('lh-ai-words-field').style.display, 'none'); + assert.equal(document.getElementById('lh-ai-slides-field').style.display, 'flex'); + assert.equal(document.getElementById('lh-ai-tab-webdav').style.display, ''); + assert.deepEqual(paths, ['/Learning', '/Learning']); + assert.equal(modelBuilds, 1); + assert.equal(fetches, 2); + + const ctype = document.getElementById('lh-ai-ctype'); + ctype.value = 'quiz'; + ctype.dispatchEvent(new window.Event('change')); + assert.equal(document.getElementById('lh-ai-q-toggle').disabled, true); + assert.equal(document.getElementById('lh-ai-quiz-card-title').textContent, 'Quiz Questions'); + }); +}); + +test('Learning Hub AI panel tab switch lazily browses empty WebDAV panel', async () => { + const dom = makeDom(); + await withGlobals(dom, async function() { + const { createAiPanelController } = await loadAiPanelController(); + const browsedPaths = []; + const controller = createAiPanelController({ + browseWebdav: function(pathValue) { browsedPaths.push(pathValue); }, + getEditorType: function() { return 'article'; }, + getWebdavPath: function() { return '/Learning/Sub'; }, + setWebdavPath: function() {} + }); + + controller.switchTab('webdav'); + assert.equal(document.getElementById('lh-ai-tab-webdav').classList.contains('active'), true); + assert.equal(document.getElementById('lh-ai-tp-webdav').classList.contains('hidden'), false); + assert.deepEqual(browsedPaths, ['/Learning/Sub']); + + document.getElementById('lh-ai-webdav-list').innerHTML = ''; + controller.switchTab('webdav'); + assert.deepEqual(browsedPaths, ['/Learning/Sub']); + }); +}); diff --git a/test/learning-hub-editor.test.js b/test/learning-hub-editor.test.js index 5ac1152..560b31c 100644 --- a/test/learning-hub-editor.test.js +++ b/test/learning-hub-editor.test.js @@ -9,6 +9,7 @@ function read(relativePath) { const source = read('public/js/learningHub.js'); const apiModule = read('public/js/learningHub/api.js'); +const aiPanelControllerModule = read('public/js/learningHub/aiPanelController.js'); const cmsControllerModule = read('public/js/learningHub/cmsController.js'); const cmsRendererModule = read('public/js/learningHub/cmsRenderer.js'); const editorModule = read('public/js/learningHub/tiptapEditor.js'); @@ -164,6 +165,21 @@ test('Learning Hub WebDAV controller owns browser state and rendering', () => { assert.doesNotMatch(webdavControllerModule, /window\.Tiptap|ai-generate|FormData/); }); +test('Learning Hub AI panel controller owns panel options and source tabs', () => { + assert.match(source, /from '\.\/learningHub\/aiPanelController\.js'/); + assert.match(source, /var aiPanel = createAiPanelController\(/); + assert.match(source, /aiPanel\.open\(\)/); + assert.match(source, /aiPanel\.close\(\)/); + assert.match(source, /aiPanel\.switchTab\(aiTab\.dataset\.aitab\)/); + assert.match(aiPanelControllerModule, /export function createAiPanelController\(deps\)/); + assert.match(aiPanelControllerModule, /export function applyAiOptionState\(type, els\)/); + assert.match(aiPanelControllerModule, /window\._buildModelOptions\(sel\)/); + assert.match(aiPanelControllerModule, /getJson\('\/api\/auth\/me'\)/); + assert.match(aiPanelControllerModule, /deps\.browseWebdav\(deps\.getWebdavPath\(\)\)/); + assert.doesNotMatch(source, /function openAiPanel\(\)|function updateAiOptions\(|function switchAiTab\(/); + assert.doesNotMatch(aiPanelControllerModule, /window\.Tiptap|ai-generate|FormData|generate-pptx/); +}); + test('Learning Hub viewer renderers are isolated from API and slide loading', () => { assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/); assert.match(viewerRendererModule, /export function buildViewerMeta\(item\)/); diff --git a/test/model-defaults.test.js b/test/model-defaults.test.js index 031fa29..142515f 100644 --- a/test/model-defaults.test.js +++ b/test/model-defaults.test.js @@ -36,7 +36,9 @@ test('top bar model selector is removed and omitted selections use backend defau test('learning hub model selector uses shared tab model options', () => { const learningHub = read('public/js/learningHub.js'); - assert.match(learningHub, /window\._buildModelOptions\(sel\)/); + const aiPanelController = read('public/js/learningHub/aiPanelController.js'); + assert.match(learningHub, /createAiPanelController\(/); + assert.match(aiPanelController, /window\._buildModelOptions\(sel\)/); assert.doesNotMatch(learningHub, /global-model-select|globalSel\.innerHTML/); assert.match(learningHub, /if \(model\) formData\.append\('model', model\)/); });