import { api } from '../api.js'; import { showToast } from './toast.js'; import { t, tn } from '../i18n.js'; // Reusable resource-count formatter. Returns localized "1 device" / "N devices" // / "No devices" based on n. Generic so the same shape can wire users / // playlists / schedules counts later without refactor - caller supplies the // i18n key bases. // keyBase: e.g. 'switcher.devices_count' (looks up _one / _other variants via tn) // zeroKey: e.g. 'switcher.no_devices' (direct lookup for n === 0) function formatResourceCount(n, keyBase, zeroKey) { if (n === undefined || n === null) return ''; if (n === 0) return t(zeroKey); return tn(keyBase, n); } // Admin affordances shown beside a workspace: manage members + rename. Returns // '' for non-admins. Shared by the single-workspace view and the multi-workspace // dropdown items so the two never drift - #19: the single view was missing these, // locking single-workspace users out of org settings (invite users, perms, slug). function adminIconsHtml(w) { if (!w.can_admin) return ''; return ` `; } // Wire the manage-members + rename buttons within `scope`. `list` resolves a // workspace id to its object (for the rename modal). stopPropagation so a click // on an icon never triggers the row's switch handler. function wireAdminIcons(scope, list) { scope.querySelectorAll('.workspace-switcher-pencil').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const ws = list.find(w => w.id === btn.dataset.renameId); if (!ws) return; scope.classList.remove('open'); const { openWorkspaceRenameModal } = await import('./workspace-rename-modal.js'); openWorkspaceRenameModal(ws); }); }); scope.querySelectorAll('.workspace-switcher-members').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); scope.classList.remove('open'); window.location.hash = `#/workspace/${btn.dataset.membersId}/members`; }); }); } // Render the workspace switcher inside #workspaceSwitcher based on the // /api/auth/me response. Three modes: // - 0 accessible workspaces: muted "No workspace" placeholder // - 1 accessible workspace: workspace name as static text // - >1 accessible workspaces: dropdown button + menu with click-to-switch export function renderWorkspaceSwitcher(me) { const container = document.getElementById('workspaceSwitcher'); if (!container) return; const list = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : []; const currentId = me?.current_workspace_id || null; if (list.length === 0) { container.classList.remove('open'); container.innerHTML = `No workspace`; return; } if (list.length === 1) { // #19: a single workspace still needs its admin affordances (manage members / // rename + slug). Render the name as before, plus the inline manage icons // when the user can administer it - no dropdown for one item. container.classList.remove('open'); const only = list[0]; container.innerHTML = `
${esc(only.name)} ${adminIconsHtml(only)}
`; wireAdminIcons(container, [only]); return; } // >1: dropdown. Alpha sort by workspace name for MVP (no recently-used yet). const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name)); const current = sorted.find(w => w.id === currentId) || sorted[0]; // Issue #16: show a type-to-filter search box once the list is big enough to // be painful to scroll (MSPs run 100+ orgs). Below the threshold a plain list // is fine. The full list is already loaded from /me, so filtering is client-side. const SHOW_SEARCH_THRESHOLD = 8; const showSearch = sorted.length >= SHOW_SEARCH_THRESHOLD; container.innerHTML = `
${showSearch ? ` ` : ''} ${sorted.map(w => { const countStr = formatResourceCount(w.device_count, 'switcher.devices_count', 'switcher.no_devices'); const orgName = w.organization_name || ''; const subtitle = orgName && countStr ? esc(orgName) + ' ยท ' + esc(countStr) : orgName ? esc(orgName) : countStr ? esc(countStr) : ''; // Searchable haystack: org name + workspace name, lowercased. const haystack = `${orgName} ${w.name}`.toLowerCase(); return `
${esc(w.name)}
${subtitle}
${adminIconsHtml(w)}
`; }).join('')}
`; const button = container.querySelector('.workspace-switcher-button'); const searchInput = container.querySelector('.ws-search-input'); // null below threshold // Shared switch action (used by click and keyboard Enter). async function switchTo(wsId) { if (wsId === currentId) { container.classList.remove('open'); return; } try { const resp = await api.switchWorkspace(wsId); if (resp?.token) { localStorage.setItem('token', resp.token); window.location.reload(); } else { showToast('Switch returned no token', 'error'); } } catch (err) { showToast(err.message || 'Failed to switch workspace', 'error'); } } // ---- type-to-filter + keyboard navigation (only when the search box renders) ---- const allItems = Array.from(container.querySelectorAll('.workspace-switcher-item')); const noResults = container.querySelector('.workspace-switcher-noresults'); let highlightIdx = -1; const visibleItems = () => allItems.filter(it => it.style.display !== 'none'); function setHighlight(idx) { const vis = visibleItems(); allItems.forEach(it => it.classList.remove('highlighted')); if (!vis.length) { highlightIdx = -1; return; } highlightIdx = Math.max(0, Math.min(idx, vis.length - 1)); const el = vis[highlightIdx]; el.classList.add('highlighted'); el.scrollIntoView({ block: 'nearest' }); } function applyFilter(q) { const query = (q || '').trim().toLowerCase(); let anyVisible = false; for (const it of allItems) { const match = !query || it.dataset.search.includes(query); it.style.display = match ? '' : 'none'; if (match) anyVisible = true; } if (noResults) noResults.style.display = anyVisible ? 'none' : ''; setHighlight(0); } if (searchInput) { searchInput.addEventListener('input', () => applyFilter(searchInput.value)); searchInput.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(highlightIdx + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(highlightIdx - 1); } else if (e.key === 'Enter') { e.preventDefault(); const el = visibleItems()[highlightIdx]; if (el) switchTo(el.dataset.workspaceId); } else if (e.key === 'Escape') { e.preventDefault(); container.classList.remove('open'); button.setAttribute('aria-expanded', 'false'); button.focus(); } }); } button.addEventListener('click', (e) => { e.stopPropagation(); const opening = !container.classList.contains('open'); container.classList.toggle('open'); button.setAttribute('aria-expanded', String(opening)); // On open, reset the filter and focus the search box for immediate typing. if (opening && searchInput) { searchInput.value = ''; applyFilter(''); setTimeout(() => searchInput.focus(), 0); } }); // Manage-members + rename icons (shared with the single-workspace view). wireAdminIcons(container, sorted); container.querySelectorAll('.workspace-switcher-item').forEach(item => { item.addEventListener('click', (e) => { // Ignore clicks that originated on an icon button (each has its own handler). if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return; switchTo(item.dataset.workspaceId); }); }); // Click-outside closes the menu. document.addEventListener('click', (e) => { if (!container.contains(e.target)) { container.classList.remove('open'); button.setAttribute('aria-expanded', 'false'); } }); } function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }