diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 6fe2c27..ec8a681 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -110,7 +110,8 @@ export class ApiClient { history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`), /** * Load history and refetch on SSE `article-`. - * `onHistory(history)` — returns unwatch(). Errors via notifyError. + * Paginates in StorageManager until the HTTP API supports skip/offset. + * `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`. */ historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory), /** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */ diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 4597dd5..c70459a 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -176,19 +176,36 @@ export class StorageManager extends ApiClient { /** * Fetch article history, then refetch whenever SSE emits `article-`. + * Paginates in the store (full list from HTTP; slice via setSkipAndOffset). * @param {string} sku - * @param {(history: unknown[]) => void} onHistory - * @returns {() => void} unwatch + * @param {(history: unknown[], total: number) => void} onHistory + * @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }} */ watchArticleHistory(sku, onHistory) { const key = String(sku); let active = true; + let loaded = false; + /** @type {unknown[]} */ + let all = []; + let skip = 0; + /** Page size (how many rows to emit). */ + let offset = Number.POSITIVE_INFINITY; + + const emit = () => { + if (!active || !loaded) return; + const total = all.length; + const end = + offset === Number.POSITIVE_INFINITY ? total : skip + offset; + onHistory(all.slice(skip, end), total); + }; const refresh = () => this.article.history(key).then((res) => { if (!active) return; requireObject(res, 'GET /api/article/{sku}/history'); - onHistory(requireArray(res.history, 'history')); + all = requireArray(res.history, 'history'); + loaded = true; + emit(); }); const run = () => @@ -211,17 +228,24 @@ export class StorageManager extends ApiClient { run(); - return () => { - active = false; - const set = this.#historyWatchers.get(key); - if (!set) return; - set.delete(run); - if (set.size === 0) { - this.#historyWatchers.delete(key); - if (!this.#articleWatchers.has(key)) { - this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal); + return { + unwatch: () => { + active = false; + const set = this.#historyWatchers.get(key); + if (!set) return; + set.delete(run); + if (set.size === 0) { + this.#historyWatchers.delete(key); + if (!this.#articleWatchers.has(key)) { + this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal); + } } - } + }, + setSkipAndOffset: (nextSkip, nextOffset) => { + skip = nextSkip; + offset = nextOffset; + emit(); + }, }; } diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx index 849da68..dc571e6 100644 --- a/src/components/article/ArticleTileTab.jsx +++ b/src/components/article/ArticleTileTab.jsx @@ -14,10 +14,14 @@ import SettingsIcon from '@mui/icons-material/Settings'; import { ARTICLE_TAB, DEFAULT_ARTICLE_TAB, + artikelDetailPath, articleShowsCompoundTab, + articleTabFromPathname, readPreferredArticleTab, + resolveArticleTab, writePreferredArticleTab, } from '../../pages/artikel/articleRoute'; +import { router } from '../../appRouter'; import { PanelTile } from '../panels'; import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout'; import ArticleAttributesTab from './tabs/ArticleAttributesTab'; @@ -69,30 +73,70 @@ function visibleTabs(article) { return tabs; } +function readRouteTab() { + return articleTabFromPathname(router.state.location.pathname); +} + class ArticleTileTab extends PureComponent { constructor(props) { super(props); this.state = { preferredTab: readPreferredArticleTab(), + tabParam: readRouteTab(), }; this.handleTabChange = this.handleTabChange.bind(this); this.handlePinDefault = this.handlePinDefault.bind(this); + this.syncTabFromRouter = this.syncTabFromRouter.bind(this); + } + + componentDidMount() { + this.unsubRouter = router.subscribe(this.syncTabFromRouter); + this.ensureValidTab(); + } + + componentDidUpdate(prevProps) { + if (prevProps.article !== this.props.article) { + this.ensureValidTab(); + } + } + + componentWillUnmount() { + this.unsubRouter?.(); + this.unsubRouter = null; + } + + syncTabFromRouter() { + const tabParam = readRouteTab(); + if (tabParam !== this.state.tabParam) { + this.setState({ tabParam }, () => this.ensureValidTab()); + } + } + + ensureValidTab() { + const { article } = this.props; + const { tabParam } = this.state; + if (!article) return; + const resolved = resolveArticleTab(tabParam, article); + if (tabParam !== resolved) { + void router.navigate(artikelDetailPath(article, resolved), { replace: true }); + } } handleTabChange(_event, tab) { - this.props.onTabChange?.(tab); + const { article } = this.props; + void router.navigate(artikelDetailPath(article, tab)); } handlePinDefault() { - const { tab } = this.props; + const tab = resolveArticleTab(this.state.tabParam, this.props.article); if (!tab) return; writePreferredArticleTab(tab); this.setState({ preferredTab: tab }); } - renderPanel() { + renderPanel(tab) { const { article: articleData, onArticleDeleted } = this.props; - switch (this.props.tab) { + switch (tab) { case ARTICLE_TAB.history: return ; case ARTICLE_TAB.supplier: @@ -123,9 +167,11 @@ class ArticleTileTab extends PureComponent { wide = 3, minHeight = 200, sx, - tab = DEFAULT_ARTICLE_TAB, } = this.props; - const { preferredTab } = this.state; + const { preferredTab, tabParam } = this.state; + const tab = resolveArticleTab(tabParam, articleData); + if (tabParam !== tab) return null; + const tabs = visibleTabs(articleData); const active = tabs.find((row) => row.id === tab) ?? @@ -270,7 +316,7 @@ class ArticleTileTab extends PureComponent { ...sx, }} > - {this.renderPanel()} + {this.renderPanel(tab)} ); diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 4cf7fae..88fbbbf 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { PureComponent } from 'react'; import { PanelTileGrid } from '../panels'; import { StorageManagerContext } from '../../api/storageManagerContext'; import withNavigate from '../withNavigate'; @@ -6,10 +6,6 @@ import { buildCategoryPath, defaultCategoryPath, } from '../../pages/artikel/categoryRoute'; -import { - artikelDetailPath, - resolveArticleTab, -} from '../../pages/artikel/articleRoute'; import ArticleTileTab from './ArticleTileTab'; import ArticlePhotoTile from './ArticlePhotoTile'; @@ -23,7 +19,7 @@ import ArticleViewLists from './boxes/ArticleViewLists'; /** Stable prop so PureComponent photo tile does not see a new sx each render. */ const PHOTO_TILE_SX = { minHeight: 0 }; -class ArticleView extends Component { +class ArticleView extends PureComponent { static contextType = StorageManagerContext; constructor(props, context) { @@ -36,13 +32,11 @@ class ArticleView extends Component { ); this.state = { article }; this.unwatchArticle = unwatch; - this.handleTabChange = this.handleTabChange.bind(this); this.handleArticleDeleted = this.handleArticleDeleted.bind(this); } componentDidUpdate(prevProps) { - const { sku, tab, navigate } = this.props; - const { article } = this.state; + const { sku } = this.props; if (prevProps.sku !== sku) { this.unwatchArticle?.(); @@ -52,13 +46,6 @@ class ArticleView extends Component { ); this.unwatchArticle = unwatch; this.setState({ article: next }); - return; - } - - if (!article) return; - const resolved = resolveArticleTab(tab, article); - if (tab !== resolved) { - navigate(artikelDetailPath(article, resolved), { replace: true }); } } @@ -81,14 +68,7 @@ class ArticleView extends Component { navigate(defaultCategoryPath()); } - handleTabChange(nextTab) { - const { navigate } = this.props; - const { article } = this.state; - navigate(artikelDetailPath(article, nextTab)); - } - render() { - const { tab } = this.props; const { article } = this.state; const categoryState = this.context.getCategoryState(); const categories = categoryState?.categories ?? null; @@ -96,9 +76,6 @@ class ArticleView extends Component { if (!article || categories == null || rootCategoryId == null) return null; - const resolvedTab = resolveArticleTab(tab, article); - if (tab !== resolvedTab) return null; - return ( @@ -122,8 +99,6 @@ class ArticleView extends Component { wide={3} minHeight={200} article={article} - tab={resolvedTab} - onTabChange={this.handleTabChange} onArticleDeleted={this.handleArticleDeleted} /> diff --git a/src/components/article/tabs/ArticleHistoryTab.jsx b/src/components/article/tabs/ArticleHistoryTab.jsx index dd02bfd..87b722d 100644 --- a/src/components/article/tabs/ArticleHistoryTab.jsx +++ b/src/components/article/tabs/ArticleHistoryTab.jsx @@ -5,6 +5,9 @@ import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import { StorageManagerContext } from '../../../api/storageManagerContext'; +import ResultsPagination from '../../../pages/artikel/ResultsPagination'; + +const PAGE_SIZE = 20; function formatDate(iso) { return new Date(iso).toLocaleString('de-DE'); @@ -15,25 +18,46 @@ export default class ArticleHistoryTab extends Component { constructor(props) { super(props); - this.state = { history: null }; + this.state = { + history: null, + total: null, + skip: 0, + }; this.unwatchHistory = null; + this.setSkipAndOffset = null; + this.handlePageChange = this.handlePageChange.bind(this); } componentDidMount() { - this.unwatchHistory = this.context.article.historyWatch( + const { unwatch, setSkipAndOffset } = this.context.article.historyWatch( this.props.sku, - (history) => this.setState({ history }), + (history, total) => this.setState({ history, total }), ); + this.unwatchHistory = unwatch; + this.setSkipAndOffset = setSkipAndOffset; + setSkipAndOffset(0, PAGE_SIZE); } componentWillUnmount() { this.unwatchHistory?.(); this.unwatchHistory = null; + this.setSkipAndOffset = null; + } + + handlePageChange(_event, page) { + const skip = (page - 1) * PAGE_SIZE; + this.setSkipAndOffset?.(skip, PAGE_SIZE); + this.setState({ skip }); } render() { - const { history } = this.state; - if (history == null) return null; + const { history, total, skip } = this.state; + if (history == null || total == null) return null; + + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const currentPage = Math.floor(skip / PAGE_SIZE) + 1; + const showPagination = total > PAGE_SIZE; + return ( Datum + sx={{ + borderBottom: '1px solid rgba(0, 0, 0, 0.35)', + verticalAlign: 'middle', + }} + > + {showPagination ? ( + + ) : null} + diff --git a/src/pages/artikel/articleRoute.js b/src/pages/artikel/articleRoute.js index 95f85a5..bdd8c42 100644 --- a/src/pages/artikel/articleRoute.js +++ b/src/pages/artikel/articleRoute.js @@ -1,3 +1,5 @@ +import { matchPath } from 'react-router-dom'; + export const ARTICLE_TAB = { history: 'history', supplier: 'supplier', @@ -84,6 +86,12 @@ export function resolveArticleTab(tabParam, article) { return preferredArticleTab(article); } +/** Tab segment from an article-detail pathname, or undefined. */ +export function articleTabFromPathname(pathname) { + const match = matchPath('/artikel/:sku/:tab?', pathname); + return match?.params?.tab; +} + /** * @param {object|string} articleOrSku * @param {string} [tab] omitted → preferred default from localStorage diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx index bc0c0aa..61f9d80 100644 --- a/src/pages/artikel/sku.jsx +++ b/src/pages/artikel/sku.jsx @@ -61,9 +61,9 @@ class ArtikelSkuPage extends Component { } render() { - const { sku, tab } = this.props; + const { sku } = this.props; if (!this.state.ready) return null; - return ; + return ; } }