@shipfox/client-integrations 13.0.0 → 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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +18 -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 +1 -1
  8. package/dist/components/callback-status-shell.d.ts.map +1 -1
  9. package/dist/components/callback-status-shell.js.map +1 -1
  10. package/dist/core/models.d.ts +6 -0
  11. package/dist/core/models.d.ts.map +1 -1
  12. package/dist/core/models.js +12 -0
  13. package/dist/core/models.js.map +1 -1
  14. package/dist/feature.d.ts +8 -0
  15. package/dist/feature.d.ts.map +1 -1
  16. package/dist/feature.js +10 -0
  17. package/dist/feature.js.map +1 -1
  18. package/dist/hooks/api/integration-mapper.d.ts +3 -1
  19. package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
  20. package/dist/hooks/api/integration-mapper.js +8 -0
  21. package/dist/hooks/api/integration-mapper.js.map +1 -1
  22. package/dist/hooks/api/integrations.d.ts +25 -1
  23. package/dist/hooks/api/integrations.d.ts.map +1 -1
  24. package/dist/hooks/api/integrations.js +44 -1
  25. package/dist/hooks/api/integrations.js.map +1 -1
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/jira-callback-state.d.ts +9 -0
  31. package/dist/jira-callback-state.d.ts.map +1 -0
  32. package/dist/jira-callback-state.js +16 -0
  33. package/dist/jira-callback-state.js.map +1 -0
  34. package/dist/jira-callback.d.ts +11 -0
  35. package/dist/jira-callback.d.ts.map +1 -0
  36. package/dist/jira-callback.js +59 -0
  37. package/dist/jira-callback.js.map +1 -0
  38. package/dist/jira-form-errors.d.ts +8 -0
  39. package/dist/jira-form-errors.d.ts.map +1 -0
  40. package/dist/jira-form-errors.js +55 -0
  41. package/dist/jira-form-errors.js.map +1 -0
  42. package/dist/pages/jira-callback-page.d.ts +2 -0
  43. package/dist/pages/jira-callback-page.d.ts.map +1 -0
  44. package/dist/pages/jira-callback-page.js +341 -0
  45. package/dist/pages/jira-callback-page.js.map +1 -0
  46. package/dist/pages/jira-install-page.d.ts +2 -0
  47. package/dist/pages/jira-install-page.d.ts.map +1 -0
  48. package/dist/pages/jira-install-page.js +26 -0
  49. package/dist/pages/jira-install-page.js.map +1 -0
  50. package/dist/provider-catalog.d.ts +1 -1
  51. package/dist/provider-catalog.d.ts.map +1 -1
  52. package/dist/provider-catalog.js +5 -0
  53. package/dist/provider-catalog.js.map +1 -1
  54. package/dist/routes/jira-callback.d.ts +8 -0
  55. package/dist/routes/jira-callback.d.ts.map +1 -0
  56. package/dist/routes/jira-callback.js +9 -0
  57. package/dist/routes/jira-callback.js.map +1 -0
  58. package/dist/routes/jira.d.ts +6 -0
  59. package/dist/routes/jira.d.ts.map +1 -0
  60. package/dist/routes/jira.js +7 -0
  61. package/dist/routes/jira.js.map +1 -0
  62. package/dist/tsconfig.test.tsbuildinfo +1 -1
  63. package/package.json +9 -8
  64. package/src/application/complete-integration-callback.test.ts +44 -1
  65. package/src/application/complete-integration-callback.ts +61 -8
  66. package/src/components/callback-status-shell.tsx +4 -1
  67. package/src/components/integration-usage-events.test.ts +13 -0
  68. package/src/core/models.ts +18 -0
  69. package/src/feature.ts +10 -0
  70. package/src/hooks/api/integration-mapper.ts +6 -0
  71. package/src/hooks/api/integrations.test.ts +92 -0
  72. package/src/hooks/api/integrations.ts +71 -0
  73. package/src/index.ts +4 -0
  74. package/src/jira-callback-state.ts +19 -0
  75. package/src/jira-callback.test.ts +76 -0
  76. package/src/jira-callback.ts +67 -0
  77. package/src/jira-form-errors.test.ts +57 -0
  78. package/src/jira-form-errors.ts +94 -0
  79. package/src/pages/jira-callback-page.test.tsx +250 -0
  80. package/src/pages/jira-callback-page.tsx +366 -0
  81. package/src/pages/jira-install-page.tsx +28 -0
  82. package/src/provider-catalog.test.ts +6 -0
  83. package/src/provider-catalog.ts +7 -1
  84. package/src/routes/jira-callback.tsx +8 -0
  85. package/src/routes/jira.tsx +4 -0
  86. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,366 @@
