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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
Reference in New Issue
Block a user