mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
fix(views): board/list load-more no longer flashes the skeleton + friendlier footer (#5893)
* fix(views): board/list load-more stops flashing the full skeleton Tail (load-more) page fetches were folded into the surface-level isLoading through the per-branch aggregate, so scrolling to load more in List and status-grouped Board replaced the already-rendered rows with the full-surface skeleton. Track head-page pending separately and gate the surface isLoading/isRefreshing on heads only; per-branch pagination state that drives the load-more sentinel is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(views): friendlier infinite-scroll footer for board/list/swimlane Collapse four copies of the error/has-more/reached-end ternary into a shared ListLoadMoreFooter: the loading spinner now carries a "Loading…" label so a slow fetch no longer reads as stuck, and a column that actually paginated shows a muted "No more" marker at the end instead of stopping silently. InfiniteScrollSentinel gains an optional label; adds the table.no_more string in en/zh-Hans/ja/ko. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import { BoardColumn, BOARD_CARD_WIDTH, type BoardColumnGroup } from "./board-co
|
||||
import { BoardCardContent } from "./board-card";
|
||||
import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
import { ListLoadMoreFooter } from "./list-load-more-footer";
|
||||
import type { ChildProgress } from "./list-row";
|
||||
import type { IssueCreateDefaults } from "../surface/types";
|
||||
import type {
|
||||
@@ -793,9 +794,12 @@ const PaginatedAssigneeBoardColumn = memo(function PaginatedAssigneeBoardColumn(
|
||||
onCreateIssue={onCreateIssue}
|
||||
sortLabel={sortLabel}
|
||||
footer={
|
||||
hasMore ? (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
) : undefined
|
||||
<ListLoadMoreFooter
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
total={total}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -822,21 +826,16 @@ const ServerPaginatedBoardColumn = memo(function ServerPaginatedBoardColumn({
|
||||
onCreateIssue?: (defaults: IssueCreateDefaults) => void;
|
||||
sortLabel?: string | null;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const footer = page.isError ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-2 text-xs text-destructive hover:underline"
|
||||
onClick={page.retry}
|
||||
>
|
||||
{t(($) => $.table.load_more_failed_retry)}
|
||||
</button>
|
||||
) : page.hasMore ? (
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={page.loadMore}
|
||||
loading={page.isLoading || page.isFetching}
|
||||
const footer = (
|
||||
<ListLoadMoreFooter
|
||||
hasMore={page.hasMore}
|
||||
isLoading={page.isLoading || page.isFetching}
|
||||
total={page.total}
|
||||
onLoadMore={page.loadMore}
|
||||
isError={page.isError}
|
||||
onRetry={page.retry}
|
||||
/>
|
||||
) : undefined;
|
||||
);
|
||||
return (
|
||||
<BoardColumn
|
||||
group={group}
|
||||
@@ -893,9 +892,12 @@ const PaginatedBoardColumn = memo(function PaginatedBoardColumn({
|
||||
onCreateIssue={onCreateIssue}
|
||||
sortLabel={sortLabel}
|
||||
footer={
|
||||
hasMore ? (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
) : undefined
|
||||
<ListLoadMoreFooter
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
total={total}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/** Sentinel that triggers once per visibility transition. Callback/loading
|
||||
@@ -9,11 +9,15 @@ import { Loader2 } from "lucide-react";
|
||||
export function InfiniteScrollSentinel({
|
||||
onVisible,
|
||||
loading,
|
||||
label,
|
||||
rootMargin = "100px",
|
||||
className = "flex items-center justify-center py-2",
|
||||
className = "flex items-center justify-center gap-1.5 py-2",
|
||||
}: {
|
||||
onVisible: () => void;
|
||||
loading: boolean;
|
||||
/** Shown next to the spinner while loading (e.g. "Loading…"). Gives the
|
||||
* bare spinner context so a slow page fetch does not read as "stuck". */
|
||||
label?: ReactNode;
|
||||
rootMargin?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -34,7 +38,14 @@ export function InfiniteScrollSentinel({
|
||||
|
||||
return (
|
||||
<div ref={sentinelRef} className={className} aria-hidden>
|
||||
{loading && <Loader2 className="size-3 animate-spin text-muted-foreground" />}
|
||||
{loading && (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin text-muted-foreground" />
|
||||
{label && (
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
71
packages/views/issues/components/list-load-more-footer.tsx
Normal file
71
packages/views/issues/components/list-load-more-footer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
|
||||
/** Server row-branch page size (use-issue-status-branches /
|
||||
* use-issue-group-branches). A column with no more rows than this never
|
||||
* paginated, so its end carries no "no more" marker — the column is
|
||||
* self-evidently complete. */
|
||||
const PAGINATED_THRESHOLD = 50;
|
||||
|
||||
/**
|
||||
* Unified end-of-column footer for the infinite-scrolled issue surfaces
|
||||
* (Board / List / Swimlane). Collapses four copies of the same
|
||||
* error / has-more / reached-end ternary into one place so the states and
|
||||
* wording stay consistent:
|
||||
* - error → retry
|
||||
* - has more → sentinel + "Loading…" label (bare spinner read as "stuck")
|
||||
* - reached end → muted "No more" marker, but only for columns that actually
|
||||
* paginated (total beyond one page)
|
||||
* - short list → nothing
|
||||
*/
|
||||
export function ListLoadMoreFooter({
|
||||
hasMore,
|
||||
isLoading,
|
||||
total,
|
||||
onLoadMore,
|
||||
isError = false,
|
||||
onRetry,
|
||||
}: {
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
total: number;
|
||||
onLoadMore: () => void;
|
||||
isError?: boolean;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
|
||||
if (isError && onRetry) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-2 text-xs text-destructive hover:underline"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t(($) => $.table.load_more_failed_retry)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMore) {
|
||||
return (
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={onLoadMore}
|
||||
loading={isLoading}
|
||||
label={t(($) => $.table.loading_branch)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (total > PAGINATED_THRESHOLD) {
|
||||
return (
|
||||
<div className="py-2 text-center text-xs text-muted-foreground/70">
|
||||
{t(($) => $.table.no_more)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
import { StatusHeading } from "./status-heading";
|
||||
import { ListRow, DraggableListRow, type ChildProgress } from "./list-row";
|
||||
import { useDragSettle } from "./use-drag-settle";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
import { ListLoadMoreFooter } from "./list-load-more-footer";
|
||||
import { useT } from "../../i18n";
|
||||
import {
|
||||
type DragMoveUpdates,
|
||||
@@ -56,11 +56,6 @@ const LIST_ROW_ESTIMATED_HEIGHT = 36;
|
||||
|
||||
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
|
||||
const EMPTY_IDS: string[] = [];
|
||||
// Passed to <Virtuoso components> when there is no Footer. Must be a STABLE
|
||||
// object, never `undefined`: react-virtuoso seeds `components` with an internal
|
||||
// `{}` default, and an explicit `undefined` prop overwrites that default, so
|
||||
// its startup destructure of `EmptyPlaceholder`/`Footer` throws (MUL-4474).
|
||||
const EMPTY_VIRTUOSO_COMPONENTS = {};
|
||||
|
||||
function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] {
|
||||
return visibleStatuses.map((status) => ({
|
||||
@@ -462,33 +457,22 @@ function StatusAccordionItem({
|
||||
// The infinite-scroll sentinel rides Virtuoso's Footer so it sits at the true
|
||||
// end of the virtualized rows and still fires loadMore when scrolled to it.
|
||||
const listComponents = useMemo(
|
||||
() => {
|
||||
if (page.isError) {
|
||||
return {
|
||||
Footer: () => (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-2 text-xs text-destructive hover:underline"
|
||||
onClick={page.retry}
|
||||
>
|
||||
{t(($) => $.table.load_more_failed_retry)}
|
||||
</button>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (page.hasMore) {
|
||||
return {
|
||||
Footer: () => (
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={page.loadMore}
|
||||
loading={page.isLoading || page.isFetching}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
return EMPTY_VIRTUOSO_COMPONENTS;
|
||||
},
|
||||
[page, t],
|
||||
() => ({
|
||||
// Always a non-undefined object: react-virtuoso throws if `components`
|
||||
// is ever undefined (MUL-4474). The footer itself renders null for a
|
||||
// short, non-paginated section.
|
||||
Footer: () => (
|
||||
<ListLoadMoreFooter
|
||||
hasMore={page.hasMore}
|
||||
isLoading={page.isLoading || page.isFetching}
|
||||
total={page.total}
|
||||
onLoadMore={page.loadMore}
|
||||
isError={page.isError}
|
||||
onRetry={page.retry}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[page],
|
||||
);
|
||||
|
||||
const computeItemKey = (_index: number, issue: Issue) => issue.id;
|
||||
|
||||
@@ -51,6 +51,7 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
import { StatusHeading } from "./status-heading";
|
||||
import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
|
||||
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
|
||||
import { ListLoadMoreFooter } from "./list-load-more-footer";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
@@ -1775,20 +1776,16 @@ function SwimLaneCell({
|
||||
—
|
||||
</p>
|
||||
)}
|
||||
{page?.isError ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-2 text-xs text-destructive hover:underline"
|
||||
onClick={page.retry}
|
||||
>
|
||||
{t(($) => $.table.load_more_failed_retry)}
|
||||
</button>
|
||||
) : page?.hasMore ? (
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={page.loadMore}
|
||||
loading={page.isLoading || page.isFetching}
|
||||
{page && (
|
||||
<ListLoadMoreFooter
|
||||
hasMore={page.hasMore}
|
||||
isLoading={page.isLoading || page.isFetching}
|
||||
total={page.total}
|
||||
onLoadMore={page.loadMore}
|
||||
isError={page.isError}
|
||||
onRetry={page.retry}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
{/* One of these per lane×status cell (~170 on a real swimlane) —
|
||||
eagerly mounted tooltip roots here were the single largest slice
|
||||
|
||||
@@ -59,6 +59,10 @@ interface StatusBranchData {
|
||||
isError: boolean;
|
||||
headUpdatedAt: number;
|
||||
headFetching: boolean;
|
||||
/** The head (cursor === null) page alone is still loading with nothing to
|
||||
* show. Distinct from `isLoading`, which also accumulates tail pages so the
|
||||
* per-branch load-more sentinel can spin. */
|
||||
headPending: boolean;
|
||||
}
|
||||
|
||||
function statusGroupKey(status: IssueStatus) {
|
||||
@@ -228,6 +232,7 @@ export function useIssueStatusBranches({
|
||||
isError: false,
|
||||
headUpdatedAt: 0,
|
||||
headFetching: false,
|
||||
headPending: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,6 +282,7 @@ export function useIssueStatusBranches({
|
||||
if (target.cursor === null) {
|
||||
current.headUpdatedAt = queryResult.dataUpdatedAt;
|
||||
current.headFetching = queryResult.isFetching;
|
||||
current.headPending = queryResult.isPending;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -395,11 +401,15 @@ export function useIssueStatusBranches({
|
||||
);
|
||||
const isTotalKnown = facets !== undefined;
|
||||
const total = facets?.total ?? issues.length;
|
||||
const rowsPending = statuses.some(
|
||||
(status) => branchData.get(status)?.isLoading,
|
||||
// Surface-level loading reflects only each branch's HEAD page. A tail page
|
||||
// (load-more) is pending/fetching while the already-rendered rows stay on
|
||||
// screen, so it must never re-trigger the full-surface skeleton or the
|
||||
// global refreshing indicator — the per-branch sentinel owns "loading more".
|
||||
const headsPending = statuses.some(
|
||||
(status) => branchData.get(status)?.headPending,
|
||||
);
|
||||
const rowsFetching = statuses.some(
|
||||
(status) => branchData.get(status)?.isFetching,
|
||||
const headsFetching = statuses.some(
|
||||
(status) => branchData.get(status)?.headFetching,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -408,11 +418,11 @@ export function useIssueStatusBranches({
|
||||
pagination,
|
||||
total,
|
||||
isTotalKnown,
|
||||
isLoading: enabled && (facetsPending || rowsPending),
|
||||
isLoading: enabled && (facetsPending || headsPending),
|
||||
isRefreshing:
|
||||
enabled &&
|
||||
!facetsPending &&
|
||||
!rowsPending &&
|
||||
(facetsFetching || rowsFetching),
|
||||
!headsPending &&
|
||||
(facetsFetching || headsFetching),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"load_more_failed_retry": "Loading more failed — Retry",
|
||||
"load_more": "Load more",
|
||||
"loading_branch": "Loading…",
|
||||
"no_more": "No more",
|
||||
"value_unavailable": "Unavailable value",
|
||||
"group_property_unavailable": "The grouped property is no longer available. Grouping was cleared.",
|
||||
"hierarchy": "Hierarchy",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"load_more_failed_retry": "続きの読み込みに失敗しました — 再試行",
|
||||
"load_more": "さらに読み込む",
|
||||
"loading_branch": "読み込み中…",
|
||||
"no_more": "これ以上ありません",
|
||||
"value_unavailable": "利用できない値",
|
||||
"group_property_unavailable": "グループ化に使っていたプロパティは利用できないため、グループ化を解除しました。",
|
||||
"hierarchy": "階層",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"load_more_failed_retry": "추가 항목을 불러오지 못했습니다 — 다시 시도",
|
||||
"load_more": "더 불러오기",
|
||||
"loading_branch": "불러오는 중…",
|
||||
"no_more": "더 이상 없음",
|
||||
"value_unavailable": "사용할 수 없는 값",
|
||||
"group_property_unavailable": "그룹화에 사용한 속성을 더 이상 사용할 수 없어 그룹화를 해제했습니다.",
|
||||
"hierarchy": "계층",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"load_more_failed_retry": "加载更多失败——重试",
|
||||
"load_more": "加载更多",
|
||||
"loading_branch": "加载中…",
|
||||
"no_more": "没有更多了",
|
||||
"value_unavailable": "不可用的值",
|
||||
"group_property_unavailable": "用于分组的属性已不可用,分组已清除。",
|
||||
"hierarchy": "层级",
|
||||
|
||||
Reference in New Issue
Block a user