1
+ import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
2
+ import {useRouteSearch} from '@shipfox/client-shell/runtime';
3
+ import {sessionStorageOrUndefined} from '@shipfox/client-ui';
4
+ import {Button, ButtonLink} from '@shipfox/react-ui/button';
5
+ import {Callout} from '@shipfox/react-ui/callout';
6
+ import {Card} from '@shipfox/react-ui/card';
7
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
8
+ import {toast} from '@shipfox/react-ui/toast';
9
+ import {Header, Text} from '@shipfox/react-ui/typography';
10
+ import {useQueryClient} from '@tanstack/react-query';
11
+ import {Link, useNavigate} from '@tanstack/react-router';
12
+ import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
13
+ import {
14
+ useCompleteIntegrationCallback,
15
+ useCompleteIntegrationCallbackResult,
16
+ } from '#application/complete-integration-callback.js';
17
+ import {CallbackStatusShell} from '#components/callback-status-shell.js';
18
+ import type {IntegrationConnection, JiraSite} from '#core/models.js';
19
+ import {
20
+ useCompleteJiraCallbackMutation,
21
+ useCompleteJiraSiteSelectionMutation,
22
+ } from '#hooks/api/integrations.js';
23
+ import {
24
+ clearJiraInstallWorkspace,
25
+ parseJiraCallbackQuery,
26
+ readJiraInstallWorkspace,
27
+ serializeJiraCallbackQuery,
28
+ } from '#jira-callback.js';
29
+ import {
30
+ JIRA_CALLBACK_CACHE_SIZE,
31
+ jiraCallbackRequests,
32
+ jiraCompletedConnections,
33
+ jiraSiteSelectionRequests,
34
+ jiraToastedCallbacks,
35
+ } from '#jira-callback-state.js';
36
+ import {classifyJiraCallbackError, type JiraCallbackFailure} from '#jira-form-errors.js';
37
+ import {rememberCallbackKey, resolveWorkspaceSlug} from '#workspace-navigation.js';
38
+
39
+ export function JiraCallbackPage() {
40
+ const navigate = useNavigate();
41
+ const queryClient = useQueryClient();
42
+ const refreshAuth = useRefreshAuth();
43
+ const {workspaces, isLoading} = useAuthState();
44
+ const completeIntegrationCallback = useCompleteIntegrationCallback();
45
+ const completeIntegrationCallbackResult = useCompleteIntegrationCallbackResult();
46
+ const {mutateAsync: completeJiraCallback} = useCompleteJiraCallbackMutation();
47
+ const {mutateAsync: completeJiraSiteSelection} = useCompleteJiraSiteSelectionMutation();
48
+ const params = useRouteSearch(parseJiraCallbackQuery);
49
+ const callbackKey = params ? serializeJiraCallbackQuery(params) : undefined;
50
+ const workspaceId = useMemo(() => readJiraInstallWorkspace(sessionStorageOrUndefined()), []);
51
+ const [sites, setSites] = useState<JiraSite[] | undefined>();
52
+ const [failure, setFailure] = useState<JiraCallbackFailure | undefined>();
53
+ const [selectedCloudId, setSelectedCloudId] = useState<string | undefined>();
54
+ const [completedWorkspace, setCompletedWorkspace] = useState<{
55
+ slug?: string | undefined;
56
+ }>();
57
+ const disposedRef = useRef(false);
58
+ const callbackKeyRef = useRef(callbackKey);
59
+ callbackKeyRef.current = callbackKey;
60
+ const [callbackStateKey, setCallbackStateKey] = useState(callbackKey);
61
+
62
+ useEffect(() => {
63
+ disposedRef.current = false;
64
+ return () => {
65
+ disposedRef.current = true;
66
+ };
67
+ }, []);
68
+
69
+ const completeConnection = useCallback(
70
+ async (
71
+ connection: IntegrationConnection,
72
+ callbackKey: string,
73
+ isActive: () => boolean = () => true,
74
+ ) => {
75
+ if (disposedRef.current || !isActive()) return;
76
+ const completedConnection = jiraCompletedConnections.get(callbackKey);
77
+ if (completedConnection) {
78
+ const workspaceSlug = await resolveWorkspaceSlug({
79
+ workspaceId: completedConnection.workspaceId,
80
+ fallbackWorkspaces: workspaces,
81
+ queryClient,
82
+ });
83
+ if (!disposedRef.current && isActive())
84
+ setCompletedWorkspace(workspaceSlug ? {slug: workspaceSlug} : {});
85
+ return;
86
+ }
87
+ rememberCompletedConnection(callbackKey, connection);
88
+ try {
89
+ clearJiraInstallWorkspace(sessionStorageOrUndefined());
90
+ } catch {
91
+ // The successful API response remains the source of truth for navigation.
92
+ }
93
+ if (disposedRef.current || !isActive()) return;
94
+ if (!jiraToastedCallbacks.has(callbackKey)) {
95
+ rememberCallbackKey(jiraToastedCallbacks, callbackKey);
96
+ toast.success('Jira installed.');
97
+ }
98
+ let workspaceSlug: string | undefined;
99
+ try {
100
+ workspaceSlug = await resolveWorkspaceSlug({
101
+ workspaceId: connection.workspaceId,
102
+ fallbackWorkspaces: workspaces,
103
+ queryClient,
104
+ });
105
+ if (disposedRef.current || !isActive()) return;
106
+ if (!workspaceSlug) {
107
+ setCompletedWorkspace({});
108
+ return;
109
+ }
110
+ setCompletedWorkspace({slug: workspaceSlug});
111
+ await navigate({
112
+ to: '/w/$workspaceSlug/settings/integrations',
113
+ params: {workspaceSlug},
114
+ replace: true,
115
+ });
116
+ } catch {
117
+ if (!disposedRef.current && isActive()) setCompletedWorkspace({slug: workspaceSlug});
118
+ }
119
+ },
120
+ [navigate, queryClient, workspaces],
121
+ );
122
+
123
+ useEffect(() => {
124
+ if (callbackKey === undefined) return;
125
+ setCallbackStateKey(callbackKey);
126
+ setSites(undefined);
127
+ setSelectedCloudId(undefined);
128
+ setFailure(undefined);
129
+ setCompletedWorkspace(undefined);
130
+ }, [callbackKey]);
131
+
132
+ useEffect(() => {
133
+ if (!params || !callbackKey || isLoading) return;
134
+
135
+ let active = true;
136
+
137
+ const completedConnection = jiraCompletedConnections.get(callbackKey);
138
+ if (completedConnection) {
139
+ void completeConnection(completedConnection, callbackKey, () => active);
140
+ return () => {
141
+ active = false;
142
+ };
143
+ }
144
+
145
+ const request = jiraCallbackRequests.run(callbackKey, async () => {
146
+ return await completeIntegrationCallbackResult({
147
+ input: params,
148
+ refreshAuth,
149
+ complete: async (query, token) => await completeJiraCallback({query, token}),
150
+ getConnection: (result) => ('sites' in result ? undefined : result),
151
+ });
152
+ });
153
+
154
+ request.then(
155
+ async (result) => {
156
+ if (disposedRef.current || !active) return;
157
+ if ('sites' in result) {
158
+ setFailure(undefined);
159
+ setSites(result.sites);
160
+ return;
161
+ }
162
+ await completeConnection(result, callbackKey, () => active);
163
+ },
164
+ (error: unknown) => {
165
+ if (!disposedRef.current && active) setFailure(classifyJiraCallbackError(error));
166
+ },
167
+ );
168
+
169
+ return () => {
170
+ active = false;
171
+ };
172
+ }, [
173
+ callbackKey,
174
+ completeIntegrationCallbackResult,
175
+ completeJiraCallback,
176
+ completeConnection,
177
+ isLoading,
178
+ params,
179
+ refreshAuth,
180
+ ]);
181
+
182
+ function selectSite(site: JiraSite) {
183
+ if (!params || !callbackKey || selectedCloudId) return;
184
+ setSelectedCloudId(site.cloudId);
185
+ setFailure(undefined);
186
+ const selectionKey = callbackKey;
187
+ const request = jiraSiteSelectionRequests.run(params.state, async () => {
188
+ return await completeIntegrationCallback({
189
+ input: {cloud_id: site.cloudId, state: params.state},
190
+ refreshAuth,
191
+ complete: async (body, token) => await completeJiraSiteSelection({body, token}),
192
+ });
193
+ });
194
+ request.then(
195
+ async (connection) => {
196
+ if (disposedRef.current || callbackKeyRef.current !== selectionKey) return;
197
+ jiraCallbackRequests.clear(selectionKey);
198
+ await completeConnection(
199
+ connection,
200
+ selectionKey,
201
+ () => callbackKeyRef.current === selectionKey,
202
+ );
203
+ },
204
+ (error: unknown) => {
205
+ if (disposedRef.current || callbackKeyRef.current !== selectionKey) return;
206
+ setSelectedCloudId(undefined);
207
+ setFailure(classifyJiraCallbackError(error));
208
+ },
209
+ );
210
+ }
211
+
212
+ if (!params) {
213
+ return (
214
+ <JiraCallbackFailurePage
215
+ failure={classifyJiraCallbackError(new Error('invalid callback'))}
216
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
217
+ />
218
+ );
219
+ }
220
+
221
+ if (isLoading) return <FullPageLoader aria-label="Connecting Jira" />;
222
+
223
+ if (callbackStateKey === callbackKey && completedWorkspace)
224
+ return (
225
+ <CallbackStatusShell
226
+ title="Jira connected"
227
+ status="success"
228
+ message={
229
+ completedWorkspace.slug
230
+ ? 'Jira is connected. Continue in integrations settings.'
231
+ : 'Jira is connected. Return to Shipfox to continue.'
232
+ }
233
+ workspaceSlug={completedWorkspace.slug}
234
+ installPath="/w/$workspaceSlug/integrations/jira"
235
+ />
236
+ );
237
+
238
+ if (callbackStateKey === callbackKey && sites)
239
+ return (
240
+ <JiraSiteSelectionPage
241
+ sites={sites}
242
+ selectedCloudId={selectedCloudId}
243
+ failure={failure}
244
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
245
+ onSelect={selectSite}
246
+ />
247
+ );
248
+
249
+ if (callbackStateKey === callbackKey && failure)
250
+ return (
251
+ <JiraCallbackFailurePage
252
+ failure={failure}
253
+ workspaceSlug={workspaces.find(({id}) => id === workspaceId)?.slug}
254
+ />
255
+ );
256
+
257
+ return <FullPageLoader aria-label="Connecting Jira" />;
258
+ }
259
+
260
+ function rememberCompletedConnection(key: string, connection: IntegrationConnection): void {
261
+ jiraCompletedConnections.delete(key);
262
+ jiraCompletedConnections.set(key, connection);
263
+ while (jiraCompletedConnections.size > JIRA_CALLBACK_CACHE_SIZE) {
264
+ const oldestKey = jiraCompletedConnections.keys().next().value;
265
+ if (oldestKey === undefined) return;
266
+ jiraCompletedConnections.delete(oldestKey);
267
+ }
268
+ }
269
+
270
+ function JiraCallbackFailurePage({
271
+ failure,
272
+ workspaceSlug,
273
+ }: {
274
+ failure: JiraCallbackFailure;
275
+ workspaceSlug: string | undefined;
276
+ }) {
277
+ return (
278
+ <CallbackStatusShell
279
+ title={failure.title}
280
+ message={failure.message}
281
+ startOver={failure.startOver}
282
+ switchAccount={failure.signIn}
283
+ workspaceSlug={workspaceSlug}
284
+ installPath="/w/$workspaceSlug/integrations/jira"
285
+ />
286
+ );
287
+ }
288
+
289
+ function JiraSiteSelectionPage({
290
+ sites,
291
+ selectedCloudId,
292
+ failure,
293
+ workspaceSlug,
294
+ onSelect,
295
+ }: {
296
+ sites: JiraSite[];
297
+ selectedCloudId: string | undefined;
298
+ failure: JiraCallbackFailure | undefined;
299
+ workspaceSlug: string | undefined;
300
+ onSelect: (site: JiraSite) => void;
301
+ }) {
302
+ return (
303
+ <main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
304
+ <div className="mx-auto flex w-full max-w-[480px] flex-col justify-center gap-20">
305
+ <header className="flex flex-col gap-8">
306
+ <Header variant="h2">Choose a Jira site</Header>
307
+ <Text size="sm" className="text-foreground-neutral-muted">
308
+ Select the Jira site to connect to this Shipfox workspace.
309
+ </Text>
310
+ </header>
311
+
312
+ {failure ? (
313
+ <Callout role="alert" type="error">
314
+ <Text size="sm">{failure.message}</Text>
315
+ </Callout>
316
+ ) : null}
317
+
318
+ <section className="flex flex-col gap-8" aria-label="Choose a Jira site">
319
+ {sites.map((site) => (
320
+ <Card key={site.cloudId} className="p-16">
321
+ <div className="flex items-center justify-between gap-12">
322
+ <div className="min-w-0">
323
+ <Text size="md" bold className="truncate">
324
+ {site.name}
325
+ </Text>
326
+ <Text size="sm" className="truncate text-foreground-neutral-muted">
327
+ {site.url}
328
+ </Text>
329
+ </div>
330
+ <Button
331
+ variant="secondary"
332
+ disabled={selectedCloudId !== undefined}
333
+ isLoading={selectedCloudId === site.cloudId}
334
+ onClick={() => onSelect(site)}
335
+ >
336
+ Connect
337
+ </Button>
338
+ </div>
339
+ </Card>
340
+ ))}
341
+ </section>
342
+
343
+ <div className="flex flex-col gap-8 sm:flex-row sm:items-center">
344
+ {failure?.startOver && workspaceSlug ? (
345
+ <ButtonLink asChild variant="muted" className="min-h-44 w-full sm:w-fit">
346
+ <Link to="/w/$workspaceSlug/integrations/jira" params={{workspaceSlug}}>
347
+ Start over
348
+ </Link>
349
+ </ButtonLink>
350
+ ) : null}
351
+ {workspaceSlug ? (
352
+ <ButtonLink asChild variant="muted" className="min-h-44 w-full sm:w-fit">
353
+ <Link to="/w/$workspaceSlug/settings/integrations" params={{workspaceSlug}}>
354
+ Back to integrations
355
+ </Link>
356
+ </ButtonLink>
357
+ ) : (
358
+ <ButtonLink asChild variant="muted" className="min-h-44 w-full sm:w-fit">
359
+ <Link to="/">Back to Shipfox</Link>
360
+ </ButtonLink>
361
+ )}
362
+ </div>
363
+ </div>
364
+ </main>
365
+ );
366
+ }
@@ -0,0 +1,28 @@
1
+ import {sessionStorageOrUndefined} from '@shipfox/client-ui';
2
+ import {useCallback} from 'react';
3
+ import {RedirectInstallPage} from '#components/redirect-install-page.js';
4
+ import {useCreateJiraInstallMutation} from '#hooks/api/integrations.js';
5
+ import {saveJiraInstallWorkspace} from '#jira-callback.js';
6
+
7
+ export function JiraInstallPage() {
8
+ const createInstall = useCreateJiraInstallMutation();
9
+ const installRequest = useCallback(
10
+ async (body: {workspace_id: string}) => await createInstall.mutateAsync(body),
11
+ [createInstall],
12
+ );
13
+
14
+ return (
15
+ <RedirectInstallPage
16
+ installRequest={installRequest}
17
+ errorFallbackMessage="Could not start Jira install."
18
+ loadingLabel="Connecting Jira"
19
+ beforeRedirect={(workspaceId) => {
20
+ try {
21
+ saveJiraInstallWorkspace(sessionStorageOrUndefined(), workspaceId);
22
+ } catch {
23
+ // Storage is only a navigation hint; the signed state is authoritative.
24
+ }
25
+ }}
26
+ />
27
+ );
28
+ }
@@ -15,6 +15,11 @@ describe('PROVIDER_CATALOG', () => {
15
15
  iconName: 'linear',
16
16
  setupPath: '/w/$workspaceSlug/integrations/linear',
17
17
  });
