Skip to content

Commit 339e200

Browse files
authored
Merge pull request #396 from auth0/feat/integration-data-refresh-domains-table
feat: integrate data refresh indicator into domains table
2 parents d717fbb + ff84740 commit 339e200

10 files changed

Lines changed: 181 additions & 24 deletions

File tree

packages/react/src/components/auth0/my-organization/__tests__/domain-table.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,73 @@ describe('DomainTable', () => {
677677
});
678678
});
679679
});
680+
681+
describe('refresh indicator', () => {
682+
it('should render the refresh control once domains data is loaded and stale', async () => {
683+
renderWithProviders(<DomainTable {...createMockDomainTableProps()} />);
684+
685+
await waitForComponentToLoad();
686+
687+
expect(screen.getByRole('button', { name: 'refresh' })).toBeInTheDocument();
688+
});
689+
690+
it('should refetch domains when the refresh button is clicked', async () => {
691+
const user = userEvent.setup();
692+
const apiService = mockCoreClient.getMyOrganizationApiClient();
693+
const listDomains = apiService.organization.domains.list as ReturnType<typeof vi.fn>;
694+
695+
renderWithProviders(<DomainTable {...createMockDomainTableProps()} />);
696+
697+
await waitForComponentToLoad();
698+
expect(listDomains).toHaveBeenCalledTimes(1);
699+
await user.click(screen.getByRole('button', { name: 'refresh' }));
700+
701+
await waitFor(() => {
702+
expect(listDomains).toHaveBeenCalledTimes(2);
703+
});
704+
});
705+
706+
it('should show the last updated label once domains data is loaded', async () => {
707+
renderWithProviders(<DomainTable {...createMockDomainTableProps()} />);
708+
await waitForComponentToLoad();
709+
expect(screen.getByText('last_updated', { exact: false })).toBeInTheDocument();
710+
});
711+
712+
it('should disable the refresh button while a refetch is in flight, then re-enable it', async () => {
713+
const user = userEvent.setup();
714+
const apiService = mockCoreClient.getMyOrganizationApiClient();
715+
const listDomains = apiService.organization.domains.list as ReturnType<typeof vi.fn>;
716+
717+
renderWithProviders(<DomainTable {...createMockDomainTableProps()} />);
718+
719+
await waitForComponentToLoad();
720+
721+
const refreshButton = screen.getByRole('button', { name: 'refresh' });
722+
expect(refreshButton).toBeEnabled();
723+
724+
let resolveRefetch: (value: unknown) => void = () => {};
725+
listDomains.mockImplementationOnce(
726+
() =>
727+
new Promise((resolve) => {
728+
resolveRefetch = resolve;
729+
}),
730+
);
731+
732+
await user.click(refreshButton);
733+
734+
await waitFor(() => {
735+
expect(screen.getByRole('button', { name: 'refresh' })).toBeDisabled();
736+
});
737+
738+
resolveRefetch({
739+
response: { organization_domains: [mockDomain, mockVerifiedDomain] },
740+
});
741+
742+
await waitFor(() => {
743+
expect(screen.getByRole('button', { name: 'refresh' })).toBeEnabled();
744+
});
745+
});
746+
});
680747
});
681748

