Skip to content

Commit 291ad12

Browse files
committed
fix(catalogs): deep-link item pinned to top + injected beyond first page
Catalog deep-links (?item=Y) loaded the preview but left the list card out of view or missing entirely. Two fixes: - Browser scroll effect now depends on sortedItems (rows don't exist until the page loads) and pins the item to the top on first arrival; later click/keyboard selections keep non-disruptive 'nearest'. - When the deep-linked item lives beyond the first page, fetch it via getCatalogItem and prepend it so the card + preview render without manual 'Load more'. A separate offsetRef tracks the real backend cursor (injection no longer shifts the offset/skips an item) and loadMore dedups the injected copy when its natural page loads.
1 parent dd952f2 commit 291ad12

2 files changed

Lines changed: 76 additions & 9 deletions

File tree

frontend/src/components/catalog/CatalogBrowser.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ export const CatalogBrowser: React.FC<CatalogBrowserProps> = ({
165165
const currentUserId = useAuthStore((s) => s.user?.id ?? null);
166166
const locale: Locale = i18n.language.startsWith('el') ? el : enUS;
167167
const scrollRef = useRef<HTMLDivElement | null>(null);
168+
// Tracks whether we've performed the initial scroll for this mount. The
169+
// workspace remounts the browser per catalog type (`key={active.type}`), so
170+
// the first scroll always corresponds to a deep-link arrival (?item= set
171+
// before the rows render) and pins the item to the top; later selections
172+
// (click / keyboard) only nudge into view so they don't jump the list.
173+
const didInitialScrollRef = useRef(false);
168174

169175
const [sortBy, setSortBy] = useState<SortKey>('name');
170176
const [sortDir, setSortDir] = useState<SortDir>('asc');
@@ -223,15 +229,22 @@ export const CatalogBrowser: React.FC<CatalogBrowserProps> = ({
223229
}
224230
};
225231

226-
// Keep the selected row in view (also helps keyboard nav). `nearest` only
227-
// scrolls when the row is off-screen, so click selection isn't disturbed.
232+
// Keep the selected row in view (also helps keyboard nav). Depends on
233+
// `sortedItems` too, because on a deep-link the rows don't exist in the DOM
234+
// until the first page loads — without this dep the effect would run once
235+
// against an empty list and never scroll once the rows arrive. On the first
236+
// scroll (deep-link arrival) the item is pinned to the top of the list;
237+
// afterwards `nearest` keeps click/keyboard selection non-disruptive.
228238
useEffect(() => {
229239
if (!selectedItemId || !scrollRef.current) return;
230240
const el = scrollRef.current.querySelector(
231241
`[data-item-id="${CSS.escape(selectedItemId)}"]`,
232-
);
233-
el?.scrollIntoView({ block: 'nearest' });
234-
}, [selectedItemId]);
242+
) as HTMLElement | null;
243+
if (!el) return;
244+
const isFirst = !didInitialScrollRef.current;
245+
didInitialScrollRef.current = true;
246+
el.scrollIntoView({ block: isFirst ? 'start' : 'nearest' });
247+
}, [selectedItemId, sortedItems]);
235248