18
+ expect(PROVIDER_CATALOG.jira).toMatchObject({
19
+ kind: 'redirect-install',
20
+ iconName: 'jira',
21
+ setupPath: '/w/$workspaceSlug/integrations/jira',
22
+ });
18
23
  expect(PROVIDER_CATALOG.gitea).toMatchObject({
19
24
  kind: 'direct-connect',
20
25
  setupPath: '/w/$workspaceSlug/integrations/gitea',
@@ -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
  });
@@ -8,7 +8,8 @@ export type ProviderSetupPath =
8
8
  | '/w/$workspaceSlug/integrations/gitea'
9
9
  | '/w/$workspaceSlug/integrations/sentry'
10
10
  | '/w/$workspaceSlug/integrations/linear'
11
- | '/w/$workspaceSlug/integrations/slack';
11
+ | '/w/$workspaceSlug/integrations/slack'
12
+ | '/w/$workspaceSlug/integrations/jira';
12
13
 
13
14
  interface RouteProviderCatalogEntry {
14
15
  kind: 'redirect-install' | 'direct-connect';
@@ -44,6 +45,11 @@ export const PROVIDER_CATALOG: Record<string, ProviderCatalogEntry> = {
44
45
  iconName: PROVIDER_ICONS.slack,
45
46
  setupPath: '/w/$workspaceSlug/integrations/slack',
46
47
  },
48
+ jira: {
49
+ kind: 'redirect-install',
50
+ iconName: PROVIDER_ICONS.jira,
51
+ setupPath: '/w/$workspaceSlug/integrations/jira',
52
+ },
47
53
  gitea: {
48
54
  kind: 'direct-connect',
49
55
  iconName: PROVIDER_ICONS.gitea,
@@ -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});