@shipfox/client-integrations 12.0.2 → 14.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.
Files changed (145) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/dist/application/complete-integration-callback.d.ts +11 -0
  4. package/dist/application/complete-integration-callback.d.ts.map +1 -1
  5. package/dist/application/complete-integration-callback.js +38 -8
  6. package/dist/application/complete-integration-callback.js.map +1 -1
  7. package/dist/components/callback-status-shell.d.ts +4 -3
  8. package/dist/components/callback-status-shell.d.ts.map +1 -1
  9. package/dist/components/callback-status-shell.js +11 -11
  10. package/dist/components/callback-status-shell.js.map +1 -1
  11. package/dist/components/installed-integrations-section.d.ts +2 -1
  12. package/dist/components/installed-integrations-section.d.ts.map +1 -1
  13. package/dist/components/installed-integrations-section.js +7 -6
  14. package/dist/components/installed-integrations-section.js.map +1 -1
  15. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
  16. package/dist/components/integration-gallery-for-workspace.js +11 -2
  17. package/dist/components/integration-gallery-for-workspace.js.map +1 -1
  18. package/dist/components/integration-gallery.stories.js +10 -9
  19. package/dist/components/integration-gallery.stories.js.map +1 -1
  20. package/dist/components/provider-grid.d.ts +2 -2
  21. package/dist/components/provider-grid.d.ts.map +1 -1
  22. package/dist/components/provider-grid.js +4 -4
  23. package/dist/components/provider-grid.js.map +1 -1
  24. package/dist/components/redirect-install-page.d.ts +1 -1
  25. package/dist/components/redirect-install-page.js +2 -2
  26. package/dist/components/redirect-install-page.js.map +1 -1
  27. package/dist/components/webhook/webhook-modals.stories.js +2 -2
  28. package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
  29. package/dist/core/models.d.ts +6 -0
  30. package/dist/core/models.d.ts.map +1 -1
  31. package/dist/core/models.js +12 -0
  32. package/dist/core/models.js.map +1 -1
  33. package/dist/feature.d.ts +15 -7
  34. package/dist/feature.d.ts.map +1 -1
  35. package/dist/feature.js +17 -7
  36. package/dist/feature.js.map +1 -1
  37. package/dist/hooks/api/integration-mapper.d.ts +3 -1
  38. package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
  39. package/dist/hooks/api/integration-mapper.js +8 -0
  40. package/dist/hooks/api/integration-mapper.js.map +1 -1
  41. package/dist/hooks/api/integrations.d.ts +25 -1
  42. package/dist/hooks/api/integrations.d.ts.map +1 -1
  43. package/dist/hooks/api/integrations.js +45 -2
  44. package/dist/hooks/api/integrations.js.map +1 -1
  45. package/dist/index.d.ts +4 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +4 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/jira-callback-state.d.ts +9 -0
  50. package/dist/jira-callback-state.d.ts.map +1 -0
  51. package/dist/jira-callback-state.js +16 -0
  52. package/dist/jira-callback-state.js.map +1 -0
  53. package/dist/jira-callback.d.ts +11 -0
  54. package/dist/jira-callback.d.ts.map +1 -0
  55. package/dist/jira-callback.js +59 -0
  56. package/dist/jira-callback.js.map +1 -0
  57. package/dist/jira-form-errors.d.ts +8 -0
  58. package/dist/jira-form-errors.d.ts.map +1 -0
  59. package/dist/jira-form-errors.js +55 -0
  60. package/dist/jira-form-errors.js.map +1 -0
  61. package/dist/pages/gitea-install-page.js +4 -4
  62. package/dist/pages/gitea-install-page.js.map +1 -1
  63. package/dist/pages/jira-callback-page.d.ts +2 -0
  64. package/dist/pages/jira-callback-page.d.ts.map +1 -0
  65. package/dist/pages/jira-callback-page.js +341 -0
  66. package/dist/pages/jira-callback-page.js.map +1 -0
  67. package/dist/pages/jira-install-page.d.ts +2 -0
  68. package/dist/pages/jira-install-page.d.ts.map +1 -0
  69. package/dist/pages/jira-install-page.js +26 -0
  70. package/dist/pages/jira-install-page.js.map +1 -0
  71. package/dist/pages/linear-callback-page.d.ts.map +1 -1
  72. package/dist/pages/linear-callback-page.js +58 -12
  73. package/dist/pages/linear-callback-page.js.map +1 -1
  74. package/dist/pages/sentry-callback-page.d.ts.map +1 -1
  75. package/dist/pages/sentry-callback-page.js +22 -9
  76. package/dist/pages/sentry-callback-page.js.map +1 -1
  77. package/dist/pages/slack-callback-page.d.ts.map +1 -1
  78. package/dist/pages/slack-callback-page.js +50 -18
  79. package/dist/pages/slack-callback-page.js.map +1 -1
  80. package/dist/pages/source-control-onboarding-page.js +1 -1
  81. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  82. package/dist/provider-catalog.d.ts +1 -1
  83. package/dist/provider-catalog.d.ts.map +1 -1
  84. package/dist/provider-catalog.js +10 -5
  85. package/dist/provider-catalog.js.map +1 -1
  86. package/dist/routes/jira-callback.d.ts +8 -0
  87. package/dist/routes/jira-callback.d.ts.map +1 -0
  88. package/dist/routes/jira-callback.js +9 -0
  89. package/dist/routes/jira-callback.js.map +1 -0
  90. package/dist/routes/jira.d.ts +6 -0
  91. package/dist/routes/jira.d.ts.map +1 -0
  92. package/dist/routes/jira.js +7 -0
  93. package/dist/routes/jira.js.map +1 -0
  94. package/dist/sentry-callback.js +2 -2
  95. package/dist/sentry-callback.js.map +1 -1
  96. package/dist/tsconfig.test.tsbuildinfo +1 -1
  97. package/dist/workspace-navigation.d.ts +13 -0
  98. package/dist/workspace-navigation.d.ts.map +1 -0
  99. package/dist/workspace-navigation.js +17 -0
  100. package/dist/workspace-navigation.js.map +1 -0
  101. package/package.json +14 -13
  102. package/src/application/complete-integration-callback.test.ts +44 -1
  103. package/src/application/complete-integration-callback.ts +61 -8
  104. package/src/components/callback-status-shell.tsx +17 -10
  105. package/src/components/installed-integrations-section.tsx +16 -9
  106. package/src/components/integration-gallery-for-workspace.tsx +20 -10
  107. package/src/components/integration-gallery.stories.tsx +10 -10
  108. package/src/components/integration-gallery.test.tsx +30 -15
  109. package/src/components/integration-usage-events.test.ts +13 -0
  110. package/src/components/provider-grid.tsx +6 -6
  111. package/src/components/redirect-install-page.test.tsx +3 -3
  112. package/src/components/redirect-install-page.tsx +2 -2
  113. package/src/components/webhook/webhook-modals.stories.tsx +2 -2
  114. package/src/core/models.ts +18 -0
  115. package/src/feature.ts +17 -7
  116. package/src/hooks/api/integration-mapper.ts +6 -0
  117. package/src/hooks/api/integrations.test.ts +92 -0
  118. package/src/hooks/api/integrations.ts +72 -1
  119. package/src/index.ts +4 -0
  120. package/src/jira-callback-state.ts +19 -0
  121. package/src/jira-callback.test.ts +76 -0
  122. package/src/jira-callback.ts +67 -0
  123. package/src/jira-form-errors.test.ts +57 -0
  124. package/src/jira-form-errors.ts +94 -0
  125. package/src/pages/gitea-install-page.tsx +2 -2
  126. package/src/pages/jira-callback-page.test.tsx +250 -0
  127. package/src/pages/jira-callback-page.tsx +366 -0
  128. package/src/pages/jira-install-page.tsx +28 -0
  129. package/src/pages/linear-callback-page.test.tsx +25 -12
  130. package/src/pages/linear-callback-page.tsx +73 -12
  131. package/src/pages/sentry-callback-page.test.tsx +8 -5
  132. package/src/pages/sentry-callback-page.tsx +32 -8
  133. package/src/pages/slack-callback-page.test.tsx +78 -0
  134. package/src/pages/slack-callback-page.tsx +56 -18
  135. package/src/pages/source-control-onboarding-page.test.tsx +4 -4
  136. package/src/pages/source-control-onboarding-page.tsx +1 -1
  137. package/src/provider-catalog.test.ts +10 -4
  138. package/src/provider-catalog.ts +16 -10
  139. package/src/routes/jira-callback.tsx +8 -0
  140. package/src/routes/jira.tsx +4 -0
  141. package/src/sentry-callback.ts +2 -2
  142. package/src/workspace-navigation.test.ts +43 -0
  143. package/src/workspace-navigation.ts +38 -0
  144. package/test/render.tsx +2 -1
  145. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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 navigate({
128
- to: '/workspaces/$wid/settings/integrations',
129
- params: {wid: workspaceId},
130
- replace: true,
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' && failure.failure.startOver ? (
197
+ {failure.failure.kind === 'terminal' &&
198
+ failure.failure.startOver &&
199
+ failureWorkspace ? (
182
200
  <Button asChild size="sm" variant="secondary" className="w-fit">
183
- <Link to="/workspaces/$wid/integrations/sentry" params={{wid: failure.workspaceId}}>
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
- {failureWorkspaceId ? (
233
+ {failureWorkspace ? (
213
234
  <ButtonLink asChild variant="muted" className="w-fit">
214
- <Link to="/workspaces/$wid/settings/integrations" params={{wid: failureWorkspaceId}}>
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 [completedWorkspaceId, setCompletedWorkspaceId] = useState<string>();
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
- setCompletedWorkspaceId(connection.workspaceId);
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.add(key);
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.add(key);
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: '/workspaces/$wid/settings/integrations',
73
- params: {wid: connection.workspaceId},
96
+ to: '/w/$workspaceSlug/settings/integrations',
97
+ params: {workspaceSlug},
74
98
  replace: true,
75
99
  });
76
100
  } catch {
77
- if (!disposed) setCompletedWorkspaceId(connection.workspaceId);
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
- }, [completeIntegrationCallback, completeSlackCallback, navigate, params, refreshAuth]);
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
- workspaceId={workspaceId}
96
- installPath="/workspaces/$wid/integrations/slack"
128
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
129
+ installPath="/w/$workspaceSlug/integrations/slack"
97
130
  />
98
131
  );
99
- if (completedWorkspaceId)
132
+ if (completedWorkspace)
100
133
  return (
101
134
  <CallbackStatusShell
102
135
  title="Slack connected"
103
- message="Slack is connected. Continue in integrations settings."
104
- workspaceId={completedWorkspaceId}
105
- installPath="/workspaces/$wid/integrations/slack"
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
- workspaceId={workspaceId}
150
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
113
151
  switchAccount={failure.signIn}
114
- installPath="/workspaces/$wid/integrations/slack"
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 {INTEGRATIONS_TEST_WID, jsonResponse, renderIntegrationsPage} from '#test/render.js';
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: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
28
- routePath: '/workspaces/$wid/integrations',
27
+ path: '/w/acme/integrations',
28
+ routePath: '/w/$workspaceSlug/integrations',
29
29
  element: <SourceControlOnboardingPage />,
30
- extraRoutes: ['/workspaces/$wid/integrations/github'],
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>
@@ -4,20 +4,25 @@ describe('PROVIDER_CATALOG', () => {
4
4
  test('contains route entries for github, sentry, and gitea', () => {
5
5
  expect(PROVIDER_CATALOG.github).toMatchObject({
6
6
  kind: 'redirect-install',
7
- setupPath: '/workspaces/$wid/integrations/github',
7
+ setupPath: '/w/$workspaceSlug/integrations/github',
8
8
  });
9
9
  expect(PROVIDER_CATALOG.sentry).toMatchObject({
10
10
  kind: 'redirect-install',
11
- setupPath: '/workspaces/$wid/integrations/sentry',
11
+ setupPath: '/w/$workspaceSlug/integrations/sentry',
12
12
  });
13
13
  expect(PROVIDER_CATALOG.linear).toMatchObject({
14
14
  kind: 'redirect-install',
15
15
  iconName: 'linear',
16
- setupPath: '/workspaces/$wid/integrations/linear',
16
+ setupPath: '/w/$workspaceSlug/integrations/linear',
17
+ });
18
+ expect(PROVIDER_CATALOG.jira).toMatchObject({
19
+ kind: 'redirect-install',
20
+ iconName: 'jira',
21
+ setupPath: '/w/$workspaceSlug/integrations/jira',
17
22
  });
18
23
  expect(PROVIDER_CATALOG.gitea).toMatchObject({
19
24
  kind: 'direct-connect',
20
- setupPath: '/workspaces/$wid/integrations/gitea',
25
+ setupPath: '/w/$workspaceSlug/integrations/gitea',
21
26
  });
22
27
  });
23
28
 
@@ -25,6 +30,7 @@ describe('PROVIDER_CATALOG', () => {
25
30
  expect(PROVIDER_CATALOG.github?.kind).toBe('redirect-install');
26
31
  expect(PROVIDER_CATALOG.sentry?.kind).toBe('redirect-install');
27
32
  expect(PROVIDER_CATALOG.linear?.kind).toBe('redirect-install');
33
+ expect(PROVIDER_CATALOG.jira?.kind).toBe('redirect-install');
28
34
  expect(PROVIDER_CATALOG.gitea?.kind).toBe('direct-connect');
29
35
  expect(PROVIDER_CATALOG.webhook?.kind).toBe('modal-connect');
30
36
  });
@@ -4,11 +4,12 @@ import type {IconName} from '@shipfox/react-ui/icon';
4
4
  // Literal union (not string) so `<Link to={catalog.setupPath}>` stays typed
5
5
  // against TanStack Router's route tree.
6
6
  export type ProviderSetupPath =
7
- | '/workspaces/$wid/integrations/github'
8
- | '/workspaces/$wid/integrations/gitea'
9
- | '/workspaces/$wid/integrations/sentry'
10
- | '/workspaces/$wid/integrations/linear'
11
- | '/workspaces/$wid/integrations/slack';
7
+ | '/w/$workspaceSlug/integrations/github'
8
+ | '/w/$workspaceSlug/integrations/gitea'
9
+ | '/w/$workspaceSlug/integrations/sentry'
10
+ | '/w/$workspaceSlug/integrations/linear'
11
+ | '/w/$workspaceSlug/integrations/slack'
12
+ | '/w/$workspaceSlug/integrations/jira';
12
13
 
13
14
  interface RouteProviderCatalogEntry {
14
15
  kind: 'redirect-install' | 'direct-connect';
@@ -27,27 +28,32 @@ export const PROVIDER_CATALOG: Record<string, ProviderCatalogEntry> = {
27
28
  github: {
28
29
  kind: 'redirect-install',
29
30
  iconName: PROVIDER_ICONS.github,
30
- setupPath: '/workspaces/$wid/integrations/github',
31
+ setupPath: '/w/$workspaceSlug/integrations/github',
31
32
  },
32
33
  sentry: {
33
34
  kind: 'redirect-install',
34
35
  iconName: PROVIDER_ICONS.sentry,
35
- setupPath: '/workspaces/$wid/integrations/sentry',
36
+ setupPath: '/w/$workspaceSlug/integrations/sentry',
36
37
  },
37
38
  linear: {
38
39
  kind: 'redirect-install',
39
40
  iconName: PROVIDER_ICONS.linear,
40
- setupPath: '/workspaces/$wid/integrations/linear',
41
+ setupPath: '/w/$workspaceSlug/integrations/linear',
41
42
  },
42
43
  slack: {
43
44
  kind: 'redirect-install',
44
45
  iconName: PROVIDER_ICONS.slack,
45
- setupPath: '/workspaces/$wid/integrations/slack',
46
+ setupPath: '/w/$workspaceSlug/integrations/slack',
47
+ },
48
+ jira: {
49
+ kind: 'redirect-install',
50
+ iconName: PROVIDER_ICONS.jira,
51
+ setupPath: '/w/$workspaceSlug/integrations/jira',
46
52
  },
47
53
  gitea: {
48
54
  kind: 'direct-connect',
49
55
  iconName: PROVIDER_ICONS.gitea,
50
- setupPath: '/workspaces/$wid/integrations/gitea',
56
+ setupPath: '/w/$workspaceSlug/integrations/gitea',
51
57
  },
52
58
  webhook: {
53
59
  kind: 'modal-connect',
@@ -0,0 +1,8 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {JiraCallbackPage} from '#pages/jira-callback-page.js';
3
+ import {parseJiraCallbackQuery} from '../jira-callback.js';
4
+
5
+ export default defineRoute({
6
+ validateSearch: parseJiraCallbackQuery,
7
+ component: JiraCallbackPage,
8
+ });
@@ -0,0 +1,4 @@
1
+ import {defineRoute} from '@shipfox/client-shell/runtime';
2
+ import {JiraInstallPage} from '#pages/jira-install-page.js';
3
+
4
+ export default defineRoute({component: JiraInstallPage});
@@ -20,7 +20,7 @@ const sentryInstallWorkspaceStorageKey = {
20
20
  } satisfies BrowserStorageKey<string>;
21
21
 
22
22
  // Storage helpers swallow failures (quota, private mode, disabled storage):
23
- // the handoff is an optimization, never a requirement the callback always
23
+ // the handoff is an optimization, never a requirement: the callback always
24
24
  // asks for explicit confirmation and only uses the stored id to pre-select.
25
25
  export function saveSentryInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
26
26
  installWorkspaceStorage(storage).write(workspaceId);
@@ -67,7 +67,7 @@ export type SentryWorkspacePreselection =
67
67
  | {kind: 'none'};
68
68
 
69
69
  // Sentry's redirect carries no state token, so the callback can never prove it
70
- // belongs to an install the user started installation always requires an
70
+ // belongs to an install the user started. Installation always requires an
71
71
  // explicit click. The stored id (and a sole workspace) only pre-select.
72
72
  export function preselectSentryWorkspace(
73
73
  storedId: string | undefined,
@@ -0,0 +1,43 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {userWorkspacesQueryKey} from '@shipfox/client-shell/runtime';
3
+ import {QueryClient} from '@tanstack/react-query';
4
+ import {resolveWorkspaceSlug} from './workspace-navigation.js';
5
+
6
+ function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
7
+ return new Response(JSON.stringify(body), {
8
+ headers: {'content-type': 'application/json'},
9
+ status: 200,
10
+ ...init,
11
+ });
12
+ }
13
+
14
+ describe('resolveWorkspaceSlug', () => {
15
+ beforeEach(() => {
16
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
17
+ });
18
+
19
+ test('uses refreshed cached memberships when the follow-up list request fails', async () => {
20
+ configureApiClient({
21
+ fetchImpl: vi
22
+ .fn()
23
+ .mockResolvedValue(
24
+ jsonResponse(
25
+ {code: 'server-error', message: 'Workspace list unavailable'},
26
+ {status: 500},
27
+ ),
28
+ ),
29
+ });
30
+ const queryClient = new QueryClient();
31
+ queryClient.setQueryData(userWorkspacesQueryKey, {
32
+ memberships: [{id: 'response-workspace', slug: 'response-workspace'}],
33
+ });
34
+
35
+ await expect(
36
+ resolveWorkspaceSlug({
37
+ workspaceId: 'response-workspace',
38
+ fallbackWorkspaces: [{id: 'response-workspace', slug: 'old-slug'}],
39
+ queryClient,
40
+ }),
41
+ ).resolves.toBe('response-workspace');
42
+ });
43
+ });
@@ -0,0 +1,38 @@
1
+ import {listUserWorkspaces, userWorkspacesQueryKey} from '@shipfox/client-shell/runtime';
2
+ import type {QueryClient} from '@tanstack/react-query';
3
+
4
+ interface WorkspaceSlugCandidate {
5
+ id: string;
6
+ slug: string;
7
+ }
8
+
9
+ interface UserWorkspacesQueryData {
10
+ memberships: WorkspaceSlugCandidate[];
11
+ }
12
+
13
+ export function rememberCallbackKey(keys: Set<string>, key: string, maxSize = 32): void {
14
+ keys.delete(key);
15
+ keys.add(key);
16
+ if (keys.size <= maxSize) return;
17
+ const oldest = keys.values().next().value;
18
+ if (typeof oldest === 'string') keys.delete(oldest);
19
+ }
20
+
21
+ export async function resolveWorkspaceSlug({
22
+ workspaceId,
23
+ fallbackWorkspaces,
24
+ queryClient,
25
+ }: {
26
+ workspaceId: string;
27
+ fallbackWorkspaces: readonly WorkspaceSlugCandidate[];
28
+ queryClient: QueryClient;
29
+ }): Promise<string | undefined> {
30
+ const currentWorkspaces = await listUserWorkspaces()
31
+ .then(({memberships}) => memberships)
32
+ .catch(() => {
33
+ const refreshedWorkspaces =
34
+ queryClient.getQueryData<UserWorkspacesQueryData>(userWorkspacesQueryKey);
35
+ return refreshedWorkspaces?.memberships ?? fallbackWorkspaces;
36
+ });
37
+ return currentWorkspaces.find(({id}) => id === workspaceId)?.slug;
38
+ }
package/test/render.tsx CHANGED
@@ -19,11 +19,12 @@ export const INTEGRATIONS_TEST_WID = '11111111-1111-4111-8111-111111111111';
19
19
  export interface TestWorkspace {
20
20
  id: string;
21
21
  name: string;
22
+ slug: string;
22
23
  membershipId: string;
23
24
  }
24
25
 
25
26
  export function testWorkspace(overrides: Partial<TestWorkspace> = {}): TestWorkspace {
26
- return {id: INTEGRATIONS_TEST_WID, name: 'Acme', membershipId: 'm-1', ...overrides};
27
+ return {id: INTEGRATIONS_TEST_WID, name: 'Acme', slug: 'acme', membershipId: 'm-1', ...overrides};
27
28
  }
28
29
 
29
30
  export function jsonResponse(body: unknown, init: ResponseInit = {}) {