/** * @file pinnedAppletAutomat.js * @description State container for applets pinned to browser routes. * Persists pin assignments in company settings using the 'applet.pinned' namespace, * with local storage fallback per company. * * Upstream Triggers: * - `authAutomat`: Subscribes to auth changes — loads pinned applet settings when authed * with a selectedCompanyId, and resets when anon/logged out. * - UI / VfsAppletRunner: Calls `pinApplet`, `unpinApplet`, `togglePin`. * - UI / PinnedAppletFabs: Calls `executeApplet`, `closeExecution`. */ import { Automat } from 'react-automat'; import { setting as settingApi } from '../api'; import authAutomat from './authAutomat'; /** * Normalizes a browser route pathname (strips trailing slashes, hashes, query params). * @param {string} rawRoute * @returns {string} */ export function normalizeRoute(rawRoute) { if (!rawRoute) return '/'; const clean = String(rawRoute).split('?')[0].split('#')[0]; const trimmed = clean.replace(/\/+$/, ''); return trimmed || '/'; } /** * @typedef {Object} PinnedAppletState * @property {Record} pinnedByRoute Mapping of normalized route -> array of applet file paths * @property {'idle'|'loading'|'ready'} status * @property {string|null} activeExecution Currently active applet fileName to execute in dialog */ /** @type {PinnedAppletState} */ const initialState = { pinnedByRoute: {}, status: 'idle', activeExecution: null, }; let pinnedAppletAutomat; const actions = { /** * Check if an applet is pinned to the specified route. * @param {string} fileName * @param {string} rawRoute * @returns {boolean} */ isPinned(fileName, rawRoute) { if (!fileName) return false; const route = normalizeRoute(rawRoute); const list = pinnedAppletAutomat.state.pinnedByRoute[route]; return Array.isArray(list) && list.includes(fileName); }, /** * Get all applet file paths pinned to the specified route. * @param {string} rawRoute * @returns {string[]} */ getPinnedForRoute(rawRoute) { const route = normalizeRoute(rawRoute); const list = pinnedAppletAutomat.state.pinnedByRoute[route]; return Array.isArray(list) ? list : []; }, /** * Pin an applet to a route. * @param {string} fileName * @param {string} rawRoute */ pinApplet(fileName, rawRoute) { if (!fileName) return; const route = normalizeRoute(rawRoute); const current = pinnedAppletAutomat.state.pinnedByRoute; const list = current[route] ? [...current[route]] : []; if (!list.includes(fileName)) { list.push(fileName); const next = { ...current, [route]: list }; pinnedAppletAutomat.setState({ pinnedByRoute: next }); void actions.save(route); } }, /** * Unpin an applet from a route. * @param {string} fileName * @param {string} rawRoute */ unpinApplet(fileName, rawRoute) { if (!fileName) return; const route = normalizeRoute(rawRoute); const current = pinnedAppletAutomat.state.pinnedByRoute; const list = current[route] ? current[route].filter((f) => f !== fileName) : []; const next = { ...current }; if (list.length > 0) { next[route] = list; } else { delete next[route]; } pinnedAppletAutomat.setState({ pinnedByRoute: next }); void actions.save(route); }, /** * Toggle pin status for an applet on a route. * @param {string} fileName * @param {string} rawRoute * @returns {boolean} New pinned status */ togglePin(fileName, rawRoute) { if (!fileName) return false; const route = normalizeRoute(rawRoute); const isCurrentlyPinned = actions.isPinned(fileName, route); if (isCurrentlyPinned) { actions.unpinApplet(fileName, route); return false; } actions.pinApplet(fileName, route); return true; }, /** * Open dialog to execute the specified applet. * @param {string} fileName */ executeApplet(fileName) { pinnedAppletAutomat.setState({ activeExecution: fileName }); }, /** * Close the execution dialog. */ closeExecution() { pinnedAppletAutomat.setState({ activeExecution: null }); }, /** * Load pinned applets from company settings namespace and localStorage. */ async load() { const companyId = authAutomat.state.user?.selectedCompanyId; if (!companyId) return; pinnedAppletAutomat.setState({ status: 'loading' }); const storageKey = `dash_company_${companyId}_applet_pinned`; let loadedMap = null; // Fast-path: Synchronous read from localStorage to eliminate blank flash try { const rawLocal = localStorage.getItem(storageKey); if (rawLocal) { loadedMap = JSON.parse(rawLocal); } } catch (err) { console.warn('[pinnedAppletAutomat] localStorage read failed:', err); } if (loadedMap && typeof loadedMap === 'object') { pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' }); } // Network read: Try company settings API with 'applet.pinned' namespace try { const res = await settingApi.get('applet.pinned'); const val = res?.setting?.value; if (val) { const parsed = JSON.parse(val); if (parsed && typeof parsed === 'object') { loadedMap = { ...(loadedMap || {}), ...parsed }; pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' }); try { localStorage.setItem(storageKey, JSON.stringify(loadedMap)); } catch {} } } } catch { // Ignore if key is not yet stored on backend } // Also scan company settings list for any keys in namespace 'applet.pinned:' try { const listRes = await settingApi.list({ frontendOnly: true }); const settings = Array.isArray(listRes?.settings) ? listRes.settings : []; let merged = false; const current = { ...(loadedMap || pinnedAppletAutomat.state.pinnedByRoute) }; for (const s of settings) { if (s.key === 'applet.pinned' && s.value) { try { const parsed = JSON.parse(s.value); if (parsed && typeof parsed === 'object') { Object.assign(current, parsed); merged = true; } } catch {} } else if (s.key && s.key.startsWith('applet.pinned:') && s.value) { const route = s.key.slice('applet.pinned:'.length); try { const parsed = JSON.parse(s.value); if (Array.isArray(parsed)) { current[route] = parsed; merged = true; } } catch {} } } if (merged) { pinnedAppletAutomat.setState({ pinnedByRoute: current, status: 'ready' }); try { localStorage.setItem(storageKey, JSON.stringify(current)); } catch {} } } catch { // Ignore network errors } pinnedAppletAutomat.setState({ status: 'ready' }); }, /** * Persist pinned applets into company settings namespace and localStorage. * @param {string} [route] Optional route updated */ async save(route = null) { const companyId = authAutomat.state.user?.selectedCompanyId; const { pinnedByRoute } = pinnedAppletAutomat.state; if (companyId) { const storageKey = `dash_company_${companyId}_applet_pinned`; try { localStorage.setItem(storageKey, JSON.stringify(pinnedByRoute)); } catch (err) { console.warn('[pinnedAppletAutomat] localStorage write failed:', err); } // Upsert into company settings namespace 'applet.pinned' try { await settingApi.upsert('applet.pinned', { value: JSON.stringify(pinnedByRoute), frontend: true, }); } catch { // Backend key validation fallback } // Also upsert route-specific key in namespace if (route) { try { const routeKey = `applet.pinned:${route}`; const routeVal = JSON.stringify(pinnedByRoute[route] || []); await settingApi.upsert(routeKey, { value: routeVal, frontend: true, }); } catch {} } } }, /** * Reset on logout or company switch. */ reset() { pinnedAppletAutomat.setState(initialState); }, }; /** * Pinned Applet state container. * @type {Automat} */ pinnedAppletAutomat = new Automat(initialState, actions, { name: 'pinnedApplet' }); // Subscribe to auth lifecycle authAutomat.subscribe((auth) => { const companyId = auth.user?.selectedCompanyId; if (auth.status === 'authed' && companyId) { void pinnedAppletAutomat.actions.load(); } else if (auth.status === 'anon') { pinnedAppletAutomat.actions.reset(); } }); // Auto-load if auth is already established when module loads if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) { void pinnedAppletAutomat.actions.load(); } export default pinnedAppletAutomat; export const { isPinned, getPinnedForRoute, pinApplet, unpinApplet, togglePin, executeApplet, closeExecution, } = pinnedAppletAutomat.actions;