682749
describe('DomainTableView', () => {

packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ describe('OrganizationMemberManagement', () => {
610610
expect(refetchMembers).not.toHaveBeenCalled();
611611
});
612612

613-
it('does not render the refresh control when the active tab data is not stale', () => {
613+
it('renders a disabled refresh control when the active tab data is not stale', () => {
614614
mockedUseOrganizationMemberManagement.mockReturnValue(
615615
createMockMemberManagementResult({
616616
activeTab: 'invitations',
@@ -620,7 +620,7 @@ describe('OrganizationMemberManagement', () => {
620620

621621
renderWithProviders(<OrganizationMemberManagement {...createMockComponentProps()} />);
622622

623-
expect(screen.queryByRole('button', { name: 'refresh' })).not.toBeInTheDocument();
623+
expect(screen.getByRole('button', { name: 'refresh' })).toBeDisabled();
624624
});
625625
});
626626
});

packages/react/src/components/auth0/my-organization/domain-table.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import { DataPagination } from '@/components/auth0/shared/data-pagination';
1313
import { DataTable, type Column } from '@/components/auth0/shared/data-table';
1414
import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper';
1515
import { Header } from '@/components/auth0/shared/header';
16+
import { RefreshIndicator } from '@/components/auth0/shared/refresh-indicator';
1617
import { StyledScope } from '@/components/auth0/shared/styled-scope';
1718
import { Badge } from '@/components/ui/badge';
1819
import { useDomainTable } from '@/hooks/my-organization/use-domain-table';
1920
import { useTelemetry } from '@/hooks/shared/use-telemetry';
2021
import { useTheme } from '@/hooks/shared/use-theme';
2122
import { useTranslator } from '@/hooks/shared/use-translator';
2223
import { DEFAULT_PAGE_SIZE_OPTIONS } from '@/lib/constants/shared/constants';
24+
import { cn } from '@/lib/utils';
2325
import { getStatusBadgeVariant } from '@/lib/utils/my-organization/domain-management/domain-management-utils';
2426
import type {
2527
DomainTableProps,
@@ -102,6 +104,9 @@ function DomainTableView({
102104
isCreating,
103105
isVerifying,
104106
isFetching,
107+
isRefetchingDomains,
108+
isDomainsStale,
109+
domainsUpdatedAt,
105110
isLoadingProviders,
106111
isDeleting,
107112
pagination,
@@ -111,6 +116,7 @@ function DomainTableView({
111116
showDeleteModal,
112117
verifyError,
113118
selectedDomain,
119+
refetchDomains,
114120
setShowCreateModal,
115121
setShowConfigureModal,
116122
setShowDeleteModal,
@@ -195,6 +201,17 @@ function DomainTableView({
195201
</div>
196202
)}
197203

204+
<div
205+
className={cn('flex justify-end mb-8', currentStyles.classes?.['DomainTable-tableActions'])}
206+
>
207+
<RefreshIndicator
208+
isStale={isDomainsStale}
209+
isFetching={isRefetchingDomains}
210+
lastUpdatedAt={domainsUpdatedAt || undefined}
211+
onRefresh={refetchDomains}
212+
/>
213+
</div>
214+
198215
<DataTable
199216
columns={columns}
200217
data={domains}

packages/react/src/components/auth0/my-organization/organization-member-management.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,13 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage
139139
? {
140140
isStale: isMembersStale,
141141
isFetching: isFetchingMembers,
142-
lastUpdatedAt: membersUpdatedAt,
142+
lastUpdatedAt: membersUpdatedAt || undefined,
143143
onRefresh: refetchMembers,
144144
}
145145
: {
146146
isStale: isInvitationsStale,
147147
isFetching: isFetchingInvitations,
148-
lastUpdatedAt: invitationsUpdatedAt,
148+
lastUpdatedAt: invitationsUpdatedAt || undefined,
149149
onRefresh: refetchInvitations,
150150
};
151151

packages/react/src/components/auth0/shared/__tests__/refresh-indicator.test.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,36 @@ describe('RefreshIndicator', () => {
2929
vi.restoreAllMocks();
3030
});
3131

32-
it('renders the refresh button when data is stale', () => {
32+
it('enables the refresh button when data is stale', () => {
3333
render(<RefreshIndicator isStale onRefresh={vi.fn()} />);
34-
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
34+
expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled();
35+
});
36+
37+
it('disables the refresh button while a refetch is in flight', () => {
38+
render(<RefreshIndicator isStale isFetching lastUpdatedAt={new Date()} onRefresh={vi.fn()} />);
39+
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
3540
});
3641

37-
it('hides the indicator while fetching', () => {
42+
it('hides the indicator during an initial load, before any data has been fetched', () => {
3843
render(<RefreshIndicator isStale isFetching onRefresh={vi.fn()} />);
3944
expect(screen.queryByRole('button', { name: 'Refresh' })).not.toBeInTheDocument();
45+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
4046
});
4147

42-
it('hides the indicator when data is not stale', () => {
43-
render(<RefreshIndicator isStale={false} onRefresh={vi.fn()} />);
44-
expect(screen.queryByRole('button', { name: 'Refresh' })).not.toBeInTheDocument();
48+
it('shows "Just now" instead of the stale time while a refetch is in flight', () => {
49+
const lastUpdatedAt = new Date('2026-06-30T11:55:00Z'); // 5 min ago
50+
render(
51+
<RefreshIndicator isStale isFetching lastUpdatedAt={lastUpdatedAt} onRefresh={vi.fn()} />,
52+
);
53+
expect(screen.getByText(/just now/i)).toBeInTheDocument();
54+
expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
55+
});
56+
57+
it('shows a disabled button and a "Just now" label when data is fresh', () => {
58+
render(<RefreshIndicator isStale={false} lastUpdatedAt={new Date()} onRefresh={vi.fn()} />);
59+
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
60+
expect(screen.getByText('Last updated', { exact: false })).toBeInTheDocument();
61+
expect(screen.getByText(/just now/i)).toBeInTheDocument();
4562
});
4663

4764
it('calls onRefresh when the button is clicked', () => {
@@ -51,6 +68,15 @@ describe('RefreshIndicator', () => {
5168
expect(onRefresh).toHaveBeenCalledTimes(1);
5269
});
5370

71+
it('does not call onRefresh when the button is disabled', () => {
72+
const onRefresh = vi.fn();
73+
render(
74+
<RefreshIndicator isStale isFetching lastUpdatedAt={new Date()} onRefresh={onRefresh} />,
75+
);
76+
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
77+
expect(onRefresh).not.toHaveBeenCalled();
78+
});
79+
5480
it('renders a relative "last updated" label', () => {
5581
const lastUpdatedAt = new Date('2026-06-30T11:59:30Z'); // 30s ago -> under a minute
5682
render(<RefreshIndicator isStale lastUpdatedAt={lastUpdatedAt} onRefresh={vi.fn()} />);
@@ -79,16 +105,15 @@ describe('RefreshIndicator', () => {
79105
render(
80106
<RefreshIndicator
81107
isStale
82-
lastUpdatedAt={new Date('2026-06-30T11:59:10Z')} // 50s ago -> under a minute
108+
lastUpdatedAt={new Date('2026-06-30T11:59:10Z')}
83109
tickIntervalMs={1000}
84110
onRefresh={vi.fn()}
85111
/>,
86112
);
87113
expect(screen.getByText(/just now/i)).toBeInTheDocument();
88114

89-
// Advance elapsed time past the minute boundary and fire the tick so the label recomputes.
90115
act(() => {
91-
vi.advanceTimersByTime(15_000); // now 1 min 5 sec ago
116+
vi.advanceTimersByTime(15_000);
92117
});
93118
expect(screen.getByText(/1 minute 5 sec ago/i)).toBeInTheDocument();
94119
});

packages/react/src/components/auth0/shared/refresh-indicator.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ export interface RefreshIndicatorProps {
2323
}
2424

2525
/**
26-
* Renders a stale-data indicator with a manual refresh button.
26+
* Renders a last-updated indicator with a manual refresh button.
2727
* @param props - {@link RefreshIndicatorProps}
28-
* @returns The rendered indicator, or `null` when it should be hidden.
28+
* @returns The rendered indicator
2929
*/
3030
export function RefreshIndicator({
3131
lastUpdatedAt,
@@ -36,18 +36,26 @@ export function RefreshIndicator({
3636
tickIntervalMs = DEFAULT_REFRESH_INDICATOR_TICK_MS,
3737
}: RefreshIndicatorProps): React.JSX.Element | null {
3838
const { t } = useTranslator('common');
39-
const visible = isStale && !isFetching;
4039
const timestampMs = lastUpdatedAt == null ? null : new Date(lastUpdatedAt).getTime();
4140
const hasValidTimestamp = timestampMs != null && !Number.isNaN(timestampMs);
41+
const isInitialLoad = isFetching && !hasValidTimestamp;
4242

43+
const showStaleLabel = isStale && !isFetching;
44+
const label = showStaleLabel
45+
? hasValidTimestamp
46+
? getRelativeTimeLabel(timestampMs, t)
47+
: null
48+
: t('time.just_now', undefined, 'Just now');
49+
50+
const shouldTick = showStaleLabel && hasValidTimestamp;
4351
const [, forceTick] = React.useReducer((n: number) => n + 1, 0);
4452
React.useEffect(() => {
45-
if (!visible || !hasValidTimestamp || tickIntervalMs <= 0) return;
53+
if (!shouldTick || tickIntervalMs <= 0) return;
4654
const id = setInterval(forceTick, tickIntervalMs);
4755
return () => clearInterval(id);
48-
}, [visible, hasValidTimestamp, timestampMs, tickIntervalMs]);
56+
}, [shouldTick, timestampMs, tickIntervalMs]);
4957

50-
if (!visible) {
58+
if (isInitialLoad) {
5159
return null;
5260
}
5361

@@ -56,14 +64,20 @@ export function RefreshIndicator({
5664
className={cn('flex items-center gap-3 text-sm text-muted-foreground', className)}
5765
role="status"
5866
>
59-
{hasValidTimestamp && (
67+
{label != null && (
6068
<span aria-live="off">
6169
{t('last_updated')}
6270
{': '}
63-
{getRelativeTimeLabel(timestampMs, t)}
71+
{label}
6472
</span>
6573
)}
66-
<Button type="button" variant="outline" size="default" onClick={onRefresh}>
74+
<Button
75+
type="button"
76+
variant="outline"
77+
size="default"
78+
disabled={!isStale || isFetching}
79+
onClick={onRefresh}
80+
>
6781
<RefreshCw aria-hidden="true" />
6882
{t('refresh')}
6983
</Button>

packages/react/src/hooks/my-organization/shared/services/use-domain-table-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,15 @@ export function useDomainTableService({
223223
providers: providersQuery.data ?? [],
224224
nextToken: domainsQuery.data?.next ?? null,
225225
isFetching: domainsQuery.isLoading,
226+
isRefetchingDomains: domainsQuery.isFetching,
227+
isDomainsStale: domainsQuery.isStale,
228+
domainsUpdatedAt: domainsQuery.dataUpdatedAt,
226229
isCreating: isMutationLoading(createDomainMutation),
227230
isDeleting: isMutationLoading(deleteDomainMutation),
228231
isVerifying: isMutationLoading(verifyDomainMutation),
229232
isLoadingProviders: providersQuery.isLoading,
230233

234+
refetchDomains: domainsQuery.refetch,
231235
fetchProviders,
232236
fetchDomains,
233237
onCreateDomain,

packages/react/src/hooks/my-organization/use-domain-table.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,14 @@ export function useDomainTable({
5252
providers,
5353
nextToken,
5454
isFetching,
55+
isRefetchingDomains,
56+
isDomainsStale,
57+
domainsUpdatedAt,
5558
isCreating,
5659
isDeleting,
5760
isVerifying,
5861
isLoadingProviders,
62+
refetchDomains,
5963
fetchProviders,
6064
onCreateDomain,
6165
onVerifyDomain,
@@ -277,11 +281,13 @@ export function useDomainTable({
277281

278282
// Loading states
279283
isFetching,
284+
isRefetchingDomains,
285+
isDomainsStale,
286+
domainsUpdatedAt,
280287
isCreating,
281288
isDeleting,
282289
isVerifying,
283290
isLoadingProviders,
284-
285291
// Pagination
286292
pagination: {
287293
pageSize,
@@ -305,6 +311,7 @@ export function useDomainTable({
305311
setShowDeleteModal,
306312

307313
// Handlers
314+
refetchDomains,
308315
handleCreate,
309316
handleVerify,
310317
handleDelete,

packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ export const createMockDomainTableReturn = (
127127
isCreating: false,
128128
isVerifying: false,
129129
isFetching: false,
130+
isRefetchingDomains: false,
131+
isDomainsStale: false,
132+
domainsUpdatedAt: 0,
130133
isLoadingProviders: false,
131134
isDeleting: false,
132135
pagination: {
@@ -145,6 +148,7 @@ export const createMockDomainTableReturn = (
145148
setShowConfigureModal: vi.fn(),
146149
setShowVerifyModal: vi.fn(),
147150
setShowDeleteModal: vi.fn(),
151+
refetchDomains: vi.fn(),
148152
handleCreate: vi.fn(),
149153
handleVerify: vi.fn(),
150154
handleDelete: vi.fn(),
@@ -194,10 +198,14 @@ export const createMockDomainTableServiceReturn = (
194198
providers: [],
195199
nextToken: null,
196200
isFetching: false,
201+
isRefetchingDomains: false,
202+
isDomainsStale: false,
203+
domainsUpdatedAt: 0,
197204
isCreating: false,
198205
isDeleting: false,
199206
isVerifying: false,
200207
isLoadingProviders: false,
208+
refetchDomains: vi.fn(),
201209
fetchProviders: vi.fn(),
202210
fetchDomains: vi.fn(),
203211
onCreateDomain: vi.fn(),

0 commit comments

Comments
 (0)