From 4352a02d4e05861f6070cd9139aa756b77770567 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 5 Aug 2026 17:31:12 +0200 Subject: [PATCH] Gate article detail on a store-backed cache so the view paints once. Sku page loads/caches the article before mounting ArticleView; the view reads sync from StorageManager instead of fetching again. --- src/api/StorageManager.js | 49 ++++++++ src/components/article/ArticleView.jsx | 87 +++++++++---- src/pages/artikel/sku.jsx | 167 ++++--------------------- 3 files changed, 137 insertions(+), 166 deletions(-) diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 2730fe8..6d4e49c 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -1,5 +1,6 @@ import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { notifyError } from '../utils/notify'; +import { parseCategoryTreeResponse } from '../store/categorySlice'; import { ApiClient } from './ApiClient'; /** @@ -73,6 +74,8 @@ export class StorageManager extends ApiClient { #events = null; /** @type {ReturnType | null} */ #articleIndexCache = null; + /** @type {Map} sku → article detail */ + #articlesBySku = new Map(); /** @type {Map void>>} sku → refreshers */ #historyWatchers = new Map(); @@ -81,6 +84,52 @@ export class StorageManager extends ApiClient { this.#initCaches(); } + /** Settled article detail for sku, or undefined. */ + getArticle(sku) { + return this.#articlesBySku.get(String(sku)); + } + + /** Keep local article detail in sync after GET/PATCH/PUT responses. */ + setArticle(article) { + requireObject(article, 'article'); + requireDefined(article.sku, 'article.sku'); + this.#articlesBySku.set(String(article.sku), article); + return article; + } + + /** + * GET /api/article/{sku}, cache detail, return article. + * @param {string} sku + */ + async loadArticle(sku) { + const res = await this.article.get(sku); + requireObject(res, 'GET /api/article/:sku'); + requireObject(res.article, 'article'); + return this.setArticle(res.article); + } + + /** + * Parsed category map + root id from settled caches, or null if any piece missing. + * @returns {{ categories: object, rootCategoryId: unknown } | null} + */ + getCategoryState() { + const treeData = this.categoryTree; + const companies = this.companies; + const session = this.authMe; + if (treeData == null || companies == null || session == null) return null; + + const companyId = session.user?.selectedCompanyId; + if (companyId == null) return null; + const company = companies.find((c) => c.id === companyId); + if (company == null) return null; + + const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId); + return { + categories: parsed.categories, + rootCategoryId: parsed.rootCategoryId, + }; + } + /** * Fetch article history, then refetch whenever SSE emits `article-`. * @param {string} sku diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 4345641..84f39eb 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -7,6 +7,10 @@ import { buildCategoryPath, defaultCategoryPath, } from '../../pages/artikel/categoryRoute'; +import { + artikelDetailPath, + resolveArticleTab, +} from '../../pages/artikel/articleRoute'; import ArticleTileTab from './ArticleTileTab'; import ArticlePhotoTile from './ArticlePhotoTile'; @@ -17,28 +21,55 @@ import ArticleViewEANs from './boxes/ArticleViewEANs'; import ArticleViewPrice from './boxes/ArticleViewPrice'; import ArticleViewLists from './boxes/ArticleViewLists'; +function viewStateFromStore(store, sku) { + const categoryState = store.getCategoryState(); + return { + article: store.getArticle(sku) ?? null, + categories: categoryState?.categories ?? null, + rootCategoryId: categoryState?.rootCategoryId ?? null, + }; +} + class ArticleView extends Component { static contextType = StorageManagerContext; + constructor(props, context) { + super(props, context); + this.state = viewStateFromStore(context, props.sku); + } + + componentDidUpdate(prevProps) { + const { sku, tab, navigate } = this.props; + const { article } = this.state; + + if (prevProps.sku !== sku) { + this.setState(viewStateFromStore(this.context, sku)); + return; + } + + if (!article) return; + const resolved = resolveArticleTab(tab, article); + if (tab !== resolved) { + navigate(artikelDetailPath(article, resolved), { replace: true }); + } + } + refreshSearchCaches = () => { void this.context.updateArticleIndex(); void this.context.updateCategoryTree(); }; handleArticleUpdated = (next) => { - this.props.onArticleUpdated?.(next); + this.context.setArticle(next); + this.setState({ article: next }); this.refreshSearchCaches(); }; handleArticleDeleted = () => { - const { - article: articleData, - navigate, - categories, - rootCategoryId, - } = this.props; + const { navigate } = this.props; + const { article, categories, rootCategoryId } = this.state; this.refreshSearchCaches(); - const categoryId = articleData.categoryIds?.[0]; + const categoryId = article?.categoryIds?.[0]; if (categories && rootCategoryId != null && categoryId != null) { navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); return; @@ -46,19 +77,25 @@ class ArticleView extends Component { navigate(defaultCategoryPath()); }; + handleTabChange = (nextTab) => { + const { navigate } = this.props; + const { article } = this.state; + navigate(artikelDetailPath(article, nextTab)); + }; + render() { - const { - article: articleData, - tab, - onTabChange, - categories, - rootCategoryId, - } = this.props; + const { tab } = this.props; + const { article, categories, rootCategoryId } = this.state; + + if (!article || categories == null || rootCategoryId == null) return null; + + const resolvedTab = resolveArticleTab(tab, article); + if (tab !== resolvedTab) return null; return ( - + @@ -93,13 +130,13 @@ class ArticleView extends Component { wide={3} tileKey={PANEL_TILE_KEYS.content} minHeight={200} - article={articleData} - tab={tab} - onTabChange={onTabChange} + article={article} + tab={resolvedTab} + onTabChange={this.handleTabChange} onArticleDeleted={this.handleArticleDeleted} /> - + ); } diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx index 6e385c9..bc0c0aa 100644 --- a/src/pages/artikel/sku.jsx +++ b/src/pages/artikel/sku.jsx @@ -1,62 +1,49 @@ import { Component } from 'react'; -import Alert from '@mui/material/Alert'; -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; import { StorageManagerContext } from '../../api/storageManagerContext'; import { ArticleView } from '../../components/article'; -import BackendError from '../../components/BackendError'; -import { requireDefined, requireObject } from '../../utils/apiShape'; -import { backendErrorFrom, isBackendFailure } from '../../utils/backendError'; -import { parseCategoryTreeResponse } from '../../store/categorySlice'; -import { - artikelDetailPath, - resolveArticleTab, -} from './articleRoute'; import withSkuRoute from './withSkuRoute'; class ArtikelSkuPage extends Component { static contextType = StorageManagerContext; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.state = { - data: null, - categories: null, - rootCategoryId: null, - error: null, - loading: true, - reloadKey: 0, + ready: + context.getArticle(props.sku) != null && + context.getCategoryState() != null, }; - this._cancelled = false; } componentDidMount() { - this._cancelled = false; this.installEscape(); - void this.load(); + if (!this.state.ready) void this.ensureArticle(); } - componentDidUpdate(prevProps, prevState) { - const { sku, tab: tabParam, navigate } = this.props; - const { data, reloadKey } = this.state; - - if (prevProps.sku !== sku || prevState.reloadKey !== reloadKey) { - void this.load(); - return; - } - - if (!data) return; - const resolved = resolveArticleTab(tabParam, data); - if (tabParam !== resolved) { - navigate(artikelDetailPath(data, resolved), { replace: true }); + componentDidUpdate(prevProps) { + if (prevProps.sku !== this.props.sku) { + const ready = + this.context.getArticle(this.props.sku) != null && + this.context.getCategoryState() != null; + if (ready !== this.state.ready) this.setState({ ready }); + if (!ready) void this.ensureArticle(); } } componentWillUnmount() { - this._cancelled = true; this.removeEscape(); } + async ensureArticle() { + await Promise.all([ + this.context.loadArticle(this.props.sku), + this.context.categoryTreePromise, + this.context.companyPromise, + this.context.authMePromise, + ]); + this.setState({ ready: true }); + } + installEscape() { this._onKeyDown = (e) => { if (e.key !== 'Escape') return; @@ -73,112 +60,10 @@ class ArtikelSkuPage extends Component { } } - retry = () => { - this.setState((s) => ({ reloadKey: s.reloadKey + 1 })); - }; - - async load() { - const { sku } = this.props; - const store = this.context; - - if (!sku) { - this.setState({ - error: 'Keine Artikelnr. angegeben', - data: null, - categories: null, - rootCategoryId: null, - loading: false, - }); - return; - } - - this._cancelled = false; - this.setState({ loading: true, error: null }); - - try { - const [res, treeData, companies, session] = await Promise.all([ - store.article.get(sku), - store.categoryTreePromise, - store.companyPromise, - store.authMePromise, - ]); - requireObject(res, 'GET /api/article/:sku'); - requireObject(res.article, 'article'); - const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId'); - const company = requireDefined( - companies.find((c) => c.id === companyId), - 'company', - ); - const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId); - if (!this._cancelled) { - this.setState({ - data: res.article, - categories: parsed.categories, - rootCategoryId: parsed.rootCategoryId, - error: null, - loading: false, - }); - } - } catch (err) { - if (!this._cancelled) { - this.setState({ - data: null, - categories: null, - rootCategoryId: null, - error: backendErrorFrom(err), - loading: false, - }); - } - } - } - - handleArticleUpdated = (next) => { - this.setState({ data: next }); - }; - - handleTabChange = (nextTab) => { - const { data } = this.state; - this.props.navigate(artikelDetailPath(data, nextTab)); - }; - render() { - const { tab: tabParam } = this.props; - const { data, categories, rootCategoryId, error, loading } = this.state; - - if (loading) { - return ( - - - - ); - } - - if (error) { - if (isBackendFailure(error)) { - return ; - } - return ( - - {typeof error === 'string' ? error : error.message} - - ); - } - - if (!data || categories == null || rootCategoryId == null) return null; - - const tab = resolveArticleTab(tabParam, data); - if (tabParam !== tab) return null; - - return ( - - ); + const { sku, tab } = this.props; + if (!this.state.ready) return null; + return ; } }