diff --git a/ui/src/components/ContentMapper/assetMapper.tsx b/ui/src/components/ContentMapper/assetMapper.tsx index 89fd69a8..0957a8c4 100644 --- a/ui/src/components/ContentMapper/assetMapper.tsx +++ b/ui/src/components/ContentMapper/assetMapper.tsx @@ -1,5 +1,5 @@ // Libraries -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { @@ -37,6 +37,9 @@ import { computeChangedUids, } from './assetMapper.utils'; +// Hooks +import { useMeasuredTableHeight } from './useMeasuredTableHeight'; + // Styles and Assets import './index.scss'; @@ -64,10 +67,18 @@ const AssetMapper = ({ // stranded on the full-page empty state with no way to clear the search. const [searchText, setSearchText] = useState(''); + const tableWrapperRef = useRef(null); + useEffect(() => { fetchAssets('', { seedSelection: true }); }, []); + // Responsive table height for the asset mapper — see useMeasuredTableHeight for the why. + const tableHeight = useMeasuredTableHeight(tableWrapperRef, [tableData?.length], { + panelSelector: '.TablePanel', + footerSelector: '.mapper-footer', + }); + // Single server-paginated fetch (same pattern as entryMapper's fetchEntries). The // Venus table drives paging by calling fetchData with { skip, limit, searchText }; // we ask the API for just that page and use the returned `count` as the grand total. @@ -277,10 +288,9 @@ const AssetMapper = ({ version="v2" testId="no-results-found-page" /> : -
+
{ ), accessor: accessorCall, id: 'uuid', - width: '250px', + width: '340px', }, { disableSortBy: true, Header: {`${newMigrationData?.legacy_cms?.selectedCms?.title} UIDs:`}, accessor: accessorForCMSUid, - id: '1' + id: '1', + width: '360px', }, { disableSortBy: true, @@ -493,11 +495,11 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { } ]; - // Must match the .Table__body height in index.scss so the react-window list is exactly - // as tall as the scroll body. Leave ~140px below for the search row, pagination bar and - // Save footer; the body scrolls internally so all rows of a page stay reachable. - const calcHeight = () => window.innerHeight - 520; - const tableHeight = calcHeight(); + // Responsive table height for the entry mapper — see useMeasuredTableHeight for the why. + const tableHeight = useMeasuredTableHeight(tableWrapperRef, [contentTypeUid, tableData?.length], { + panelSelector: '.TablePanel', + footerSelector: '.mapper-footer', + }); return ( isLoading || newMigrationData?.isprojectMapped @@ -619,82 +621,85 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { {/* Entry Mapping Table */}
-
-
0 ? ' has-locale-select' : ''}`}> - {localeOptions?.length > 0 && ( -
- setSelectedLocale(opt)} + isSearchable={false} + isClearable={false} + placeholder="Select locale" + width="240px" + version="v2" + /> +
+ )} +
- } - /> - {(totalCounts > 0 || (tableData?.length ?? 0) > 0) && ( -
-
- {/* Total Entries: {totalCounts} */} -
- + key={contentTypeUid || 'entry-mapper-table'} + loading={loading} + canSearch={true} + totalCounts={totalCounts ?? 0} + data={[...tableData]} + columns={columns} + uniqueKey={'id'} + isRowSelect={true} + fullRowSelect={true} + fetchTableData={fetchData} + tableHeight={tableHeight} + equalWidthColumns={false} + columnSelector={false} + v2Features={{ pagination: true, isNewEmptyState: true }} + rowPerPageOptions={[10, 30, 50, 100]} + minBatchSizeToFetch={30} + initialRowSelectedData={initialRowSelectedData} + initialSelectedRowIds={rowIds} + itemSize={70} + getSelectedRow={handleSelectedEntries} + rowSelectCheckboxProp={{ key: '_canSelect', value: true }} + name={{ + singular: '', + plural: `${totalCounts === 0 ? 'Count' : ''}` + }} + customEmptyState={ + + } + /> + {(totalCounts > 0 || (tableData?.length ?? 0) > 0) && ( +
+
+ {/* Total Entries: {totalCounts} */}
- )} + +
+ )}
diff --git a/ui/src/components/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index c8987d35..57193a51 100644 --- a/ui/src/components/ContentMapper/index.scss +++ b/ui/src/components/ContentMapper/index.scss @@ -128,6 +128,16 @@ max-width: 75%; width: 100%; padding: 0; + min-height: 0; + + // The table wrapper fills the fields column so its child .entry-mapper-table + // has a bounded height to flex into (keeps pagination + Save footer in view on zoom). + .entry-table-wrapper.entry-mapper-container { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } } .cta-wrapper { border-top: 1px solid $color-base-gray-40; @@ -138,7 +148,13 @@ position: relative; } .table-container { - flex: 1 0 auto; + flex: 1 1 auto; + min-height: 0; + // Panels sit side by side and must stretch to the container's full height so the + // fields column (and its internally-scrolling table) is height-bounded, not + // content-sized. align-content:stretch keeps a single flex line filling the height. + align-items: stretch; + align-content: stretch; } .table-wrapper { flex: 1; @@ -486,10 +502,7 @@ div .table-row { // Asset mapper table: venus's .Table sets min-height: 31.25rem, forcing the table // tall and pushing the pagination bar down with a dead gap. Pin an explicit height // so the table + pagination end where we want and the Save button can sit below. -.entry-asset-mapper .step-container .Table { - height: 400px !important; - min-height: 0px !important; -} + .custom-empty-state { .Icon--original { @@ -531,8 +544,17 @@ div .table-row { .entry-asset-mapper { display: flex; flex-direction: column; - height: 100%; + // Match the usable step-content area: .stepContent is height:calc(100vh - 56px) with a + // 190px top padding for the fixed stepper header, so the real space below it is + // calc(100vh - 246px). Pinning to that makes this subtree self-bounding (the stepper + // tree above isn't flex, so height:100% grew to content height on zoom and pushed the + // Save footer off with no scroll). Inner flex distributes the rest; the body scrolls + // and pagination + Save stay pinned. + height: calc(100vh - 246px); + max-height: calc(100vh - 246px); width: 100%; + overflow: hidden; + min-height: 0; // The active child mapper (its own .step-container) fills the space left by the toggle. > .step-container { @@ -633,10 +655,19 @@ div .table-row { // search row, pagination bar and Save footer. // Entry mapper only — the asset mapper lets venus size the body from its measured // tableHeight prop, and this !important would override those inline styles. + // Let the scroll body flex to fill the space .entry-mapper-table hands it, rather + // than a fixed calc(100vh - …). 100vh doesn't shrink on zoom, so a fixed height keeps + // the body full-size and shoves the pagination bar under/past the Save footer. Flexing + // makes the body shrink with the available height so pagination + footer stay in view. + // The body height is driven by react-window via the measured `tableHeight` prop (see + // entryMapper.tsx) so its native scroll works. Don't override height here — just make it + // the flex item that takes the leftover space and never forces the .Table taller than its + // container (which would push pagination/footer off-screen). &:not(.asset-mapper-container) .Table__body, &:not(.asset-mapper-container) .Table.TableWithPaginated .Table__body { - height: calc(100vh - 520px) !important; - max-height: calc(100vh - 520px) !important; + flex: 0 0 auto !important; + min-height: 0 !important; + overflow-y: auto !important; } // Empty state ("No Records Found"): venus renders a plain .Table__body with no height, @@ -674,16 +705,27 @@ div .table-row { } } - // Target actual column containers + // Target actual column containers. Honour the per-column `width` venus applies inline + // from the `columns` config (flex-basis: auto reads that width) as the STARTING size, + // instead of forcing every column to an equal `flex: 1 1 0`. flex-shrink:1 (+ min-width:0) + // lets the fixed-width columns give way on narrow viewports so the row never exceeds the + // available width and forces a horizontal scrollbar; they hold their width when there's room. .Table__head__column, .Table__body__column { - flex: 1 1 0 !important; + flex: 0 1 auto; min-width: 0 !important; display: flex !important; align-items: center; border-right: 1px solid $color-brand-secondary-lightest !important; } + // The final column has no fixed width in the config — let it absorb the leftover width + // so the row still spans the full table. + .Table__head__column:last-of-type, + .Table__body__column:last-of-type { + flex: 1 1 auto; + } + // Long filenames/paths must wrap and stay inside their own column instead // of overflowing into the next one. min-width:0 above lets the flex item // actually shrink; these make the inner content honour the column width. @@ -706,3 +748,80 @@ div .table-row { } } } + +// Asset mapper table — responsive like the entry mapper. Flex column that fills the +// bounded step area; the venus table is content-sized (its body height comes from the +// measured `tableHeight` prop in assetMapper.tsx, not CSS, so react-window's native scroll +// works), and the Save footer stays pinned below. +.asset-mapper-table { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + + // Size to content, not flex-grow — flex-grow would balloon the body and, since we measure + // to set tableHeight, create a runaway loop. + .Table { + flex: 0 0 auto; + height: auto !important; + min-height: 0 !important; + display: flex; + flex-direction: column; + } + + .Table__body { + flex: 0 0 auto !important; + min-height: 0 !important; + overflow-y: auto !important; + } + + .TableFooter, + .Pagination, + .Table__pagination { + flex: 0 0 auto; + } + + .mapper-footer { + flex: 0 0 auto; + position: static; + } +} + +// Make the entry-mapper table area responsive so the pagination bar never slides +// under (or past) the Save footer when the effective viewport shrinks on zoom. +// It's a flex column that owns all the vertical space between the locale/search row +// and the footer; the table grows/shrinks to fit and its body scrolls internally, +// while the footer stays pinned at the bottom. +.entry-mapper-table { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + + // Size to content, NOT flex-grow. react-window sizes the body from the measured + // `tableHeight` prop (entryMapper.tsx); letting .Table flex-grow would balloon the body + // and, since we measure to set that prop, create a runaway loop. Content-sized keeps + // .Table = body(tableHeight) + panel + pagination, leaving room for the footer below. + .Table { + flex: 0 0 auto; + min-height: 0 !important; + height: auto !important; + display: flex; + flex-direction: column; + } + + // Keep pagination pinned just above the footer regardless of table height. + .TableFooter, + .Pagination, + .Table__pagination { + flex: 0 0 auto; + } + + // Footer never gets pushed off-screen — it's the last flex child. + .mapper-footer { + flex: 0 0 auto; + position: static; + } +} \ No newline at end of file diff --git a/ui/src/components/ContentMapper/useMeasuredTableHeight.ts b/ui/src/components/ContentMapper/useMeasuredTableHeight.ts new file mode 100644 index 00000000..1b0e559c --- /dev/null +++ b/ui/src/components/ContentMapper/useMeasuredTableHeight.ts @@ -0,0 +1,101 @@ +import { RefObject, useEffect, useState } from 'react'; + +/** + * Measures the height to hand to InfiniteScrollTable's `tableHeight` prop for the + * delta-iteration mappers (Entry / Asset). + * + * react-window sizes its virtual scroll viewport from that JS number, not CSS. The layout + * caps the mapper to the available step-content area, so we measure the bounded outer box + * (.entry-asset-mapper) minus the fixed chrome (toggle, search/panel row, pagination bar, + * Save footer) and feed that back — otherwise the table renders a fixed-height viewport that + * overruns the viewport on zoom and pushes pagination + Save off-screen. + * + * Anchoring on the OUTER box (never on .Table/.Table__body) is deliberate: those are sized BY + * react-window from this very number, so reading them would create a runaway feedback loop. + * + * All DOM reads are scoped to `wrapperRef` (the mapper's own wrapper element) rather than a + * global document.querySelector, so a missing node is contained to this subtree and — for + * the chrome elements we can't find — surfaces a dev-only warning instead of silently falling + * back to the magic constants. + */ +export interface MeasuredTableHeightOptions { + /** Selector for the table's search/panel row, resolved within `wrapperRef`. */ + panelSelector: string; + /** Selector for the Save footer, resolved within `wrapperRef`. */ + footerSelector: string; +} + +// Fixed chrome fallbacks, used only until the real elements are mounted/measured. +const PANEL_FALLBACK = 64; +const FOOTER_FALLBACK = 65; +const PAGINATION_AND_BUFFER = 56; // pagination bar (fixed height) + small buffer +const MIN_USABLE_HEIGHT = 80; // floor for the table body so it never collapses/goes negative +// Fallback for the bounded box height (matches .entry-asset-mapper's calc(100vh - 246px) cap), +// used only when the box isn't mounted/measured yet. +const BOX_HEIGHT_FALLBACK = 246; +// Reserve when no chrome is measured yet (toggle defaults to 0). Same terms as measure()'s +// `reserve` so the pre-measure guess uses the hook's own box model, not a stray constant. +const RESERVE_FALLBACK = PANEL_FALLBACK + FOOTER_FALLBACK + PAGINATION_AND_BUFFER; +const BOX_SELECTOR = '.entry-asset-mapper'; +const TOGGLE_SELECTOR = '.mapper-view-toggle'; + +export function useMeasuredTableHeight( + wrapperRef: RefObject, + deps: unknown[], + { panelSelector, footerSelector }: MeasuredTableHeightOptions, +): number { + // Pre-measure guess: same model as measure() (box fallback − reserve), clamped to the floor + // so the one frame react-window renders before the effect runs never gets a negative height. + const [tableHeight, setTableHeight] = useState(() => + Math.max(MIN_USABLE_HEIGHT, window.innerHeight - BOX_HEIGHT_FALLBACK - RESERVE_FALLBACK), + ); + + useEffect(() => { + const measure = () => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + // The bounded box is a shared ancestor of the mapper; reach it from the wrapper so the + // read stays scoped and doesn't grab the wrong mapper's box if both are ever mounted. + const box = wrapper.closest(BOX_SELECTOR) as HTMLElement | null; + const toggle = box?.querySelector(TOGGLE_SELECTOR) as HTMLElement | null; + const panel = wrapper.querySelector(panelSelector) as HTMLElement | null; + const footer = wrapper.querySelector(footerSelector) as HTMLElement | null; + + if (import.meta.env.DEV) { + // A rename/markup change in venus would drop us to the magic constants and quietly + // regress the layout — warn loudly in dev so it's caught rather than shipped. + if (!box) console.warn(`useMeasuredTableHeight: "${BOX_SELECTOR}" not found — falling back.`); + if (!panel) console.warn(`useMeasuredTableHeight: "${panelSelector}" not found — using ${PANEL_FALLBACK}px fallback.`); + if (!footer) console.warn(`useMeasuredTableHeight: "${footerSelector}" not found — using ${FOOTER_FALLBACK}px fallback.`); + } + + // `||` not `??`: a momentarily 0-height box (measured before layout settles) should + // hit the fallback too, otherwise boxH is 0 and `avail` goes negative. + const boxH = box?.clientHeight || window.innerHeight - BOX_HEIGHT_FALLBACK; + const reserve = + (toggle?.offsetHeight ?? 0) + + (panel?.offsetHeight ?? PANEL_FALLBACK) + + (footer?.offsetHeight ?? FOOTER_FALLBACK) + + PAGINATION_AND_BUFFER; + // Clamp rather than skip: at extreme zoom `avail` can dip low, but keeping the previous + // (possibly large) value would re-expose the overflow this hook exists to prevent. + setTableHeight(Math.max(MIN_USABLE_HEIGHT, Math.floor(boxH - reserve))); + }; + + measure(); + const box = wrapperRef.current?.closest(BOX_SELECTOR) as HTMLElement | null; + const ro = new ResizeObserver(measure); + // Observe the bounded box when we can (it's what actually resizes); fall back to the + // wrapper so we still react to layout changes if the box lookup misses. + ro.observe(box ?? wrapperRef.current ?? document.body); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return tableHeight; +} \ No newline at end of file