@shipfox/client-integrations 12.0.2 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +34 -0
- package/dist/components/callback-status-shell.d.ts +4 -3
- package/dist/components/callback-status-shell.d.ts.map +1 -1
- package/dist/components/callback-status-shell.js +11 -11
- package/dist/components/callback-status-shell.js.map +1 -1
- package/dist/components/installed-integrations-section.d.ts +2 -1
- package/dist/components/installed-integrations-section.d.ts.map +1 -1
- package/dist/components/installed-integrations-section.js +7 -6
- package/dist/components/installed-integrations-section.js.map +1 -1
- package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
- package/dist/components/integration-gallery-for-workspace.js +11 -2
- package/dist/components/integration-gallery-for-workspace.js.map +1 -1
- package/dist/components/integration-gallery.stories.js +10 -9
- package/dist/components/integration-gallery.stories.js.map +1 -1
- package/dist/components/provider-grid.d.ts +2 -2
- package/dist/components/provider-grid.d.ts.map +1 -1
- package/dist/components/provider-grid.js +4 -4
- package/dist/components/provider-grid.js.map +1 -1
- package/dist/components/redirect-install-page.d.ts +1 -1
- package/dist/components/redirect-install-page.js +2 -2
- package/dist/components/redirect-install-page.js.map +1 -1
- package/dist/components/webhook/webhook-modals.stories.js +2 -2
- package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
- package/dist/feature.d.ts +7 -7
- package/dist/feature.js +7 -7
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/integrations.js +1 -1
- package/dist/hooks/api/integrations.js.map +1 -1
- package/dist/pages/gitea-install-page.js +4 -4
- package/dist/pages/gitea-install-page.js.map +1 -1
- package/dist/pages/linear-callback-page.d.ts.map +1 -1
- package/dist/pages/linear-callback-page.js +58 -12
- package/dist/pages/linear-callback-page.js.map +1 -1
- package/dist/pages/sentry-callback-page.d.ts.map +1 -1
- package/dist/pages/sentry-callback-page.js +22 -9
- package/dist/pages/sentry-callback-page.js.map +1 -1
- package/dist/pages/slack-callback-page.d.ts.map +1 -1
- package/dist/pages/slack-callback-page.js +50 -18
- package/dist/pages/slack-callback-page.js.map +1 -1
- package/dist/pages/source-control-onboarding-page.js +1 -1
- package/dist/pages/source-control-onboarding-page.js.map +1 -1
- package/dist/provider-catalog.d.ts +1 -1
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +5 -5
- package/dist/provider-catalog.js.map +1 -1
- package/dist/sentry-callback.js +2 -2
- package/dist/sentry-callback.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/workspace-navigation.d.ts +13 -0
- package/dist/workspace-navigation.d.ts.map +1 -0
- package/dist/workspace-navigation.js +17 -0
- package/dist/workspace-navigation.js.map +1 -0
- package/package.json +13 -13
- package/src/components/callback-status-shell.tsx +14 -10
- package/src/components/installed-integrations-section.tsx +16 -9
- package/src/components/integration-gallery-for-workspace.tsx +20 -10
- package/src/components/integration-gallery.stories.tsx +10 -10
- package/src/components/integration-gallery.test.tsx +30 -15
- package/src/components/provider-grid.tsx +6 -6
- package/src/components/redirect-install-page.test.tsx +3 -3
- package/src/components/redirect-install-page.tsx +2 -2
- package/src/components/webhook/webhook-modals.stories.tsx +2 -2
- package/src/feature.ts +7 -7
- package/src/hooks/api/integrations.ts +1 -1
- package/src/pages/gitea-install-page.tsx +2 -2
- package/src/pages/linear-callback-page.test.tsx +25 -12
- package/src/pages/linear-callback-page.tsx +73 -12
- package/src/pages/sentry-callback-page.test.tsx +8 -5
- package/src/pages/sentry-callback-page.tsx +32 -8
- package/src/pages/slack-callback-page.test.tsx +78 -0
- package/src/pages/slack-callback-page.tsx +56 -18
- package/src/pages/source-control-onboarding-page.test.tsx +4 -4
- package/src/pages/source-control-onboarding-page.tsx +1 -1
- package/src/provider-catalog.test.ts +4 -4
- package/src/provider-catalog.ts +10 -10
- package/src/sentry-callback.ts +2 -2
- package/src/workspace-navigation.test.ts +43 -0
- package/src/workspace-navigation.ts +38 -0
- package/test/render.tsx +2 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -4,7 +4,7 @@ import {ApiError} from '@shipfox/client-api';
|
|
|
4
4
|
import {screen, waitFor} from '@testing-library/react';
|
|
5
5
|
import {StrictMode} from 'react';
|
|
6
6
|
import {LINEAR_INSTALL_WORKSPACE_KEY} from '#linear-callback.js';
|
|
7
|
-
import {INTEGRATIONS_TEST_WID, renderIntegrationsPage} from '#test/render.js';
|
|
7
|
+
import {INTEGRATIONS_TEST_WID, renderIntegrationsPage, testWorkspace} from '#test/render.js';
|
|
8
8
|
import {LinearCallbackPage} from './linear-callback-page.js';
|
|
9
9
|
|
|
10
10
|
const {completeCallbackMock} = vi.hoisted(() => ({completeCallbackMock: vi.fn()}));
|
|
@@ -37,8 +37,8 @@ function renderCallback(search: string, options?: {strict?: boolean}) {
|
|
|
37
37
|
<LinearCallbackPage />
|
|
38
38
|
),
|
|
39
39
|
extraRoutes: [
|
|
40
|
-
'/
|
|
41
|
-
'/
|
|
40
|
+
'/w/$workspaceSlug/settings/integrations',
|
|
41
|
+
'/w/$workspaceSlug/integrations/linear',
|
|
42
42
|
'/auth/login',
|
|
43
43
|
],
|
|
44
44
|
});
|
|
@@ -70,7 +70,7 @@ describe('LinearCallbackPage', () => {
|
|
|
70
70
|
window.sessionStorage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
|
|
71
71
|
completeCallbackMock.mockResolvedValue({
|
|
72
72
|
id: 'connection-1',
|
|
73
|
-
|
|
73
|
+
workspaceId: responseWorkspaceId,
|
|
74
74
|
provider: 'linear',
|
|
75
75
|
external_account_id: 'linear-org',
|
|
76
76
|
slug: 'linear_org',
|
|
@@ -81,7 +81,24 @@ describe('LinearCallbackPage', () => {
|
|
|
81
81
|
updated_at: '2026-01-01T00:00:00.000Z',
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
renderIntegrationsPage({
|
|
85
|
+
path: '/integrations/linear/callback?code=grant-code-success&state=signed-state-success',
|
|
86
|
+
routePath: '/integrations/linear/callback',
|
|
87
|
+
element: (
|
|
88
|
+
<StrictMode>
|
|
89
|
+
<LinearCallbackPage />
|
|
90
|
+
</StrictMode>
|
|
91
|
+
),
|
|
92
|
+
workspaces: [
|
|
93
|
+
testWorkspace(),
|
|
94
|
+
testWorkspace({id: responseWorkspaceId, slug: 'response-workspace'}),
|
|
95
|
+
],
|
|
96
|
+
extraRoutes: [
|
|
97
|
+
'/w/response-workspace/settings/integrations',
|
|
98
|
+
'/w/$workspaceSlug/integrations/linear',
|
|
99
|
+
'/auth/login',
|
|
100
|
+
],
|
|
101
|
+
});
|
|
85
102
|
|
|
86
103
|
await waitFor(() =>
|
|
87
104
|
expect(completeCallbackMock).toHaveBeenCalledWith({
|
|
@@ -92,7 +109,7 @@ describe('LinearCallbackPage', () => {
|
|
|
92
109
|
expect(completeCallbackMock).toHaveBeenCalledTimes(1);
|
|
93
110
|
await waitFor(() =>
|
|
94
111
|
expect(
|
|
95
|
-
screen.getByTestId('route:/
|
|
112
|
+
screen.getByTestId('route:/w/response-workspace/settings/integrations'),
|
|
96
113
|
).toBeInTheDocument(),
|
|
97
114
|
);
|
|
98
115
|
expect(window.sessionStorage.getItem(LINEAR_INSTALL_WORKSPACE_KEY)).toBeNull();
|
|
@@ -136,14 +153,10 @@ describe('LinearCallbackPage', () => {
|
|
|
136
153
|
|
|
137
154
|
expect(switchAccountLink).toHaveAttribute(
|
|
138
155
|
'href',
|
|
139
|
-
`/auth/logout?redirect=${encodeURIComponent(
|
|
140
|
-
`/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
|
|
141
|
-
)}`,
|
|
156
|
+
`/auth/logout?redirect=${encodeURIComponent('/w/acme/integrations/linear')}`,
|
|
142
157
|
);
|
|
143
158
|
expect(switchAccountUrl.pathname).toBe('/auth/logout');
|
|
144
|
-
expect(switchAccountUrl.searchParams.get('redirect')).toBe(
|
|
145
|
-
`/workspaces/${INTEGRATIONS_TEST_WID}/integrations/linear`,
|
|
146
|
-
);
|
|
159
|
+
expect(switchAccountUrl.searchParams.get('redirect')).toBe('/w/acme/integrations/linear');
|
|
147
160
|
expect(screen.getByRole('link', {name: 'Start over'})).toBeVisible();
|
|
148
161
|
});
|
|
149
162
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {useRefreshAuth} from '@shipfox/client-auth';
|
|
1
|
+
import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
|
|
2
2
|
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
3
|
import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
4
4
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
5
5
|
import {toast} from '@shipfox/react-ui/toast';
|
|
6
|
+
import {useQueryClient} from '@tanstack/react-query';
|
|
6
7
|
import {useNavigate} from '@tanstack/react-router';
|
|
7
8
|
import {useEffect, useMemo, useState} from 'react';
|
|
8
9
|
import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
|
|
@@ -17,26 +18,33 @@ import {
|
|
|
17
18
|
readLinearInstallWorkspace,
|
|
18
19
|
serializeLinearCallbackQuery,
|
|
19
20
|
} from '#linear-callback.js';
|
|
21
|
+
import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
|
|
20
22
|
|
|
21
23
|
// Retain only recent completions: this bounds long-lived callback pages while
|
|
22
24
|
// still covering StrictMode and immediate Back/Forward remounts.
|
|
23
25
|
const callbackRequests = createSingleFlight<string, IntegrationConnection>({
|
|
24
26
|
maxTerminalResults: 32,
|
|
25
27
|
});
|
|
28
|
+
const completedCallbacks = new Set<string>();
|
|
26
29
|
// Keeps the success toast firing once per distinct callback even though the
|
|
27
30
|
// effect re-runs against the cached request as the mutation identity churns.
|
|
28
31
|
const toastedCallbacks = new Set<string>();
|
|
29
32
|
|
|
30
33
|
export function LinearCallbackPage() {
|
|
31
34
|
const navigate = useNavigate();
|
|
35
|
+
const queryClient = useQueryClient();
|
|
32
36
|
const refreshAuth = useRefreshAuth();
|
|
33
37
|
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
34
38
|
const {mutateAsync: completeLinearCallback} = useCompleteLinearCallbackMutation();
|
|
39
|
+
const {workspaces, isLoading} = useAuthState();
|
|
35
40
|
const params = useRouteSearch(parseLinearCallbackQuery);
|
|
36
41
|
const workspaceId = useMemo(() => readLinearInstallWorkspace(sessionStorageOrUndefined()), []);
|
|
37
42
|
const [failure, setFailure] = useState<LinearCallbackFailure | undefined>();
|
|
43
|
+
const [completedWorkspace, setCompletedWorkspace] = useState<{
|
|
44
|
+
slug?: string | undefined;
|
|
45
|
+
}>();
|
|
38
46
|
useEffect(() => {
|
|
39
|
-
if (!params) return;
|
|
47
|
+
if (!params || isLoading) return;
|
|
40
48
|
|
|
41
49
|
let disposed = false;
|
|
42
50
|
const key = serializeLinearCallbackQuery(params);
|
|
@@ -53,6 +61,16 @@ export function LinearCallbackPage() {
|
|
|
53
61
|
request.then(
|
|
54
62
|
async (connection) => {
|
|
55
63
|
if (disposed) return;
|
|
64
|
+
if (completedCallbacks.has(key)) {
|
|
65
|
+
const workspaceSlug = await resolveWorkspaceSlug({
|
|
66
|
+
workspaceId: connection.workspaceId,
|
|
67
|
+
fallbackWorkspaces: workspaces,
|
|
68
|
+
queryClient,
|
|
69
|
+
});
|
|
70
|
+
if (!disposed) setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
rememberCallbackKey(completedCallbacks, key);
|
|
56
74
|
try {
|
|
57
75
|
clearLinearInstallWorkspace(sessionStorageOrUndefined());
|
|
58
76
|
} catch {
|
|
@@ -60,17 +78,31 @@ export function LinearCallbackPage() {
|
|
|
60
78
|
}
|
|
61
79
|
if (disposed) return;
|
|
62
80
|
if (!toastedCallbacks.has(key)) {
|
|
63
|
-
toastedCallbacks
|
|
81
|
+
rememberCallbackKey(toastedCallbacks, key);
|
|
64
82
|
toast.success('Linear installed.');
|
|
65
83
|
}
|
|
84
|
+
let workspaceSlug: string | undefined;
|
|
66
85
|
try {
|
|
86
|
+
if (disposed) return;
|
|
87
|
+
workspaceSlug = await resolveWorkspaceSlug({
|
|
88
|
+
workspaceId: connection.workspaceId,
|
|
89
|
+
fallbackWorkspaces: workspaces,
|
|
90
|
+
queryClient,
|
|
91
|
+
});
|
|
92
|
+
if (disposed) return;
|
|
93
|
+
if (!workspaceSlug) {
|
|
94
|
+
setCompletedWorkspace({});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setCompletedWorkspace({slug: workspaceSlug});
|
|
67
98
|
await navigate({
|
|
68
|
-
to: '/
|
|
69
|
-
params: {
|
|
99
|
+
to: '/w/$workspaceSlug/settings/integrations',
|
|
100
|
+
params: {workspaceSlug},
|
|
70
101
|
replace: true,
|
|
71
102
|
});
|
|
72
103
|
} catch {
|
|
73
104
|
// Keep the completed callback page visible if client navigation is interrupted.
|
|
105
|
+
if (!disposed) setCompletedWorkspace({slug: workspaceSlug});
|
|
74
106
|
}
|
|
75
107
|
},
|
|
76
108
|
(error: unknown) => {
|
|
@@ -82,7 +114,16 @@ export function LinearCallbackPage() {
|
|
|
82
114
|
return () => {
|
|
83
115
|
disposed = true;
|
|
84
116
|
};
|
|
85
|
-
}, [
|
|
117
|
+
}, [
|
|
118
|
+
completeIntegrationCallback,
|
|
119
|
+
completeLinearCallback,
|
|
120
|
+
isLoading,
|
|
121
|
+
navigate,
|
|
122
|
+
params,
|
|
123
|
+
queryClient,
|
|
124
|
+
refreshAuth,
|
|
125
|
+
workspaces,
|
|
126
|
+
]);
|
|
86
127
|
|
|
87
128
|
if (!params) {
|
|
88
129
|
return (
|
|
@@ -93,22 +134,42 @@ export function LinearCallbackPage() {
|
|
|
93
134
|
startOver: true,
|
|
94
135
|
signIn: false,
|
|
95
136
|
}}
|
|
96
|
-
|
|
137
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
97
138
|
/>
|
|
98
139
|
);
|
|
99
140
|
}
|
|
100
141
|
|
|
142
|
+
if (completedWorkspace)
|
|
143
|
+
return (
|
|
144
|
+
<CallbackStatusShell
|
|
145
|
+
title="Linear connected"
|
|
146
|
+
status="success"
|
|
147
|
+
message={
|
|
148
|
+
completedWorkspace.slug
|
|
149
|
+
? 'Linear is connected. Continue in integrations settings.'
|
|
150
|
+
: 'Linear is connected. Return to Shipfox to continue.'
|
|
151
|
+
}
|
|
152
|
+
workspaceSlug={completedWorkspace.slug}
|
|
153
|
+
installPath="/w/$workspaceSlug/integrations/linear"
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
|
|
101
157
|
if (!failure) return <FullPageLoader aria-label="Connecting Linear" />;
|
|
102
158
|
|
|
103
|
-
return
|
|
159
|
+
return (
|
|
160
|
+
<LinearCallbackFailurePage
|
|
161
|
+
failure={failure}
|
|
162
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
104
165
|
}
|
|
105
166
|
|
|
106
167
|
function LinearCallbackFailurePage({
|
|
107
168
|
failure,
|
|
108
|
-
|
|
169
|
+
workspaceSlug,
|
|
109
170
|
}: {
|
|
110
171
|
failure: LinearCallbackFailure;
|
|
111
|
-
|
|
172
|
+
workspaceSlug: string | undefined;
|
|
112
173
|
}) {
|
|
113
174
|
return (
|
|
114
175
|
<CallbackStatusShell
|
|
@@ -116,8 +177,8 @@ function LinearCallbackFailurePage({
|
|
|
116
177
|
message={failure.message}
|
|
117
178
|
startOver={failure.startOver}
|
|
118
179
|
switchAccount={failure.signIn}
|
|
119
|
-
|
|
120
|
-
installPath="/
|
|
180
|
+
workspaceSlug={workspaceSlug}
|
|
181
|
+
installPath="/w/$workspaceSlug/integrations/linear"
|
|
121
182
|
/>
|
|
122
183
|
);
|
|
123
184
|
}
|
|
@@ -41,7 +41,10 @@ function renderCallback(options: {
|
|
|
41
41
|
path: `/integrations/sentry/callback${search}`,
|
|
42
42
|
routePath: '/integrations/sentry/callback',
|
|
43
43
|
element: <SentryCallbackPage />,
|
|
44
|
-
extraRoutes: [
|
|
44
|
+
extraRoutes: [
|
|
45
|
+
'/w/$workspaceSlug/settings/integrations',
|
|
46
|
+
'/w/$workspaceSlug/integrations/sentry',
|
|
47
|
+
],
|
|
45
48
|
...(options.workspaces ? {workspaces: options.workspaces} : {}),
|
|
46
49
|
...(options.loadingAuth ? {loadingAuth: true} : {}),
|
|
47
50
|
});
|
|
@@ -104,7 +107,7 @@ describe('SentryCallbackPage', () => {
|
|
|
104
107
|
|
|
105
108
|
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
106
109
|
|
|
107
|
-
await screen.findByTestId('route:/
|
|
110
|
+
await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
|
|
108
111
|
expect(connectSentryMock).toHaveBeenCalledWith({
|
|
109
112
|
body: {
|
|
110
113
|
workspace_id: testWorkspace().id,
|
|
@@ -126,7 +129,7 @@ describe('SentryCallbackPage', () => {
|
|
|
126
129
|
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
127
130
|
fireEvent.click(await screen.findByRole('button', {name: 'Retry'}));
|
|
128
131
|
|
|
129
|
-
await screen.findByTestId('route:/
|
|
132
|
+
await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
|
|
130
133
|
expect(connectSentryMock).toHaveBeenCalledTimes(2);
|
|
131
134
|
});
|
|
132
135
|
|
|
@@ -149,7 +152,7 @@ describe('SentryCallbackPage', () => {
|
|
|
149
152
|
await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled());
|
|
150
153
|
|
|
151
154
|
// A second attempt (via the still-enabled workspace Install) fails with no
|
|
152
|
-
// backoff hint
|
|
155
|
+
// backoff hint: the lock must clear so the user is not stranded.
|
|
153
156
|
fireEvent.click(screen.getByRole('button', {name: 'Install'}));
|
|
154
157
|
await waitFor(() => expect(screen.getByRole('button', {name: 'Retry'})).toBeEnabled());
|
|
155
158
|
});
|
|
@@ -203,7 +206,7 @@ describe('SentryCallbackPage', () => {
|
|
|
203
206
|
renderCallback({installationId: 'install-remount'});
|
|
204
207
|
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
205
208
|
|
|
206
|
-
await screen.findByTestId('route:/
|
|
209
|
+
await screen.findByTestId('route:/w/$workspaceSlug/settings/integrations');
|
|
207
210
|
await waitFor(() => expect(connectSentryMock).toHaveBeenCalledTimes(2));
|
|
208
211
|
});
|
|
209
212
|
});
|
|
@@ -7,6 +7,7 @@ import {Card} from '@shipfox/react-ui/card';
|
|
|
7
7
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
8
8
|
import {toast} from '@shipfox/react-ui/toast';
|
|
9
9
|
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
10
|
+
import {useQueryClient} from '@tanstack/react-query';
|
|
10
11
|
import {Link, useNavigate} from '@tanstack/react-router';
|
|
11
12
|
import {useEffect, useMemo, useRef, useState} from 'react';
|
|
12
13
|
import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
|
|
@@ -20,11 +21,13 @@ import {
|
|
|
20
21
|
readSentryInstallWorkspace,
|
|
21
22
|
type SentryConnectFailure,
|
|
22
23
|
} from '#sentry-callback.js';
|
|
24
|
+
import {resolveWorkspaceSlug} from '#workspace-navigation.js';
|
|
23
25
|
|
|
24
26
|
const connectRequests = createSingleFlight<string, IntegrationConnection>();
|
|
25
27
|
|
|
26
28
|
export function SentryCallbackPage() {
|
|
27
29
|
const navigate = useNavigate();
|
|
30
|
+
const queryClient = useQueryClient();
|
|
28
31
|
const refreshAuth = useRefreshAuth();
|
|
29
32
|
const {workspaces, isLoading} = useAuthState();
|
|
30
33
|
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
@@ -124,11 +127,21 @@ export function SentryCallbackPage() {
|
|
|
124
127
|
clearSentryInstallWorkspace(sessionStorageOrUndefined());
|
|
125
128
|
if (disposedRef.current) return;
|
|
126
129
|
toast.success('Sentry installed.');
|
|
127
|
-
await
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
const workspaceSlug = await resolveWorkspaceSlug({
|
|
131
|
+
workspaceId,
|
|
132
|
+
fallbackWorkspaces: workspaces,
|
|
133
|
+
queryClient,
|
|
131
134
|
});
|
|
135
|
+
if (disposedRef.current) return;
|
|
136
|
+
await navigate(
|
|
137
|
+
workspaceSlug
|
|
138
|
+
? {
|
|
139
|
+
to: '/w/$workspaceSlug/settings/integrations',
|
|
140
|
+
params: {workspaceSlug},
|
|
141
|
+
replace: true,
|
|
142
|
+
}
|
|
143
|
+
: {to: '/', replace: true},
|
|
144
|
+
);
|
|
132
145
|
})
|
|
133
146
|
.catch((error: unknown) => {
|
|
134
147
|
if (disposedRef.current) return;
|
|
@@ -151,6 +164,9 @@ export function SentryCallbackPage() {
|
|
|
151
164
|
: workspaces;
|
|
152
165
|
|
|
153
166
|
const failureWorkspaceId = failure?.workspaceId ?? preselectedId ?? workspaces[0]?.id;
|
|
167
|
+
const failureWorkspace = failureWorkspaceId
|
|
168
|
+
? workspaces.find(({id}) => id === failureWorkspaceId)
|
|
169
|
+
: undefined;
|
|
154
170
|
|
|
155
171
|
return (
|
|
156
172
|
<CallbackColumn>
|
|
@@ -178,9 +194,14 @@ export function SentryCallbackPage() {
|
|
|
178
194
|
Retry
|
|
179
195
|
</Button>
|
|
180
196
|
) : null}
|
|
181
|
-
{failure.failure.kind === 'terminal' &&
|
|
197
|
+
{failure.failure.kind === 'terminal' &&
|
|
198
|
+
failure.failure.startOver &&
|
|
199
|
+
failureWorkspace ? (
|
|
182
200
|
<Button asChild size="sm" variant="secondary" className="w-fit">
|
|
183
|
-
<Link
|
|
201
|
+
<Link
|
|
202
|
+
to="/w/$workspaceSlug/integrations/sentry"
|
|
203
|
+
params={{workspaceSlug: failureWorkspace.slug}}
|
|
204
|
+
>
|
|
184
205
|
Start over
|
|
185
206
|
</Link>
|
|
186
207
|
</Button>
|
|
@@ -209,9 +230,12 @@ export function SentryCallbackPage() {
|
|
|
209
230
|
))}
|
|
210
231
|
</section>
|
|
211
232
|
|
|
212
|
-
{
|
|
233
|
+
{failureWorkspace ? (
|
|
213
234
|
<ButtonLink asChild variant="muted" className="w-fit">
|
|
214
|
-
<Link
|
|
235
|
+
<Link
|
|
236
|
+
to="/w/$workspaceSlug/settings/integrations"
|
|
237
|
+
params={{workspaceSlug: failureWorkspace.slug}}
|
|
238
|
+
>
|
|
215
239
|
Back to settings
|
|
216
240
|
</Link>
|
|
217
241
|
</ButtonLink>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import {screen, waitFor} from '@testing-library/react';
|
|
4
|
+
import {StrictMode} from 'react';
|
|
5
|
+
import {SLACK_INSTALL_WORKSPACE_KEY} from '#slack-callback.js';
|
|
6
|
+
import {INTEGRATIONS_TEST_WID, renderIntegrationsPage, testWorkspace} from '#test/render.js';
|
|
7
|
+
import {SlackCallbackPage} from './slack-callback-page.js';
|
|
8
|
+
|
|
9
|
+
const {completeCallbackMock} = vi.hoisted(() => ({completeCallbackMock: vi.fn()}));
|
|
10
|
+
|
|
11
|
+
vi.mock('@shipfox/client-auth', async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<typeof import('@shipfox/client-auth')>();
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
useRefreshAuth: () => () => Promise.resolve({accessToken: 'test-token'}),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
vi.mock('#hooks/api/integrations.js', async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal<typeof import('#hooks/api/integrations.js')>();
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
useCompleteSlackCallbackMutation: () => ({mutateAsync: completeCallbackMock}),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
window.sessionStorage.clear();
|
|
29
|
+
completeCallbackMock.mockReset();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('SlackCallbackPage', () => {
|
|
33
|
+
test('completes a callback once in Strict Mode and navigates to its workspace', async () => {
|
|
34
|
+
const responseWorkspaceId = '22222222-2222-4222-8222-222222222222';
|
|
35
|
+
window.sessionStorage.setItem(SLACK_INSTALL_WORKSPACE_KEY, INTEGRATIONS_TEST_WID);
|
|
36
|
+
completeCallbackMock.mockResolvedValue({
|
|
37
|
+
id: 'connection-1',
|
|
38
|
+
workspaceId: responseWorkspaceId,
|
|
39
|
+
provider: 'slack',
|
|
40
|
+
slug: 'slack_org',
|
|
41
|
+
displayName: 'Slack org',
|
|
42
|
+
capabilities: [],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
renderIntegrationsPage({
|
|
46
|
+
path: '/integrations/slack/callback?code=grant-code-success&state=signed-state-success',
|
|
47
|
+
routePath: '/integrations/slack/callback',
|
|
48
|
+
element: (
|
|
49
|
+
<StrictMode>
|
|
50
|
+
<SlackCallbackPage />
|
|
51
|
+
</StrictMode>
|
|
52
|
+
),
|
|
53
|
+
workspaces: [
|
|
54
|
+
testWorkspace(),
|
|
55
|
+
testWorkspace({id: responseWorkspaceId, slug: 'response-workspace'}),
|
|
56
|
+
],
|
|
57
|
+
extraRoutes: [
|
|
58
|
+
'/w/response-workspace/settings/integrations',
|
|
59
|
+
'/w/$workspaceSlug/integrations/slack',
|
|
60
|
+
'/auth/login',
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await waitFor(() =>
|
|
65
|
+
expect(completeCallbackMock).toHaveBeenCalledWith({
|
|
66
|
+
query: {code: 'grant-code-success', state: 'signed-state-success'},
|
|
67
|
+
token: 'test-token',
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
expect(completeCallbackMock).toHaveBeenCalledTimes(1);
|
|
71
|
+
await waitFor(() =>
|
|
72
|
+
expect(
|
|
73
|
+
screen.getByTestId('route:/w/response-workspace/settings/integrations'),
|
|
74
|
+
).toBeInTheDocument(),
|
|
75
|
+
);
|
|
76
|
+
expect(screen.getByText('Slack installed.')).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {useRefreshAuth} from '@shipfox/client-auth';
|
|
1
|
+
import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
|
|
2
2
|
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
3
|
import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
4
4
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
5
5
|
import {toast} from '@shipfox/react-ui/toast';
|
|
6
|
+
import {useQueryClient} from '@tanstack/react-query';
|
|
6
7
|
import {useNavigate} from '@tanstack/react-router';
|
|
7
8
|
import {useEffect, useMemo, useState} from 'react';
|
|
8
9
|
import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type SlackCallbackFailure,
|
|
18
19
|
serializeSlackCallbackQuery,
|
|
19
20
|
} from '#slack-callback.js';
|
|
21
|
+
import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
|
|
20
22
|
|
|
21
23
|
// Retain only recent completions: this bounds long-lived callback pages while
|
|
22
24
|
// still covering StrictMode and immediate Back/Forward remounts.
|
|
@@ -28,16 +30,20 @@ const toastedCallbacks = new Set<string>();
|
|
|
28
30
|
|
|
29
31
|
export function SlackCallbackPage() {
|
|
30
32
|
const navigate = useNavigate();
|
|
33
|
+
const queryClient = useQueryClient();
|
|
31
34
|
const refreshAuth = useRefreshAuth();
|
|
32
35
|
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
33
36
|
const {mutateAsync: completeSlackCallback} = useCompleteSlackCallbackMutation();
|
|
37
|
+
const {workspaces, isLoading} = useAuthState();
|
|
34
38
|
const params = useRouteSearch(parseSlackCallbackQuery);
|
|
35
39
|
const workspaceId = useMemo(() => readSlackInstallWorkspace(sessionStorageOrUndefined()), []);
|
|
36
40
|
const [failure, setFailure] = useState<SlackCallbackFailure>();
|
|
37
|
-
const [
|
|
41
|
+
const [completedWorkspace, setCompletedWorkspace] = useState<{
|
|
42
|
+
slug?: string | undefined;
|
|
43
|
+
}>();
|
|
38
44
|
|
|
39
45
|
useEffect(() => {
|
|
40
|
-
if (!params) return;
|
|
46
|
+
if (!params || isLoading) return;
|
|
41
47
|
let disposed = false;
|
|
42
48
|
const key = serializeSlackCallbackQuery(params);
|
|
43
49
|
const request = callbackRequests.run(
|
|
@@ -53,10 +59,15 @@ export function SlackCallbackPage() {
|
|
|
53
59
|
async (connection) => {
|
|
54
60
|
if (disposed) return;
|
|
55
61
|
if (completedCallbacks.has(key)) {
|
|
56
|
-
|
|
62
|
+
const workspaceSlug = await resolveWorkspaceSlug({
|
|
63
|
+
workspaceId: connection.workspaceId,
|
|
64
|
+
fallbackWorkspaces: workspaces,
|
|
65
|
+
queryClient,
|
|
66
|
+
});
|
|
67
|
+
if (!disposed) setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
|
|
57
68
|
return;
|
|
58
69
|
}
|
|
59
|
-
completedCallbacks
|
|
70
|
+
rememberCallbackKey(completedCallbacks, key);
|
|
60
71
|
try {
|
|
61
72
|
clearSlackInstallWorkspace(sessionStorageOrUndefined());
|
|
62
73
|
} catch {
|
|
@@ -64,17 +75,30 @@ export function SlackCallbackPage() {
|
|
|
64
75
|
}
|
|
65
76
|
if (disposed) return;
|
|
66
77
|
if (!toastedCallbacks.has(key)) {
|
|
67
|
-
toastedCallbacks
|
|
78
|
+
rememberCallbackKey(toastedCallbacks, key);
|
|
68
79
|
toast.success('Slack installed.');
|
|
69
80
|
}
|
|
81
|
+
let workspaceSlug: string | undefined;
|
|
70
82
|
try {
|
|
83
|
+
if (disposed) return;
|
|
84
|
+
workspaceSlug = await resolveWorkspaceSlug({
|
|
85
|
+
workspaceId: connection.workspaceId,
|
|
86
|
+
fallbackWorkspaces: workspaces,
|
|
87
|
+
queryClient,
|
|
88
|
+
});
|
|
89
|
+
if (disposed) return;
|
|
90
|
+
if (!workspaceSlug) {
|
|
91
|
+
setCompletedWorkspace({});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setCompletedWorkspace({slug: workspaceSlug});
|
|
71
95
|
await navigate({
|
|
72
|
-
to: '/
|
|
73
|
-
params: {
|
|
96
|
+
to: '/w/$workspaceSlug/settings/integrations',
|
|
97
|
+
params: {workspaceSlug},
|
|
74
98
|
replace: true,
|
|
75
99
|
});
|
|
76
100
|
} catch {
|
|
77
|
-
if (!disposed)
|
|
101
|
+
if (!disposed) setCompletedWorkspace({slug: workspaceSlug});
|
|
78
102
|
}
|
|
79
103
|
},
|
|
80
104
|
(error: unknown) => {
|
|
@@ -84,7 +108,16 @@ export function SlackCallbackPage() {
|
|
|
84
108
|
return () => {
|
|
85
109
|
disposed = true;
|
|
86
110
|
};
|
|
87
|
-
}, [
|
|
111
|
+
}, [
|
|
112
|
+
completeIntegrationCallback,
|
|
113
|
+
completeSlackCallback,
|
|
114
|
+
isLoading,
|
|
115
|
+
navigate,
|
|
116
|
+
params,
|
|
117
|
+
queryClient,
|
|
118
|
+
refreshAuth,
|
|
119
|
+
workspaces,
|
|
120
|
+
]);
|
|
88
121
|
|
|
89
122
|
if (!params)
|
|
90
123
|
return (
|
|
@@ -92,26 +125,31 @@ export function SlackCallbackPage() {
|
|
|
92
125
|
title="Invalid Slack callback"
|
|
93
126
|
message="This Slack link is missing required parameters. Start the install again from workspace settings."
|
|
94
127
|
startOver
|
|
95
|
-
|
|
96
|
-
installPath="/
|
|
128
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
129
|
+
installPath="/w/$workspaceSlug/integrations/slack"
|
|
97
130
|
/>
|
|
98
131
|
);
|
|
99
|
-
if (
|
|
132
|
+
if (completedWorkspace)
|
|
100
133
|
return (
|
|
101
134
|
<CallbackStatusShell
|
|
102
135
|
title="Slack connected"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
status="success"
|
|
137
|
+
message={
|
|
138
|
+
completedWorkspace.slug
|
|
139
|
+
? 'Slack is connected. Continue in integrations settings.'
|
|
140
|
+
: 'Slack is connected. Return to Shipfox to continue.'
|
|
141
|
+
}
|
|
142
|
+
workspaceSlug={completedWorkspace.slug}
|
|
143
|
+
installPath="/w/$workspaceSlug/integrations/slack"
|
|
106
144
|
/>
|
|
107
145
|
);
|
|
108
146
|
if (failure)
|
|
109
147
|
return (
|
|
110
148
|
<CallbackStatusShell
|
|
111
149
|
{...failure}
|
|
112
|
-
|
|
150
|
+
workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
|
|
113
151
|
switchAccount={failure.signIn}
|
|
114
|
-
installPath="/
|
|
152
|
+
installPath="/w/$workspaceSlug/integrations/slack"
|
|
115
153
|
/>
|
|
116
154
|
);
|
|
117
155
|
return <FullPageLoader aria-label="Connecting Slack" />;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import '@testing-library/jest-dom/vitest';
|
|
3
3
|
import {configureApiClient} from '@shipfox/client-api';
|
|
4
4
|
import {screen} from '@testing-library/react';
|
|
5
|
-
import {
|
|
5
|
+
import {jsonResponse, renderIntegrationsPage} from '#test/render.js';
|
|
6
6
|
import {SourceControlOnboardingPage} from './source-control-onboarding-page.js';
|
|
7
7
|
|
|
8
8
|
describe('SourceControlOnboardingPage', () => {
|
|
@@ -24,10 +24,10 @@ describe('SourceControlOnboardingPage', () => {
|
|
|
24
24
|
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
25
25
|
|
|
26
26
|
renderIntegrationsPage({
|
|
27
|
-
path:
|
|
28
|
-
routePath: '/
|
|
27
|
+
path: '/w/acme/integrations',
|
|
28
|
+
routePath: '/w/$workspaceSlug/integrations',
|
|
29
29
|
element: <SourceControlOnboardingPage />,
|
|
30
|
-
extraRoutes: ['/
|
|
30
|
+
extraRoutes: ['/w/$workspaceSlug/integrations/github'],
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
expect(await screen.findByRole('heading', {name: 'Install source control'})).toBeVisible();
|
|
@@ -17,12 +17,12 @@ export function SourceControlOnboardingPage() {
|
|
|
17
17
|
</header>
|
|
18
18
|
|
|
19
19
|
<ProviderGrid
|
|
20
|
+
workspaceSlug={workspace.slug}
|
|
20
21
|
providers={providersQuery.data ?? []}
|
|
21
22
|
isPending={providersQuery.isPending}
|
|
22
23
|
isFetching={providersQuery.isFetching}
|
|
23
24
|
error={providersQuery.isError ? providersQuery.error : undefined}
|
|
24
25
|
onRetry={() => void providersQuery.refetch()}
|
|
25
|
-
workspaceId={workspace.id}
|
|
26
26
|
emptyMessage="Enable at least one source-control provider in the application settings."
|
|
27
27
|
/>
|
|
28
28
|
</div>
|