Skip to content

Commit b045c12

Browse files
updates
1 parent 66afc2c commit b045c12

5 files changed

Lines changed: 152 additions & 4 deletions

File tree

components/ingest/redesign/__tests__/source-row.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ function baseProps(group: SourceGroupData): SourceGroupProps {
4949
onAddConnection: jest.fn(),
5050
onEditSource: jest.fn(),
5151
onDeleteSource: jest.fn(),
52+
connectionsStatus: 'ready',
53+
onRetryConnections: jest.fn(),
5254
};
5355
}
5456

@@ -93,4 +95,34 @@ describe('SourceRow', () => {
9395
expect(screen.queryByTestId('add-connection-s1')).not.toBeInTheDocument();
9496
expect(screen.getByText('No connections yet')).toBeInTheDocument();
9597
});
98+
99+
// Connections are fetched separately from sources, so a source row can render
100+
// before its connections have arrived. Zero connections must not be read as
101+
// "this source has none" until the fetch has actually answered.
102+
it('shows a loading placeholder instead of the add-connection CTA while connections load', () => {
103+
const group: SourceGroupData = { source: source('s1', 'Kobo'), connections: [] };
104+
render(<SourceRow {...baseProps(group)} connectionsStatus="loading" />);
105+
expect(screen.getByTestId('connections-loading-s1')).toBeInTheDocument();
106+
expect(screen.queryByTestId('add-connection-s1')).not.toBeInTheDocument();
107+
expect(screen.queryByText('No connections yet')).not.toBeInTheDocument();
108+
});
109+
110+
it('shows a retry affordance instead of the add-connection CTA when connections failed to load', () => {
111+
const group: SourceGroupData = { source: source('s1', 'Kobo'), connections: [] };
112+
const props = baseProps(group);
113+
render(<SourceRow {...props} connectionsStatus="error" />);
114+
expect(screen.queryByTestId('add-connection-s1')).not.toBeInTheDocument();
115+
fireEvent.click(screen.getByTestId('retry-connections-s1'));
116+
expect(props.onRetryConnections).toHaveBeenCalledTimes(1);
117+
});
118+
119+
it('still renders already-loaded connections while the list is revalidating', () => {
120+
const group: SourceGroupData = {
121+
source: source('s1', 'Kobo'),
122+
connections: [conn('c1', 's1')],
123+
};
124+
render(<SourceRow {...baseProps(group)} connectionsStatus="loading" />);
125+
expect(screen.getByTestId('connection-row-c1')).toBeInTheDocument();
126+
expect(screen.queryByTestId('connections-loading-s1')).not.toBeInTheDocument();
127+
});
96128
});

components/ingest/redesign/__tests__/steady-view.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,35 @@ describe('SteadyView (smoke)', () => {
6666
expect(screen.getByTestId('connection-row-c1')).toBeInTheDocument();
6767
});
6868

