This commit is contained in:
seb
2026-10-09 10:42:24 +02:00
parent 1933ac794d
commit 38339b13a2
8 changed files with 353 additions and 37 deletions

View File

@@ -208,7 +208,7 @@ const actions = {
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const res = await articleApi.create({ categoryId: parentId, name });
const created = res.article;
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
return created;
@@ -223,7 +223,7 @@ const actions = {
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await articleApi.create({ parentId, name });
const res = await articleApi.create({ categoryId: parentId, name });
if (res.article) created.push(res.article);
}
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });

View File

@@ -37,12 +37,16 @@ import supplierAutomat from './supplierAutomat';
* @typedef {Object} SseState
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
* @property {string|null} companyId Company the stream is bound to.
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
*/
const EVENT_HISTORY_LIMIT = 200;
/** @type {SseState} */
const initialState = {
status: 'disconnected',
companyId: null,
events: [],
};
/** Event name prefix for per-article signals. */
@@ -54,9 +58,11 @@ let eventSource = null;
/**
* Handlers for statically named events (`category`, `articles`, …).
* @type {Map<string, Set<(event: MessageEvent) => void>>}
* @type {Map<string, Set<{handler: (event: MessageEvent) => void, detach: (() => void)|null}>>}
*/
const handlers = new Map();
let nextEventId = 0;
const seenEvents = new WeakSet();
/**
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
@@ -65,6 +71,31 @@ const handlers = new Map();
*/
const articleWatchers = new Map();
function recordEvent(event) {
if (seenEvents.has(event)) return;
seenEvents.add(event);
const entry = {
id: ++nextEventId,
type: event.type,
data: typeof event.data === 'string' ? event.data : '',
lastEventId: event.lastEventId ?? '',
time: Date.now(),
};
sseAutomat.setState((state) => ({
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
}));
}
function addTrackedListener(es, type, handler) {
const listener = (event) => {
recordEvent(event);
handler(event);
};
es.addEventListener(type, listener);
return () => es.removeEventListener(type, listener);
}
function disconnect() {
if (!eventSource) return;
eventSource.close();
@@ -80,8 +111,8 @@ function disconnect() {
*/
function attachHandlers(es) {
for (const [event, set] of handlers) {
for (const handler of set) {
es.addEventListener(event, handler);
for (const entry of set) {
entry.detach = addTrackedListener(es, event, entry.handler);
}
}
for (const [sku] of articleWatchers) {
@@ -100,7 +131,8 @@ function attachHandlers(es) {
* @returns {void}
*/
function attachArticleListener(sku) {
eventSource?.addEventListener(`${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
if (!eventSource) return;
addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
const set = articleWatchers.get(sku);
if (!set || set.size === 0) return;
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
@@ -121,7 +153,7 @@ function connect() {
const es = new EventSource(url, { withCredentials: true });
// Category tree changed: refresh the tree and every open category view.
es.addEventListener('category', () => {
addTrackedListener(es, 'category', () => {
void categoryAutomat.actions.loadTree().catch((err) => {
console.error('[sse] category tree refresh failed', err);
});
@@ -129,44 +161,47 @@ function connect() {
});
// Article index changed: refresh the index and open category views.
es.addEventListener('articles', () => {
addTrackedListener(es, 'articles', () => {
invalidateAllCategoryViews();
void articleIndexAutomat.actions.reload().catch((err) => {
console.error('[sse] article index refresh failed', err);
});
});
es.addEventListener('warehouse', () => {
addTrackedListener(es, 'warehouse', () => {
void warehouseAutomat.actions.reload().catch((err) => {
console.error('[sse] warehouse refresh failed', err);
});
});
es.addEventListener('attributename', () => {
addTrackedListener(es, 'attributename', () => {
void attributeAutomat.actions.load().catch((err) => {
console.error('[sse] attributename refresh failed', err);
});
});
es.addEventListener('propertyname', () => {
addTrackedListener(es, 'propertyname', () => {
void propertyAutomat.actions.load().catch((err) => {
console.error('[sse] propertyname refresh failed', err);
});
});
es.addEventListener('supplier', () => {
addTrackedListener(es, 'supplier', () => {
void supplierAutomat.actions.reload().catch((err) => {
console.error('[sse] supplier refresh failed', err);
});
});
es.addEventListener('open', () => {
addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected' });
});
addTrackedListener(es, 'message', () => {});
// EventSource reconnects on its own; only surface the state.
es.onerror = () => {
console.error('[sse] event stream error');
recordEvent({ type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
};
@@ -186,6 +221,10 @@ const actions = {
connect();
},
clearEvents() {
sseAutomat.setState({ events: [] });
},
/**
* Registers a handler for a statically named backend event.
*
@@ -202,11 +241,15 @@ const actions = {
set = new Set();
handlers.set(event, set);
}
set.add(handler);
eventSource?.addEventListener(event, handler);
let entry = Array.from(set).find((candidate) => candidate.handler === handler);
if (!entry) {
entry = { handler, detach: null };
set.add(entry);
if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler);
}
return () => {
eventSource?.removeEventListener(event, handler);
set.delete(handler);
entry.detach?.();
set.delete(entry);
if (set.size === 0) handlers.delete(event);
};
},