236249
return (
237250
<div className="flex flex-col h-full min-h-0 gap-2">

frontend/src/pages/Catalogs/CatalogWorkspace.tsx

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* Registry-driven: the catalog types come from `GET /catalogs`. The "concept"
1717
* type links out to the dedicated Taxonomy Manager.
1818
*/
19-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
19+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2020
import { useSearchParams } from 'react-router-dom';
2121
import { Database, ArrowLeft, Info as InfoIcon, Edit3, History as HistoryIcon, ExternalLink, Trash2, RotateCcw, List as ListIcon, GitBranch } from 'lucide-react';
2222
import PageHeader from '../../components/ui/PageHeader';
@@ -48,6 +48,7 @@ import { ScopeBadge } from '../../components/catalog/ScopeBadge';
4848
import {
4949
listCatalogTypes,
5050
listCatalogItems,
51+
getCatalogItem,
5152
createCatalogItem,
5253
updateCatalogItem,
5354
deleteCatalogItem,
@@ -197,6 +198,16 @@ export const CatalogWorkspace: React.FC = () => {
197198
const PAGE_SIZE = 50;
198199
const isMineScope = scopeFilter === 'mine';
199200

201+
// Backend pagination cursor (how many items the server has handed us),
202+
// tracked separately from `items.length` because deep-link injection (see
203+
// the effect below) prepends an out-of-page item. Using items.length as the
204+
// next offset in that case would skip a real item, so load/loadMore advance
205+
// this ref from the server response sizes instead.
206+
const offsetRef = useRef(0);
207+
// Item ids that 404/403'd on the single-item fetch — don't keep retrying the
208+
// injection on every list mutation. Cleared on catalog-type change.
209+
const knownMissingRef = useRef<Set<string>>(new Set());
210+
200211
// Server-side facet params (kind for concepts, class for anatomy/…).
201212
// Stringified for a stable dependency so client-side facet toggles don't
202213
// trigger a refetch — only server-facet changes do.
@@ -217,9 +228,11 @@ export const CatalogWorkspace: React.FC = () => {
217228
});
218229
setItems(resp.items);
219230
setTotal(resp.total);
231+
offsetRef.current = resp.items.length;
220232
} catch {
221233
setItems([]);
222234
setTotal(0);
235+
offsetRef.current = 0;
223236
} finally {
224237
setItemsLoading(false);
225238
}
@@ -238,21 +251,61 @@ export const CatalogWorkspace: React.FC = () => {
238251
...catalogFilter.serverParams,
239252
include: 'relations',
240253
limit: PAGE_SIZE,
241-
offset: items.length,
254+
offset: offsetRef.current,
255+
});
256+
offsetRef.current += resp.items.length;
257+
// Dedup against what we already show: a deep-linked item is injected at
258+
// the top of the list, so its natural copy would reappear on its real
259+
// page — drop it there to avoid showing the card twice.
260+
setItems((prev) => {
261+
const seen = new Set(prev.map((it) => String(it.id)));
262+
const fresh = resp.items.filter((it) => !seen.has(String(it.id)));
263+
return fresh.length ? [...prev, ...fresh] : prev;
242264
});
243-
setItems((prev) => [...prev, ...resp.items]);
244265
} catch {
245266
/* ignore — keep what we have */
246267
} finally {
247268
setLoadingMore(false);
248269
}
249270
// eslint-disable-next-line react-hooks/exhaustive-deps
250-
}, [activeType, scopeFilter, serverFilterKey, items.length, loadingMore, isMineScope]);
271+
}, [activeType, scopeFilter, serverFilterKey, loadingMore, isMineScope]);
251272

252273
useEffect(() => {
253274
load();
254275
}, [load]);
255276

277+
// Deep-link support: when arriving with ?item=Y and Y isn't in the loaded
278+
// page (it lives on a later page), fetch it directly and prepend it so the
279+
// preview + list can render it without the user paging through "Load more".
280+
// Runs after `load` settles and re-checks after every items mutation; the
281+
// presence guard + knownMissingRef keep it from looping or refetching.
282+
useEffect(() => {
283+
if (!activeType || !itemId || itemsLoading) return;
284+
if (knownMissingRef.current.has(itemId)) return;
285+
if (items.some((it) => String(it.id) === itemId)) return;
286+
let cancelled = false;
287+
(async () => {
288+
try {
289+
const single = await getCatalogItem(activeType, itemId);
290+
if (cancelled || !single || single.id == null) return;
291+
const asItem = single as unknown as CatalogItem;
292+
setItems((prev) =>
293+
prev.some((it) => String(it.id) === String(asItem.id))
294+
? prev
295+
: [asItem, ...prev],
296+
);
297+
} catch (e: any) {
298+
// 404/403 — the item is gone or not visible to this user. Record it so
299+
// we don't refetch on every subsequent list mutation.
300+
const status = e?.response?.status;
301+
if (status === 404 || status === 403) knownMissingRef.current.add(itemId);
302+
}
303+
})();
304+
return () => {
305+
cancelled = true;
306+
};
307+
}, [activeType, itemId, items, itemsLoading]);
308+
256309
/** In-memory filters: 'mine' ownership + page-search + facet filters, over the loaded items. */
257310
const filteredItems = useMemo(() => {
258311
let result = items;
@@ -280,6 +333,7 @@ export const CatalogWorkspace: React.FC = () => {
280333
useEffect(() => {
281334
setTab(INFO);
282335
clearAll();
336+
knownMissingRef.current.clear();
283337
}, [activeType, clearAll]);
284338

285339
// Anatomy-only: fetch the anatomy_class concepts that back the (server-side)

0 commit comments

Comments
 (0)