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.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
|
import { parseCategoryTreeResponse } from '../store/categorySlice';
|
||||||
import { ApiClient } from './ApiClient';
|
import { ApiClient } from './ApiClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +74,8 @@ 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, object>} sku → article detail */
|
||||||
|
#articlesBySku = new Map();
|
||||||
/** @type {Map<string, Set<() => void>>} sku → refreshers */
|
/** @type {Map<string, Set<() => void>>} sku → refreshers */
|
||||||
#historyWatchers = new Map();
|
#historyWatchers = new Map();
|
||||||
|
|
||||||
@@ -81,6 +84,52 @@ export class StorageManager extends ApiClient {
|
|||||||
this.#initCaches();
|
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-<sku>`.
|
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
|
||||||
* @param {string} sku
|
* @param {string} sku
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
buildCategoryPath,
|
buildCategoryPath,
|
||||||
defaultCategoryPath,
|
defaultCategoryPath,
|
||||||
} from '../../pages/artikel/categoryRoute';
|
} from '../../pages/artikel/categoryRoute';
|
||||||
|
import {
|
||||||
|
artikelDetailPath,
|
||||||
|
resolveArticleTab,
|
||||||
|
} from '../../pages/artikel/articleRoute';
|
||||||
|
|
||||||
import ArticleTileTab from './ArticleTileTab';
|
import ArticleTileTab from './ArticleTileTab';
|
||||||
import ArticlePhotoTile from './ArticlePhotoTile';
|
import ArticlePhotoTile from './ArticlePhotoTile';
|
||||||
@@ -17,28 +21,55 @@ import ArticleViewEANs from './boxes/ArticleViewEANs';
|
|||||||
import ArticleViewPrice from './boxes/ArticleViewPrice';
|
import ArticleViewPrice from './boxes/ArticleViewPrice';
|
||||||
import ArticleViewLists from './boxes/ArticleViewLists';
|
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 {
|
class ArticleView extends Component {
|
||||||
static contextType = StorageManagerContext;
|
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 = () => {
|
refreshSearchCaches = () => {
|
||||||
void this.context.updateArticleIndex();
|
void this.context.updateArticleIndex();
|
||||||
void this.context.updateCategoryTree();
|
void this.context.updateCategoryTree();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleArticleUpdated = (next) => {
|
handleArticleUpdated = (next) => {
|
||||||
this.props.onArticleUpdated?.(next);
|
this.context.setArticle(next);
|
||||||
|
this.setState({ article: next });
|
||||||
this.refreshSearchCaches();
|
this.refreshSearchCaches();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleArticleDeleted = () => {
|
handleArticleDeleted = () => {
|
||||||
const {
|
const { navigate } = this.props;
|
||||||
article: articleData,
|
const { article, categories, rootCategoryId } = this.state;
|
||||||
navigate,
|
|
||||||
categories,
|
|
||||||
rootCategoryId,
|
|
||||||
} = this.props;
|
|
||||||
this.refreshSearchCaches();
|
this.refreshSearchCaches();
|
||||||
const categoryId = articleData.categoryIds?.[0];
|
const categoryId = article?.categoryIds?.[0];
|
||||||
if (categories && rootCategoryId != null && categoryId != null) {
|
if (categories && rootCategoryId != null && categoryId != null) {
|
||||||
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
||||||
return;
|
return;
|
||||||
@@ -46,19 +77,25 @@ class ArticleView extends Component {
|
|||||||
navigate(defaultCategoryPath());
|
navigate(defaultCategoryPath());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleTabChange = (nextTab) => {
|
||||||
|
const { navigate } = this.props;
|
||||||
|
const { article } = this.state;
|
||||||
|
navigate(artikelDetailPath(article, nextTab));
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const { tab } = this.props;
|
||||||
article: articleData,
|
const { article, categories, rootCategoryId } = this.state;
|
||||||
tab,
|
|
||||||
onTabChange,
|
if (!article || categories == null || rootCategoryId == null) return null;
|
||||||
categories,
|
|
||||||
rootCategoryId,
|
const resolvedTab = resolveArticleTab(tab, article);
|
||||||
} = this.props;
|
if (tab !== resolvedTab) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTileGrid>
|
<PanelTileGrid>
|
||||||
<ArticleViewName
|
<ArticleViewName
|
||||||
article={articleData}
|
article={article}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
rootCategoryId={rootCategoryId}
|
rootCategoryId={rootCategoryId}
|
||||||
onArticleUpdated={this.handleArticleUpdated}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
@@ -68,24 +105,24 @@ class ArticleView extends Component {
|
|||||||
wide={1}
|
wide={1}
|
||||||
tileKey={PANEL_TILE_KEYS.photo}
|
tileKey={PANEL_TILE_KEYS.photo}
|
||||||
sx={{ minHeight: 0 }}
|
sx={{ minHeight: 0 }}
|
||||||
article={articleData}
|
article={article}
|
||||||
onArticleUpdated={this.handleArticleUpdated}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleViewStock article={articleData} />
|
<ArticleViewStock article={article} />
|
||||||
|
|
||||||
<ArticleViewBrand
|
<ArticleViewBrand
|
||||||
article={articleData}
|
article={article}
|
||||||
onArticleUpdated={this.handleArticleUpdated}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleViewEANs
|
<ArticleViewEANs
|
||||||
article={articleData}
|
article={article}
|
||||||
onArticleUpdated={this.handleArticleUpdated}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleViewPrice
|
<ArticleViewPrice
|
||||||
article={articleData}
|
article={article}
|
||||||
onArticleUpdated={this.handleArticleUpdated}
|
onArticleUpdated={this.handleArticleUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -93,13 +130,13 @@ class ArticleView extends Component {
|
|||||||
wide={3}
|
wide={3}
|
||||||
tileKey={PANEL_TILE_KEYS.content}
|
tileKey={PANEL_TILE_KEYS.content}
|
||||||
minHeight={200}
|
minHeight={200}
|
||||||
article={articleData}
|
article={article}
|
||||||
tab={tab}
|
tab={resolvedTab}
|
||||||
onTabChange={onTabChange}
|
onTabChange={this.handleTabChange}
|
||||||
onArticleDeleted={this.handleArticleDeleted}
|
onArticleDeleted={this.handleArticleDeleted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ArticleViewLists article={articleData} />
|
<ArticleViewLists article={article} />
|
||||||
</PanelTileGrid>
|
</PanelTileGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,49 @@
|
|||||||
import { Component } from 'react';
|
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 { StorageManagerContext } from '../../api/storageManagerContext';
|
||||||
import { ArticleView } from '../../components/article';
|
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';
|
import withSkuRoute from './withSkuRoute';
|
||||||
|
|
||||||
class ArtikelSkuPage extends Component {
|
class ArtikelSkuPage extends Component {
|
||||||
static contextType = StorageManagerContext;
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props, context) {
|
||||||
super(props);
|
super(props, context);
|
||||||
this.state = {
|
this.state = {
|
||||||
data: null,
|
ready:
|
||||||
categories: null,
|
context.getArticle(props.sku) != null &&
|
||||||
rootCategoryId: null,
|
context.getCategoryState() != null,
|
||||||
error: null,
|
|
||||||
loading: true,
|
|
||||||
reloadKey: 0,
|
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this._cancelled = false;
|
|
||||||
this.installEscape();
|
this.installEscape();
|
||||||
void this.load();
|
if (!this.state.ready) void this.ensureArticle();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps) {
|
||||||
const { sku, tab: tabParam, navigate } = this.props;
|
if (prevProps.sku !== this.props.sku) {
|
||||||
const { data, reloadKey } = this.state;
|
const ready =
|
||||||
|
this.context.getArticle(this.props.sku) != null &&
|
||||||
if (prevProps.sku !== sku || prevState.reloadKey !== reloadKey) {
|
this.context.getCategoryState() != null;
|
||||||
void this.load();
|
if (ready !== this.state.ready) this.setState({ ready });
|
||||||
return;
|
if (!ready) void this.ensureArticle();
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) return;
|
|
||||||
const resolved = resolveArticleTab(tabParam, data);
|
|
||||||
if (tabParam !== resolved) {
|
|
||||||
navigate(artikelDetailPath(data, resolved), { replace: true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
|
||||||
this.removeEscape();
|
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() {
|
installEscape() {
|
||||||
this._onKeyDown = (e) => {
|
this._onKeyDown = (e) => {
|
||||||
if (e.key !== 'Escape') return;
|
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() {
|
render() {
|
||||||
const { tab: tabParam } = this.props;
|
const { sku, tab } = this.props;
|
||||||
const { data, categories, rootCategoryId, error, loading } = this.state;
|
if (!this.state.ready) return null;
|
||||||
|
return <ArticleView sku={sku} tab={tab} />;
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (isBackendFailure(error)) {
|
|
||||||
return <BackendError error={error} onRetry={this.retry} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Alert severity="error" sx={{ maxWidth: 640 }}>
|
|
||||||
{typeof error === 'string' ? error : error.message}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || categories == null || rootCategoryId == null) return null;
|
|
||||||
|
|
||||||
const tab = resolveArticleTab(tabParam, data);
|
|
||||||
if (tabParam !== tab) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ArticleView
|
|
||||||
article={data}
|
|
||||||
categories={categories}
|
|
||||||
rootCategoryId={rootCategoryId}
|
|
||||||
tab={tab}
|
|
||||||
onTabChange={this.handleTabChange}
|
|
||||||
onArticleUpdated={this.handleArticleUpdated}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user