Files
dashfront/src/automats/pinnedAppletAutomat.js
2026-10-15 20:15:19 +02:00

305 lines
9.0 KiB
JavaScript

/**
* @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<string, string[]>} 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<PinnedAppletState>}
*/
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;