Expose totalItemsLoading and improve table loading UX

Add totalItemsLoading to EntityListContextProps so consumers can
distinguish a stale count (being refreshed) from a fresh one. Rename
the internal asyncTotalItems to totalItems.

Update CatalogTable to show a dimmed count while the count is loading,
and only show the full-table spinner on the very first load (when no
entities exist yet). Subsequent fetches keep stale rows visible with
a small spinner next to the title.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-05-22 10:57:03 +02:00
parent 9772b28bd8
commit 0e1c7e9504
8 changed files with 70 additions and 58 deletions
+10 -10
View File
@@ -81,20 +81,20 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata';
readonly 'inspectEntityDialog.overviewPage.labels': 'Labels';
readonly 'inspectEntityDialog.overviewPage.status.title': 'Status';
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}';
readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied';
readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more';
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML';
readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.';
readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON';
readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML';
readonly 'inspectEntityDialog.tabNames.overview': 'Overview';
readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry';
readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated';
readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML';
readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?';
readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel';
readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity';
@@ -120,13 +120,13 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'entityTableColumnTitle.label': 'Label';
readonly 'entityTableColumnTitle.title': 'Title';
readonly 'entityTableColumnTitle.description': 'Description';
readonly 'entityTableColumnTitle.domain': 'Domain';
readonly 'entityTableColumnTitle.system': 'System';
readonly 'entityTableColumnTitle.namespace': 'Namespace';
readonly 'entityTableColumnTitle.tags': 'Tags';
readonly 'entityTableColumnTitle.domain': 'Domain';
readonly 'entityTableColumnTitle.owner': 'Owner';
readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle';
readonly 'entityTableColumnTitle.owner': 'Owner';
readonly 'entityTableColumnTitle.targets': 'Targets';
readonly 'entityTableColumnTitle.tags': 'Tags';
readonly 'entityRelationCard.emptyHelpLinkTitle': 'Learn how to change this.';
readonly 'missingAnnotationEmptyState.title': 'Missing Annotation';
readonly 'missingAnnotationEmptyState.readMore': 'Read more';
@@ -666,16 +666,16 @@ export const EntityTableColumnTitle: (
input: EntityTableColumnTitleProps,
) =>
| 'System'
| 'Title'
| 'Domain'
| 'Name'
| 'Description'
| 'Lifecycle'
| 'Namespace'
| 'Owner'
| 'Tags'
| 'Type'
| 'Name'
| 'Description'
| 'Targets'
| 'Title'
| 'Label';
// @alpha (undocumented)
+8 -7
View File
@@ -203,20 +203,20 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata';
readonly 'inspectEntityDialog.overviewPage.labels': 'Labels';
readonly 'inspectEntityDialog.overviewPage.status.title': 'Status';
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}';
readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied';
readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more';
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML';
readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.';
readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON';
readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML';
readonly 'inspectEntityDialog.tabNames.overview': 'Overview';
readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry';
readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated';
readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML';
readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?';
readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel';
readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity';
@@ -242,13 +242,13 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'entityTableColumnTitle.label': 'Label';
readonly 'entityTableColumnTitle.title': 'Title';
readonly 'entityTableColumnTitle.description': 'Description';
readonly 'entityTableColumnTitle.domain': 'Domain';
readonly 'entityTableColumnTitle.system': 'System';
readonly 'entityTableColumnTitle.namespace': 'Namespace';
readonly 'entityTableColumnTitle.tags': 'Tags';
readonly 'entityTableColumnTitle.domain': 'Domain';
readonly 'entityTableColumnTitle.owner': 'Owner';
readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle';
readonly 'entityTableColumnTitle.owner': 'Owner';
readonly 'entityTableColumnTitle.targets': 'Targets';
readonly 'entityTableColumnTitle.tags': 'Tags';
readonly 'entityRelationCard.emptyHelpLinkTitle': 'Learn how to change this.';
readonly 'missingAnnotationEmptyState.title': 'Missing Annotation';
readonly 'missingAnnotationEmptyState.readMore': 'Read more';
@@ -466,6 +466,7 @@ export type EntityListContextProps<
prev?: () => void;
};
totalItems?: number;
totalItemsLoading: boolean;
limit: number;
offset?: number;
setLimit: (limit: number) => void;
+1
View File
@@ -73,6 +73,7 @@ export function MockEntityListContextProvider<
error: value?.error,
totalItems:
value?.totalItems ?? (value?.entities ?? defaultValues.entities).length,
totalItemsLoading: value?.totalItemsLoading ?? false,
limit: value?.limit ?? 20,
offset: value?.offset,
setLimit: value?.setLimit ?? (() => {}),
@@ -1239,6 +1239,7 @@ describe('versioned context', () => {
updateFilters: jest.fn(),
queryParameters: {},
loading: true,
totalItemsLoading: false,
limit: 277,
setLimit: jest.fn(),
setOffset: jest.fn(),
@@ -121,6 +121,7 @@ export type EntityListContextProps<
prev?: () => void;
};
totalItems?: number;
totalItemsLoading: boolean;
limit: number;
offset?: number;
setLimit: (limit: number) => void;
@@ -350,25 +351,26 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
// decoupled from the main list fetch so that page navigation doesn't
// re-run the expensive count query, and so that the count can arrive
// asynchronously without blocking the list response.
const [{ value: asyncTotalItems }, refreshCount] = useAsyncFn(async () => {
if (paginationMode === 'none') {
return undefined;
}
const compacted = compact(Object.values(adjustedFilters));
if (compacted.length === 0) {
return undefined;
}
const backendFilter = reduceCatalogFilters(compacted);
try {
const response = await catalogApi.queryEntities({
...backendFilter,
limit: 0,
});
return response.totalItems;
} catch {
return undefined;
}
}, [catalogApi, paginationMode, adjustedFilters]);
const [{ value: totalItems, loading: totalItemsLoading }, refreshCount] =
useAsyncFn(async () => {
if (paginationMode === 'none') {
return undefined;
}
const compacted = compact(Object.values(adjustedFilters));
if (compacted.length === 0) {
return undefined;
}
const backendFilter = reduceCatalogFilters(compacted);
try {
const response = await catalogApi.queryEntities({
...backendFilter,
limit: 0,
});
return response.totalItems;
} catch {
return undefined;
}
}, [catalogApi, paginationMode, adjustedFilters]);
useDebounce(refreshCount, 10, [adjustedFilters]);
@@ -475,7 +477,8 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
totalItems:
paginationMode === 'none'
? entities.length
: asyncTotalItems ?? backendState.totalItems,
: totalItems ?? backendState.totalItems,
totalItemsLoading: paginationMode !== 'none' && totalItemsLoading,
limit,
offset,
setLimit,
@@ -486,7 +489,8 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
requestedFilters,
entities,
backendState,
asyncTotalItems,
totalItems,
totalItemsLoading,
updateFilters,
queryParameters,
loading,
@@ -73,6 +73,7 @@ export function MockEntityListContextProvider<
error: value?.error,
totalItems:
value?.totalItems ?? (value?.entities ?? defaultValues.entities).length,
totalItemsLoading: value?.totalItemsLoading ?? false,
limit: value?.limit ?? 20,
offset: value?.offset,
setLimit: value?.setLimit ?? (() => {}),
@@ -126,7 +126,8 @@ describe('CatalogTable component', () => {
},
},
);
expect(screen.getByText(/Owned Components \(3\)/)).toBeInTheDocument();
expect(screen.getByText(/Owned Components/)).toBeInTheDocument();
expect(screen.getByText(/\(3\)/)).toBeInTheDocument();
expect(screen.getByText(/component1/)).toBeInTheDocument();
expect(screen.getByText(/component2/)).toBeInTheDocument();
expect(screen.getByText(/component3/)).toBeInTheDocument();
@@ -110,16 +110,15 @@ export const CatalogTable = (props: CatalogTableProps) => {
filters,
pageInfo,
totalItems,
totalItemsLoading,
paginationMode,
} = entityListContext;
// For non-paginated tables, only show the full loading indicator when
// there's no data yet (initial load). During filter changes we keep stale
// data visible and let the new results swap in seamlessly. For paginated
// tables we always show loading, since stale data from a different page
// would be misleading.
const isLoading =
paginationMode === 'none' ? loading && entities.length === 0 : loading;
// Only show the full-table loading spinner on the very first load when
// there's no data yet. During subsequent fetches (filter changes, page
// navigation) we keep stale rows visible so the user sees content
// immediately — a small spinner next to the title signals the refresh.
const isLoading = loading && entities.length === 0;
const tableColumns = useMemo(
() =>
@@ -202,22 +201,26 @@ export const CatalogTable = (props: CatalogTableProps) => {
const titlePreamble = capitalize(
filters.user?.value ?? t('catalogTable.allFilters'),
);
const titleText =
const titleBase =
props.title ||
[titlePreamble, currentType, pluralize(currentKind), currentCount]
[titlePreamble, currentType, pluralize(currentKind)]
.filter(s => s)
.join(' ');
const title =
loading && !isLoading ? (
<span
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5em' }}
>
{titleText}
const title = (
<span
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5em' }}
>
{titleBase}
{currentCount && (
<span style={{ opacity: totalItemsLoading ? 0.5 : 1 }}>
{currentCount}
</span>
)}
{loading && !isLoading && (
<CircularProgress size="0.8em" data-testid="loading-indicator" />
</span>
) : (
titleText
);
)}
</span>
);
const actions = props.actions || defaultActions;
const options: TableProps['options'] = {