Watch article history over SSE so the tab stays current while open.
This commit is contained in:
@@ -108,6 +108,12 @@ export class ApiClient {
|
|||||||
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
|
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
|
||||||
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
|
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
|
||||||
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
|
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
|
||||||
|
/**
|
||||||
|
* Load history and refetch on SSE `article-<sku>`.
|
||||||
|
* `onHistory(history)` / `onError(err)` — returns unwatch().
|
||||||
|
*/
|
||||||
|
historyWatch: (sku, onHistory, onError) =>
|
||||||
|
this.watchArticleHistory(sku, onHistory, onError),
|
||||||
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */
|
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */
|
||||||
update: async (sku, body) => {
|
update: async (sku, body) => {
|
||||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||||
|
|||||||
@@ -72,12 +72,67 @@ export class StorageManager extends ApiClient {
|
|||||||
#events = null;
|
#events = null;
|
||||||
/** @type {ReturnType<typeof createCache> | null} */
|
/** @type {ReturnType<typeof createCache> | null} */
|
||||||
#articleIndexCache = null;
|
#articleIndexCache = null;
|
||||||
|
/** @type {Map<string, Set<() => void>>} sku → refreshers */
|
||||||
|
#historyWatchers = new Map();
|
||||||
|
|
||||||
constructor(baseUrl = window.location.origin) {
|
constructor(baseUrl = window.location.origin) {
|
||||||
super(baseUrl);
|
super(baseUrl);
|
||||||
this.#initCaches();
|
this.#initCaches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
|
||||||
|
* @param {string} sku
|
||||||
|
* @param {(history: unknown[]) => void} onHistory
|
||||||
|
* @param {(err: unknown) => void} onError
|
||||||
|
* @returns {() => void} unwatch
|
||||||
|
*/
|
||||||
|
watchArticleHistory(sku, onHistory, onError) {
|
||||||
|
const key = String(sku);
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const refresh = () =>
|
||||||
|
this.article.history(key).then((res) => {
|
||||||
|
if (!active) return;
|
||||||
|
requireObject(res, 'GET /api/article/{sku}/history');
|
||||||
|
onHistory(requireArray(res.history, 'history'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = () =>
|
||||||
|
void refresh().catch((err) => {
|
||||||
|
if (!active) return;
|
||||||
|
onError(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
let watchers = this.#historyWatchers.get(key);
|
||||||
|
if (!watchers) {
|
||||||
|
watchers = new Set();
|
||||||
|
this.#historyWatchers.set(key, watchers);
|
||||||
|
this.#events?.addEventListener(`article-${key}`, this.#onArticleHistorySignal);
|
||||||
|
}
|
||||||
|
watchers.add(run);
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
const set = this.#historyWatchers.get(key);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(run);
|
||||||
|
if (set.size === 0) {
|
||||||
|
this.#historyWatchers.delete(key);
|
||||||
|
this.#events?.removeEventListener(`article-${key}`, this.#onArticleHistorySignal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#onArticleHistorySignal = (event) => {
|
||||||
|
const sku = event.type.slice('article-'.length);
|
||||||
|
const watchers = this.#historyWatchers.get(sku);
|
||||||
|
if (!watchers) return;
|
||||||
|
for (const run of watchers) run();
|
||||||
|
};
|
||||||
|
|
||||||
/** Patch local articleIndex name if already loaded; no-op otherwise. */
|
/** Patch local articleIndex name if already loaded; no-op otherwise. */
|
||||||
setArticleIndexName(sku, name) {
|
setArticleIndexName(sku, name) {
|
||||||
const articles = this.#articleIndexCache.value;
|
const articles = this.#articleIndexCache.value;
|
||||||
@@ -147,6 +202,9 @@ export class StorageManager extends ApiClient {
|
|||||||
console.error('[storageMgr] articles SSE refresh failed', err);
|
console.error('[storageMgr] articles SSE refresh failed', err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
for (const sku of this.#historyWatchers.keys()) {
|
||||||
|
es.addEventListener(`article-${sku}`, this.#onArticleHistorySignal);
|
||||||
|
}
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
console.error('[storageMgr] events stream error');
|
console.error('[storageMgr] events stream error');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ export default class ArticleHistoryTab extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { history: null };
|
this.state = { history: null };
|
||||||
|
this.unwatchHistory = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
void this.context.article
|
this.unwatchHistory = this.context.article.historyWatch(
|
||||||
.history(this.props.sku)
|
this.props.sku,
|
||||||
.then((res) => this.setState({ history: res.history }))
|
(history) => this.setState({ history }),
|
||||||
.catch(notifyError);
|
notifyError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unwatchHistory?.();
|
||||||
|
this.unwatchHistory = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
Reference in New Issue
Block a user