69+
// Sources and connections are two separate fetches; sources can land first.
70+
// Until connections answer, a source must not be labelled as having none.
71+
it('does not show the add-connection empty state while connections are still loading', () => {
72+
mockConnections.mockReturnValue({ data: [], isLoading: true, mutate: jest.fn() });
73+
mockSources.mockReturnValue({ data: [source('s2', 'Sheets')], mutate: jest.fn() });
74+
75+
render(<SteadyView />);
76+
77+
expect(screen.getByTestId('source-row-s2')).toBeInTheDocument();
78+
expect(screen.getByTestId('connections-loading-s2')).toBeInTheDocument();
79+
expect(screen.queryByTestId('add-connection-s2')).not.toBeInTheDocument();
80+
});
81+
82+
it('offers a retry instead of the empty state when the connections fetch failed', () => {
83+
const mutate = jest.fn();
84+
mockConnections.mockReturnValue({
85+
data: [],
86+
isLoading: false,
87+
isError: new Error('boom'),
88+
mutate,
89+
});
90+
mockSources.mockReturnValue({ data: [source('s2', 'Sheets')], mutate: jest.fn() });
91+
92+
render(<SteadyView />);
93+
94+
expect(screen.queryByTestId('add-connection-s2')).not.toBeInTheDocument();
95+
expect(screen.getByTestId('retry-connections-s2')).toBeInTheDocument();
96+
});
97+
6998
it('shows a source with no connections as a plain add-connection row', () => {
7099
mockConnections.mockReturnValue({ data: [], mutate: jest.fn() });
71100
mockSources.mockReturnValue({ data: [source('s2', 'Sheets')], mutate: jest.fn() });

components/ingest/redesign/source-row.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client';
22

3-
import { Plus, MoreVertical, Pencil, Trash2, Plug } from 'lucide-react';
3+
import { Plus, MoreVertical, Pencil, Trash2, Plug, AlertCircle } from 'lucide-react';
44
import { Button } from '@/components/ui/button';
5+
import { Skeleton } from '@/components/ui/skeleton';
56
import { Table, TableBody } from '@/components/ui/table';
67
import {
78
DropdownMenu,
@@ -142,6 +143,45 @@ function SourceMenu({
142143
);
143144
}
144145

146+
/**
147+
* Placeholder occupying the connections column while the connections fetch is in
148+
* flight. Sized to a one-connection row so the source row doesn't jump height
149+
* when the real rows arrive.
150+
*/
151+
function ConnectionsLoading({ sourceId }: { sourceId: string }) {
152+
return (
153+
<div
154+
className="flex h-full items-center gap-3 px-6 py-4"
155+
data-testid={`connections-loading-${sourceId}`}
156+
aria-busy="true"
157+
aria-label="Loading connections"
158+
>
159+
<Skeleton className="h-4 w-[45%]" />
160+
<Skeleton className="h-4 w-[25%]" />
161+
</div>
162+
);
163+
}
164+
165+
/** The connections fetch failed — say so and offer a retry instead of guessing. */
166+
function ConnectionsError({ sourceId, onRetry }: { sourceId: string; onRetry: () => void }) {
167+
return (
168+
<div className="flex h-full items-center gap-2 px-6 py-4">
169+
<AlertCircle className="h-4 w-4 flex-shrink-0 text-destructive" />
170+
<p className="text-base text-muted-foreground">
171+
Couldn&apos;t load connections.{' '}
172+
<button
173+
type="button"
174+
onClick={onRetry}
175+
data-testid={`retry-connections-${sourceId}`}
176+
className="cursor-pointer font-medium text-primary underline underline-offset-2 hover:opacity-80"
177+
>
178+
Retry
179+
</button>
180+
</p>
181+
</div>
182+
);
183+
}
184+
145185
/**
146186
* One source rendered as a horizontal band for the "Side-by-side" layout:
147187
* a fixed-width left column (source identity + 3-dots menu, which owns the
@@ -172,8 +212,14 @@ export function SourceRow({
172212
onAddConnection,
173213
onEditSource,
174214
onDeleteSource,
215+
connectionsStatus,
216+
onRetryConnections,
175217
}: SourceGroupProps) {
176218
const { source, connections } = group;
219+
// Zero connections is only meaningful once the fetch has answered — before
220+
// that it's absence of data, not absence of connections. Already-loaded
221+
// connections keep rendering through a background revalidate.
222+
const pending = connections.length === 0 && connectionsStatus !== 'ready';
177223

178224
return (
179225
<div
@@ -199,7 +245,13 @@ export function SourceRow({
199245
{/* Right — connections stacked as full-width rows, vertically centered so a
200246
single connection lines up with the source block instead of sitting at the top */}
201247
<div className="flex-1 min-w-0 flex flex-col justify-center">
202-
{connections.length === 0 ? (
248+
{pending ? (
249+
connectionsStatus === 'error' ? (
250+
<ConnectionsError sourceId={source.sourceId} onRetry={onRetryConnections} />
251+
) : (
252+
<ConnectionsLoading sourceId={source.sourceId} />
253+
)
254+
) : connections.length === 0 ? (
203255
// Empty state — the source is connected but has no connections yet.
204256
// An inline sentence with a clickable "add a connection" that opens the
205257
// connection modal (a button reads as a hard action; this reads as guidance).

components/ingest/redesign/steady-view.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import { PendingActions } from '@/components/connections/pending-actions';
2727
import { SchemaChangeForm } from '@/components/connections/schema-change-form';
2828
import { SourceForm } from '@/components/ingest/sources/SourceForm';
2929
import { SourceRow } from '@/components/ingest/redesign/source-row';
30-
import { groupConnectionsBySource } from '@/components/ingest/redesign/utils';
30+
import {
31+
groupConnectionsBySource,
32+
type ConnectionsStatus,
33+
} from '@/components/ingest/redesign/utils';
3134
import type { Connection, ClearStreamData } from '@/types/connections';
3235
import type { Source } from '@/types/source';
3336

@@ -94,7 +97,12 @@ function compareSources(a: Source, b: Source, sort: SortOption): number {
9497
* forms.
9598
*/
9699
export function SteadyView() {
97-
const { data: connections, mutate: mutateConnections } = useConnectionsList();
100+
const {
101+
data: connections,
102+
isLoading: connectionsLoading,
103+
isError: connectionsError,
104+
mutate: mutateConnections,
105+
} = useConnectionsList();
98106
const { data: sources, mutate: mutateSources } = useSources();
99107
const { hasPermission } = useRbac();
100108
const { confirm, DialogComponent } = useConfirmationDialog();
@@ -165,6 +173,19 @@ export function SteadyView() {
165173
);
166174
}, [sources, connections, searchTerm, sortOption]);
167175

176+
// Sources and connections are separate fetches, and this view mounts as soon as
177+
// sources land. Passing the connections fetch state down stops each source row
178+
// reading "still loading" as "no connections" (see ConnectionsStatus).
179+
const connectionsStatus: ConnectionsStatus = connectionsError
180+
? 'error'
181+
: connectionsLoading
182+
? 'loading'
183+
: 'ready';
184+
185+
const handleRetryConnections = useCallback(() => {
186+
mutateConnections();
187+
}, [mutateConnections]);
188+
168189
const handleSortSources = useCallback(() => {
169190
setSortOption((prev) => nextSort(prev));
170191
}, []);
@@ -501,6 +522,8 @@ export function SteadyView() {
501522
<SourceRow
502523
key={group.source.sourceId}
503524
group={group}
525+
connectionsStatus={connectionsStatus}
526+
onRetryConnections={handleRetryConnections}
504527
syncingIds={syncingIds}
505528
canSync={canSync}
506529
canEditConnection={canEditConnection}

components/ingest/redesign/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@ export interface SourceGroupData {
88
connections: Connection[];
99
}
1010

11+
/**
12+
* How the connections fetch is doing. Sources and connections are two separate
13+
* requests, so a source row can render before its connections exist client-side.
14+
* Without this, an unanswered (or failed) fetch is indistinguishable from a
15+
* source that genuinely has no connections — and the row wrongly tells the user
16+
* to go add one. Same trap as the warehouse/NO_WAREHOUSE split in state.ts.
17+
*/
18+
export type ConnectionsStatus = 'loading' | 'error' | 'ready';
19+
1120
/**
1221
* Props for a source renderer (SourceRow). Carries one grouped source plus the
1322
* permission flags and action callbacks passed straight through to the reused
1423
* ConnectionRow and source menu.
1524
*/
1625
export interface SourceGroupProps {
1726
group: SourceGroupData;
27+
// Fetch state of the connections list (see ConnectionsStatus)
28+
connectionsStatus: ConnectionsStatus;
29+
onRetryConnections: () => void;
1830
// Connection permissions + action wiring (passed straight to ConnectionRow)
1931
syncingIds: string[];
2032
canSync: boolean;

0 commit comments

Comments
 (0)