Skip to content

Commit 94fc143

Browse files
committed
refactor(ui): derive ConfigureSSO test-run fetching from the connection state
1 parent d330732 commit 94fc143

7 files changed

Lines changed: 134 additions & 95 deletions

File tree

packages/ui/src/components/ConfigureSSO/domain/__tests__/enterpriseConnectionState.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
} from '@clerk/shared/types';
66
import { describe, expect, it } from 'vitest';
77

8-
import { deriveEnterpriseConnectionState } from '../enterpriseConnectionState';
8+
import { deriveEnterpriseConnectionState, isEnterpriseConnectionConfigured } from '../enterpriseConnectionState';
99

1010
const makeSamlConnection = (overrides: Partial<SamlAccountConnectionResource> = {}): SamlAccountConnectionResource =>
1111
({
@@ -149,3 +149,32 @@ describe('deriveEnterpriseConnectionState', () => {
149149
expect(typeof state.hasSuccessfulTestRun).toBe('boolean');
150150
});
151151
});
152+
153+
describe('isEnterpriseConnectionConfigured', () => {
154+
it('undefined connection → false', () => {
155+
expect(isEnterpriseConnectionConfigured(undefined)).toBe(false);
156+
});
157+
it('null connection → false', () => {
158+
expect(isEnterpriseConnectionConfigured(null)).toBe(false);
159+
});
160+
it('no saml config → false', () => {
161+
expect(isEnterpriseConnectionConfigured(makeConnection({ samlConnection: null }))).toBe(false);
162+
});
163+
it('empty saml config → false', () => {
164+
expect(isEnterpriseConnectionConfigured(makeConnection({ samlConnection: makeSamlConnection() }))).toBe(false);
165+
});
166+
it('idpSsoUrl only → false', () => {
167+
expect(
168+
isEnterpriseConnectionConfigured(
169+
makeConnection({ samlConnection: makeSamlConnection({ idpSsoUrl: 'https://idp.example.com/sso' }) }),
170+
),
171+
).toBe(false);
172+
});
173+
it('idpSsoUrl + idpEntityId present → true', () => {
174+
expect(isEnterpriseConnectionConfigured(makeConnection({ samlConnection: fullyConfiguredSaml }))).toBe(true);
175+
});
176+
it('matches the derived `hasMinimumConfiguration` field', () => {
177+
const connection = makeConnection({ samlConnection: fullyConfiguredSaml });
178+
expect(isEnterpriseConnectionConfigured(connection)).toBe(derive({ connection }).hasMinimumConfiguration);
179+
});
180+
});

packages/ui/src/components/ConfigureSSO/domain/enterpriseConnectionState.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ export interface EnterpriseConnectionState {
6767
hasSuccessfulTestRun: boolean;
6868
}
6969

70+
/**
71+
* Whether the connection carries the minimum identity-provider configuration
72+
* required to advance past the configure step (currently a SAML IdP SSO URL +
73+
* entity ID). A pure predicate over the raw connection — it reads no derived
74+
* state, so it is safe to use both inside {@link deriveEnterpriseConnectionState}
75+
* and to gate the test-runs source upstream without a circular dependency on
76+
* the derived snapshot.
77+
*/
78+
// TODO - Update to support OpenID Connect
79+
export const isEnterpriseConnectionConfigured = (
80+
connection: EnterpriseConnectionResource | null | undefined,
81+
): boolean => Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId);
82+
7083
/**
7184
* Builds the {@link EnterpriseConnectionState} from the raw inputs gathered in
7285
* `useOrganizationEnterpriseConnection`. Pure: identical inputs yield identical
@@ -80,8 +93,7 @@ export const deriveEnterpriseConnectionState = ({
8093
provider: connection?.provider as ProviderType | undefined,
8194
hasConnection: Boolean(connection),
8295
isActive: Boolean(connection?.active),
83-
// TODO - Update to support OpenID Connect
84-
hasMinimumConfiguration: Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId),
96+
hasMinimumConfiguration: isEnterpriseConnectionConfigured(connection),
8597
isPrimaryEmailVerified: primaryEmail?.verification?.status === 'verified',
8698
hasSuccessfulTestRun,
8799
});

packages/ui/src/components/ConfigureSSO/hooks/__tests__/useOrganizationEnterpriseConnection.test.tsx

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
1-
import { act, renderHook } from '@testing-library/react';
1+
import { renderHook } from '@testing-library/react';
22
import { beforeEach, describe, expect, it, vi } from 'vitest';
33

44
// The umbrella hook composes several `@clerk/shared/react` hooks. We mock the
55
// whole module so the test drives exactly what the source query and the
66
// underlying test-runs query report, and can assert how the umbrella gates the
77
// test-runs query's `enabled` and folds its loading into the global gate.
88

9+
// A connection shaped enough for the umbrella's gating: the derived
10+
// "configured" predicate reads `samlConnection.idpSsoUrl` + `idpEntityId`, and
11+
// the active path reads `active`.
12+
type MockConnection = {
13+
id: string;
14+
active?: boolean;
15+
samlConnection?: { idpSsoUrl?: string; idpEntityId?: string } | null;
16+
};
17+
18+
const configuredConnection = (id: string): MockConnection => ({
19+
id,
20+
samlConnection: { idpSsoUrl: 'https://idp.example.com/sso', idpEntityId: 'https://idp.example.com/entity' },
21+
});
22+
23+
const unconfiguredConnection = (id: string): MockConnection => ({ id, samlConnection: null });
24+
925
// Mutable state the connection-source mock reads from, so a test can flip from
1026
// "no connection at load" to "connection created mid-flow" between renders.
1127
const connectionsState = vi.hoisted(() => ({
12-
data: [] as Array<{ id: string }>,
28+
data: [] as MockConnection[],
1329
isLoading: false,
1430
}));
1531

@@ -63,21 +79,30 @@ beforeEach(() => {
6379
const lastTwoCalls = () => testRunsState.calls.slice(-2);
6480

6581
describe('useOrganizationEnterpriseConnection — test-runs gating', () => {
66-
it('(a) existing connection at load → test-runs active and contribute to the global isLoading', () => {
67-
connectionsState.data = [{ id: 'ent_1' }];
82+
it('(a) existing configured connection at load → test-runs active and contribute to the global isLoading', () => {
83+
connectionsState.data = [configuredConnection('ent_1')];
6884
testRunsState.isLoading = true;
6985

7086
const { result } = renderHook(() => useOrganizationEnterpriseConnection());
7187

72-
// Both underlying queries (probe + list) are enabled from the first render.
88+
// A configured connection existed at load → both underlying queries (probe +
89+
// list) are enabled from the first render.
7390
expect(lastTwoCalls()).toEqual([{ enabled: true }, { enabled: true }]);
74-
// A connection existed at load, so the cold test-runs load gates the full
75-
// skeleton.
91+
// …so the cold test-runs load gates the full skeleton (hadInitialConnection).
7692
expect(result.current.isLoading).toBe(true);
7793
expect(result.current.testRuns.isLoading).toBe(true);
7894
});
7995

80-
it('(b) no connection at load → test-runs inactive after a mid-flow create, and never gate the global skeleton', () => {
96+
it('(a2) active (but unconfigured) connection at load → test-runs active via the isActive path', () => {
97+
connectionsState.data = [{ id: 'ent_active', active: true, samlConnection: null }];
98+
99+
const { result } = renderHook(() => useOrganizationEnterpriseConnection());
100+
101+
expect(lastTwoCalls()).toEqual([{ enabled: true }, { enabled: true }]);
102+
expect(result.current.isLoading).toBe(false);
103+
});
104+
105+
it('(b) fresh start: no connection → dormant; connection created but NOT yet configured → still dormant, never gating the global skeleton', () => {
81106
connectionsState.data = [];
82107
testRunsState.isLoading = true;
83108

@@ -87,40 +112,41 @@ describe('useOrganizationEnterpriseConnection — test-runs gating', () => {
87112
expect(lastTwoCalls()).toEqual([{ enabled: false }, { enabled: false }]);
88113
expect(result.current.isLoading).toBe(false);
89114

90-
// A connection appears mid-flow (the create on the fresh-start path).
91-
connectionsState.data = [{ id: 'ent_new' }];
115+
// A connection appears mid-flow but is not yet configured (the create on the
116+
// fresh-start path, before the configure step fills in the SAML fields).
117+
connectionsState.data = [unconfiguredConnection('ent_new')];
92118
rerender();
93119

94-
// The query stays dormant — creating the connection must NOT fire it…
120+
// The query stays dormant — creating an unconfigured connection must NOT
121+
// fire it…
95122
expect(lastTwoCalls()).toEqual([{ enabled: false }, { enabled: false }]);
96123
// …and the global gate never folds in test-runs on this path, even though
97124
// the underlying flag would say loading were it enabled.
98125
expect(result.current.isLoading).toBe(false);
99126
});
100127

101-
it('(c) after Test-step activation → test-runs active, with loading at the table level (isFetching), not the global skeleton', () => {
128+
it('(c) fresh start: once the connection is configured → test-runs active, with loading at the table level (isFetching), not the global skeleton', () => {
102129
connectionsState.data = [];
103130

104131
const { result, rerender } = renderHook(() => useOrganizationEnterpriseConnection());
105132

106-
// Connection created mid-flow; still dormant until the Test step activates.
107-
connectionsState.data = [{ id: 'ent_new' }];
133+
// Connection created mid-flow but unconfigured → still dormant.
134+
connectionsState.data = [unconfiguredConnection('ent_new')];
108135
rerender();
109136
expect(lastTwoCalls()).toEqual([{ enabled: false }, { enabled: false }]);
110137

111-
// The Test step calls `activate()` on entry. A background list fetch is now
138+
// The configure step fills in the SAML fields. A background list fetch is now
112139
// in flight (table-level), but it is not a cold load.
113140
testRunsState.isFetching = true;
114-
act(() => {
115-
result.current.testRuns.activate();
116-
});
141+
connectionsState.data = [configuredConnection('ent_new')];
142+
rerender();
117143

118-
// Now active → both queries fire.
144+
// Now configured → both queries fire.
119145
expect(lastTwoCalls()).toEqual([{ enabled: true }, { enabled: true }]);
120146
// Loading is table-level only…
121147
expect(result.current.testRuns.isFetching).toBe(true);
122148
// …and the global skeleton stays down: no connection at initial load, so
123-
// test-runs never gate it.
149+
// test-runs never gate it (no flash on configure → test).
124150
expect(result.current.isLoading).toBe(false);
125151
});
126152

packages/ui/src/components/ConfigureSSO/hooks/useEnterpriseConnectionTestRuns.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,14 @@ export interface EnterpriseConnectionTestRuns {
101101
* - `isFetching` — any in-flight *list* fetch, with previously-loaded rows kept
102102
* visible (`keepPreviousData` semantics) → table-level loading on re-entry.
103103
*
104-
* `active` gates *both* underlying queries. The umbrella hook keeps it `true`
105-
* for the existing-connection-at-load case (so test-runs fetch on first load,
106-
* cover the full skeleton, and drive the `tested` guard) but `false` on the
107-
* fresh-start path until the user actually lands on the Test step — so merely
108-
* *creating* a connection mid-flow does not fire a test-runs fetch and flash
109-
* the global skeleton for a connection that provably has zero runs.
104+
* `active` gates *both* underlying queries. The umbrella hook derives it
105+
* straight from the connection — `true` once the connection is configured (or
106+
* active), which is also when the Test step becomes reachable. So an existing,
107+
* configured connection at load fetches test-runs on first load (covering the
108+
* full skeleton and driving the `tested` guard), while on the fresh-start path
109+
* a connection that is merely *created* but not yet configured stays `false` —
110+
* no test-runs fetch fires to flash the global skeleton for a connection that
111+
* provably has zero runs.
110112
*/
111113
export const useEnterpriseConnectionTestRuns = (
112114
connection: EnterpriseConnectionResource | undefined,

packages/ui/src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ import type {
1212
SignedInSessionResource,
1313
UserResource,
1414
} from '@clerk/shared/types';
15-
import { useCallback, useMemo, useRef, useState } from 'react';
15+
import { useMemo, useRef } from 'react';
1616

17-
import { deriveEnterpriseConnectionState, type EnterpriseConnectionState } from '../domain/enterpriseConnectionState';
17+
import {
18+
deriveEnterpriseConnectionState,
19+
type EnterpriseConnectionState,
20+
isEnterpriseConnectionConfigured,
21+
} from '../domain/enterpriseConnectionState';
1822
import {
1923
type EnterpriseConnectionMutations,
2024
useEnterpriseConnectionMutations,
@@ -28,7 +32,7 @@ export interface UseOrganizationEnterpriseConnectionResult {
2832
*
2933
* Test-runs contribute to this only when a connection was present at first
3034
* load (they're fetched as part of that initial load). On the fresh-start
31-
* path they are dormant until the Test step activates them, so creating a
35+
* path they stay dormant until the connection is configured, so creating a
3236
* connection mid-flow never re-flashes the global skeleton.
3337
*/
3438
isLoading: boolean;
@@ -61,8 +65,8 @@ export interface UseOrganizationEnterpriseConnectionResult {
6165
* exists at initial load it's fetched as part of that load, so a cold landing
6266
* on the test step is covered by the full skeleton; re-entering the step later
6367
* refetches via {@link TestRunsView.refresh}. On the fresh-start path the
64-
* queries stay dormant until {@link TestRunsView.activate} is called on Test
65-
* step entry, after which loading is table-level only.
68+
* queries stay dormant until the connection is configured, after which loading
69+
* is table-level only.
6670
*/
6771
testRuns: TestRunsView;
6872
}
@@ -109,17 +113,6 @@ export interface TestRunsView {
109113
* pass `{ armPolling: true }` after the user kicks off a run.
110114
*/
111115
refresh: (options?: RefreshTestRunsOptions) => Promise<unknown>;
112-
/**
113-
* Activate the test-runs queries for the fresh-start path. When there was no
114-
* connection at initial load the queries stay dormant (so creating a
115-
* connection mid-flow doesn't fire a fetch and flash the global skeleton);
116-
* the Test step calls this on entry to wake them, after which loading surfaces
117-
* at the table level (`isFetching`) rather than as the full skeleton.
118-
*
119-
* No-op when a connection was present at initial load — the queries are
120-
* already active there.
121-
*/
122-
activate: () => void;
123116
}
124117

125118
/**
@@ -157,17 +150,22 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise
157150
}
158151
const hadInitialConnection = hadInitialConnectionRef.current === true;
159152

160-
// Set once when the user lands on the Test step (event-driven, never an
161-
// effect that syncs to props). Used only on the fresh-start path to wake the
162-
// test-runs queries that were kept dormant through create/configure.
163-
const [testStepActivated, setTestStepActivated] = useState(false);
164-
const activateTestStep = useCallback(() => setTestStepActivated(true), []);
165-
166-
// Existing connection at load → active immediately (fires on load, gates the
167-
// initial skeleton, drives the `tested` guard). No connection at load →
168-
// dormant until the Test step activates, so a mid-flow create does NOT fire
169-
// the test-runs queries and flash the global skeleton.
170-
const testRunsActive = hadInitialConnection || testStepActivated;
153+
// The test-runs source is relevant exactly when the connection is configured —
154+
// the same condition that makes the Test step reachable
155+
// (`hasMinimumConfiguration || isActive`). Deriving activation straight from
156+
// the connection drops the imperative activate-on-mount ceremony:
157+
// - existing connection at load → configured (or active) → fires on load,
158+
// gates the initial skeleton, drives the `tested` guard;
159+
// - fresh start with no connection, or a connection created but not yet
160+
// configured → dormant, so a mid-flow create does NOT fire the test-runs
161+
// queries and flash the global skeleton;
162+
// - configure completes (`hasMinimumConfiguration`) → fires, surfacing as
163+
// table-level loading because `hadInitialConnection` is false.
164+
//
165+
// Computed from the raw connection (not `connectionState`, which depends on
166+
// the test-run probe below — reading it here would be circular).
167+
const testRunsActive =
168+
isEnterpriseConnectionConfigured(enterpriseConnection) || Boolean(enterpriseConnection?.active);
171169

172170
const {
173171
hasSuccessfulTestRun,
@@ -204,7 +202,6 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise
204202
page: testRunPage,
205203
setPage: setTestRunPage,
206204
refresh: refreshTestRuns,
207-
activate: activateTestStep,
208205
}),
209206
[
210207
testRunRows,
@@ -215,7 +212,6 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise
215212
testRunPage,
216213
setTestRunPage,
217214
refreshTestRuns,
218-
activateTestStep,
219215
],
220216
);
221217

@@ -242,8 +238,9 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise
242238
organization,
243239
// Test-runs gate the full skeleton only when a connection was present at
244240
// first load — that case fetches them as part of the initial load. On the
245-
// fresh-start path they are dormant until the Test step activates them, and
246-
// landing there shows table-level loading, never the global skeleton.
241+
// fresh-start path they stay dormant until the connection is configured, and
242+
// landing on the test step then shows table-level loading, never the global
243+
// skeleton.
247244
isLoading: isLoadingEnterpriseConnections || (hadInitialConnection && isLoadingTestRuns),
248245
enterpriseConnection,
249246
primaryEmailAddress,

packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,12 @@ export const TestConfigurationStep = (): JSX.Element => {
5858
page: currentPage,
5959
setPage: setCurrentPage,
6060
refresh: refreshTestRuns,
61-
activate: activateTestRuns,
6261
} = testRuns;
6362

64-
// Entering the test step wakes the test-runs source on the fresh-start path
65-
// (no connection at initial load → the queries were kept dormant through
66-
// create/configure so a mid-flow create never flashed the full skeleton).
67-
// `activate()` is a no-op when a connection existed at initial load, where the
68-
// queries already fetched on load. Once active, loading surfaces at the table
69-
// level (`isFetching`), never the global skeleton.
63+
// The test-runs source activates itself upstream the moment the connection is
64+
// configured (the same condition that makes this step reachable), so the step
65+
// no longer has to wake it on entry — by the time we land here it is already
66+
// live.
7067
//
7168
// The wizard's initial load already fetched the test-runs (covered by the
7269
// full skeleton) for the existing-connection case, so landing on the test
@@ -77,11 +74,9 @@ export const TestConfigurationStep = (): JSX.Element => {
7774
//
7875
// The step only mounts while it is the current step, so this runs once per
7976
// entry. `isInitialStep` is the wizard's own "no navigation yet" signal, read
80-
// at mount: this fires a mount-driven activation + data refresh, it is NOT
81-
// syncing state via an effect. Empty deps → mount-only, so re-renders never
82-
// re-fire.
77+
// at mount: this fires a mount-driven data refresh, it is NOT syncing state
78+
// via an effect. Empty deps → mount-only, so re-renders never re-fire.
8379
useEffect(() => {
84-
activateTestRuns();
8580
if (!isInitialStep) {
8681
void refreshTestRuns();
8782
}

0 commit comments

Comments
 (0)