@shipfox/client-integrations 5.0.0 → 6.0.1

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 (187) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/dist/application/complete-integration-callback.d.ts +13 -0
  4. package/dist/application/complete-integration-callback.d.ts.map +1 -0
  5. package/dist/application/complete-integration-callback.js +26 -0
  6. package/dist/application/complete-integration-callback.js.map +1 -0
  7. package/dist/components/connection-picker.d.ts +2 -2
  8. package/dist/components/connection-picker.d.ts.map +1 -1
  9. package/dist/components/connection-picker.js +4 -4
  10. package/dist/components/connection-picker.js.map +1 -1
  11. package/dist/components/installed-integrations-section.d.ts +8 -6
  12. package/dist/components/installed-integrations-section.d.ts.map +1 -1
  13. package/dist/components/installed-integrations-section.js +22 -15
  14. package/dist/components/installed-integrations-section.js.map +1 -1
  15. package/dist/components/integration-gallery-for-workspace.d.ts +2 -2
  16. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
  17. package/dist/components/integration-gallery-for-workspace.js +14 -7
  18. package/dist/components/integration-gallery-for-workspace.js.map +1 -1
  19. package/dist/components/integration-gallery.d.ts +2 -2
  20. package/dist/components/integration-gallery.d.ts.map +1 -1
  21. package/dist/components/integration-gallery.js.map +1 -1
  22. package/dist/components/integration-usage-events.d.ts +1 -3
  23. package/dist/components/integration-usage-events.d.ts.map +1 -1
  24. package/dist/components/integration-usage-events.js +1 -125
  25. package/dist/components/integration-usage-events.js.map +1 -1
  26. package/dist/components/integration-usage-modal.d.ts +2 -2
  27. package/dist/components/integration-usage-modal.d.ts.map +1 -1
  28. package/dist/components/integration-usage-modal.js +2 -2
  29. package/dist/components/integration-usage-modal.js.map +1 -1
  30. package/dist/components/provider-grid.d.ts +7 -4
  31. package/dist/components/provider-grid.d.ts.map +1 -1
  32. package/dist/components/provider-grid.js +15 -10
  33. package/dist/components/provider-grid.js.map +1 -1
  34. package/dist/components/redirect-install-page.d.ts +2 -3
  35. package/dist/components/redirect-install-page.d.ts.map +1 -1
  36. package/dist/components/redirect-install-page.js +1 -1
  37. package/dist/components/redirect-install-page.js.map +1 -1
  38. package/dist/components/repository-picker.d.ts +2 -2
  39. package/dist/components/repository-picker.d.ts.map +1 -1
  40. package/dist/components/repository-picker.js +4 -4
  41. package/dist/components/repository-picker.js.map +1 -1
  42. package/dist/components/webhook/webhook-create-modal.d.ts +2 -2
  43. package/dist/components/webhook/webhook-create-modal.d.ts.map +1 -1
  44. package/dist/components/webhook/webhook-create-modal.js +8 -8
  45. package/dist/components/webhook/webhook-create-modal.js.map +1 -1
  46. package/dist/components/webhook/webhook-modals.stories.d.ts.map +1 -1
  47. package/dist/components/webhook/webhook-modals.stories.js +7 -7
  48. package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
  49. package/dist/components/webhook/webhook-usage-details.d.ts.map +1 -1
  50. package/dist/components/webhook/webhook-usage-details.js +2 -2
  51. package/dist/components/webhook/webhook-usage-details.js.map +1 -1
  52. package/dist/connection-status-badge.d.ts +2 -2
  53. package/dist/connection-status-badge.d.ts.map +1 -1
  54. package/dist/connection-status-badge.js +11 -15
  55. package/dist/connection-status-badge.js.map +1 -1
  56. package/dist/core/models.d.ts +66 -0
  57. package/dist/core/models.d.ts.map +1 -0
  58. package/dist/core/models.js +156 -0
  59. package/dist/core/models.js.map +1 -0
  60. package/dist/feature.d.ts +7 -0
  61. package/dist/feature.d.ts.map +1 -1
  62. package/dist/feature.js +10 -9
  63. package/dist/feature.js.map +1 -1
  64. package/dist/hooks/api/integration-mapper.d.ts +11 -0
  65. package/dist/hooks/api/integration-mapper.d.ts.map +1 -0
  66. package/dist/hooks/api/integration-mapper.js +54 -0
  67. package/dist/hooks/api/integration-mapper.js.map +1 -0
  68. package/dist/hooks/api/integrations.d.ts +45 -240
  69. package/dist/hooks/api/integrations.d.ts.map +1 -1
  70. package/dist/hooks/api/integrations.js +78 -31
  71. package/dist/hooks/api/integrations.js.map +1 -1
  72. package/dist/hooks/api/webhook-connections.d.ts +12 -64
  73. package/dist/hooks/api/webhook-connections.d.ts.map +1 -1
  74. package/dist/hooks/api/webhook-connections.js +17 -9
  75. package/dist/hooks/api/webhook-connections.js.map +1 -1
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +2 -1
  79. package/dist/index.js.map +1 -1
  80. package/dist/linear-callback.d.ts +2 -1
  81. package/dist/linear-callback.d.ts.map +1 -1
  82. package/dist/linear-callback.js +14 -15
  83. package/dist/linear-callback.js.map +1 -1
  84. package/dist/pages/gitea-install-page.d.ts.map +1 -1
  85. package/dist/pages/gitea-install-page.js +1 -10
  86. package/dist/pages/gitea-install-page.js.map +1 -1
  87. package/dist/pages/linear-callback-page.d.ts.map +1 -1
  88. package/dist/pages/linear-callback-page.js +19 -32
  89. package/dist/pages/linear-callback-page.js.map +1 -1
  90. package/dist/pages/linear-install-page.d.ts.map +1 -1
  91. package/dist/pages/linear-install-page.js +2 -1
  92. package/dist/pages/linear-install-page.js.map +1 -1
  93. package/dist/pages/sentry-callback-page.d.ts.map +1 -1
  94. package/dist/pages/sentry-callback-page.js +22 -25
  95. package/dist/pages/sentry-callback-page.js.map +1 -1
  96. package/dist/pages/sentry-install-page.d.ts.map +1 -1
  97. package/dist/pages/sentry-install-page.js +2 -1
  98. package/dist/pages/sentry-install-page.js.map +1 -1
  99. package/dist/pages/slack-callback-page.d.ts.map +1 -1
  100. package/dist/pages/slack-callback-page.js +21 -28
  101. package/dist/pages/slack-callback-page.js.map +1 -1
  102. package/dist/pages/slack-install-page.d.ts.map +1 -1
  103. package/dist/pages/slack-install-page.js +2 -1
  104. package/dist/pages/slack-install-page.js.map +1 -1
  105. package/dist/pages/source-control-onboarding-page.d.ts.map +1 -1
  106. package/dist/pages/source-control-onboarding-page.js +5 -1
  107. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  108. package/dist/provider-catalog.d.ts.map +1 -1
  109. package/dist/provider-catalog.js +7 -6
  110. package/dist/provider-catalog.js.map +1 -1
  111. package/dist/routes/github-callback.d.ts +16 -0
  112. package/dist/routes/github-callback.d.ts.map +1 -1
  113. package/dist/routes/github-callback.js +43 -21
  114. package/dist/routes/github-callback.js.map +1 -1
  115. package/dist/routes/linear-callback.d.ts +2 -0
  116. package/dist/routes/linear-callback.d.ts.map +1 -1
  117. package/dist/routes/linear-callback.js +2 -0
  118. package/dist/routes/linear-callback.js.map +1 -1
  119. package/dist/routes/sentry-callback.d.ts +2 -0
  120. package/dist/routes/sentry-callback.d.ts.map +1 -1
  121. package/dist/routes/sentry-callback.js +2 -0
  122. package/dist/routes/sentry-callback.js.map +1 -1
  123. package/dist/routes/slack-callback.d.ts +2 -0
  124. package/dist/routes/slack-callback.d.ts.map +1 -1
  125. package/dist/routes/slack-callback.js +2 -0
  126. package/dist/routes/slack-callback.js.map +1 -1
  127. package/dist/sentry-callback.d.ts +2 -1
  128. package/dist/sentry-callback.d.ts.map +1 -1
  129. package/dist/sentry-callback.js +16 -19
  130. package/dist/sentry-callback.js.map +1 -1
  131. package/dist/slack-callback.d.ts +2 -1
  132. package/dist/slack-callback.d.ts.map +1 -1
  133. package/dist/slack-callback.js +14 -15
  134. package/dist/slack-callback.js.map +1 -1
  135. package/dist/tsconfig.test.tsbuildinfo +1 -1
  136. package/package.json +17 -17
  137. package/src/application/complete-integration-callback.test.ts +41 -0
  138. package/src/application/complete-integration-callback.ts +39 -0
  139. package/src/components/connection-picker.tsx +7 -7
  140. package/src/components/installed-integrations-section.tsx +29 -24
  141. package/src/components/integration-gallery-for-workspace.tsx +23 -16
  142. package/src/components/integration-gallery.tsx +3 -3
  143. package/src/components/integration-usage-events.ts +1 -116
  144. package/src/components/integration-usage-modal.tsx +4 -4
  145. package/src/components/provider-grid.tsx +30 -18
  146. package/src/components/redirect-install-page.test.tsx +2 -6
  147. package/src/components/redirect-install-page.tsx +3 -2
  148. package/src/components/repository-picker.tsx +6 -6
  149. package/src/components/webhook/webhook-create-modal.tsx +12 -11
  150. package/src/components/webhook/webhook-modals.stories.tsx +10 -10
  151. package/src/components/webhook/webhook-usage-details.tsx +2 -4
  152. package/src/connection-status-badge.tsx +8 -14
  153. package/src/core/models.ts +200 -0
  154. package/src/feature.ts +12 -10
  155. package/src/hooks/api/integration-mapper.ts +64 -0
  156. package/src/hooks/api/integrations.test.ts +44 -22
  157. package/src/hooks/api/integrations.ts +229 -84
  158. package/src/hooks/api/webhook-connections.ts +58 -17
  159. package/src/index.ts +2 -1
  160. package/src/linear-callback.ts +21 -16
  161. package/src/pages/gitea-install-page.tsx +1 -10
  162. package/src/pages/linear-callback-page.tsx +18 -30
  163. package/src/pages/linear-install-page.tsx +2 -1
  164. package/src/pages/sentry-callback-page.test.tsx +5 -1
  165. package/src/pages/sentry-callback-page.tsx +28 -25
  166. package/src/pages/sentry-install-page.tsx +2 -1
  167. package/src/pages/slack-callback-page.tsx +20 -26
  168. package/src/pages/slack-install-page.tsx +2 -1
  169. package/src/pages/source-control-onboarding-page.tsx +5 -1
  170. package/src/provider-catalog.ts +7 -6
  171. package/src/routes/github-callback.test.ts +35 -1
  172. package/src/routes/github-callback.tsx +47 -21
  173. package/src/routes/linear-callback.tsx +5 -1
  174. package/src/routes/sentry-callback.tsx +5 -1
  175. package/src/routes/slack-callback.tsx +6 -1
  176. package/src/sentry-callback.test.ts +2 -1
  177. package/src/sentry-callback.ts +28 -20
  178. package/src/slack-callback.ts +21 -16
  179. package/tsconfig.build.tsbuildinfo +1 -1
  180. package/vercel.json +5 -0
  181. package/dist/integration-icon.d.ts +0 -17
  182. package/dist/integration-icon.d.ts.map +0 -1
  183. package/dist/integration-icon.js +0 -22
  184. package/dist/integration-icon.js.map +0 -1
  185. package/src/integration-icon.react.test.tsx +0 -40
  186. package/src/integration-icon.test.ts +0 -19
  187. package/src/integration-icon.tsx +0 -26
@@ -1,11 +1,22 @@
1
1
  import type {
2
2
  CreateWebhookConnectionBodyDto,
3
- ListWebhookConnectionsResponseDto,
4
3
  UpdateWebhookConnectionBodyDto,
5
- WebhookConnectionDto,
6
4
  } from '@shipfox/api-integration-webhook-dto';
7
- import {apiRequest} from '@shipfox/client-api';
8
- import {type QueryClient, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
5
+ import {
6
+ listWebhookConnectionsResponseSchema,
7
+ webhookConnectionDtoSchema,
8
+ } from '@shipfox/api-integration-webhook-dto';
9
+ import {checkedApiRequest, emptyResponseSchema} from '@shipfox/client-api';
10
+ import {
11
+ type FetchQueryOptions,
12
+ type QueryClient,
13
+ queryOptions,
14
+ useMutation,
15
+ useQuery,
16
+ useQueryClient,
17
+ } from '@tanstack/react-query';
18
+ import type {WebhookConnection} from '#core/models.js';
19
+ import {toWebhookConnection} from './integration-mapper.js';
9
20
  import {integrationsQueryKeys} from './integrations.js';
10
21
 
11
22
  export const webhookConnectionsQueryKeys = {
@@ -13,6 +24,17 @@ export const webhookConnectionsQueryKeys = {
13
24
  list: (workspaceId: string) => [...webhookConnectionsQueryKeys.all, 'list', workspaceId] as const,
14
25
  };
15
26
 
27
+ type WebhookConnectionsListQueryKey =
28
+ | ReturnType<typeof webhookConnectionsQueryKeys.list>
29
+ | readonly ['webhook-connections', 'list'];
30
+
31
+ type WebhookConnectionsListQueryOptions = FetchQueryOptions<
32
+ WebhookConnection[],
33
+ Error,
34
+ WebhookConnection[],
35
+ WebhookConnectionsListQueryKey
36
+ >;
37
+
16
38
  export async function listWebhookConnections({
17
39
  workspaceId,
18
40
  signal,
@@ -21,17 +43,24 @@ export async function listWebhookConnections({
21
43
  signal?: AbortSignal;
22
44
  }) {
23
45
  const search = new URLSearchParams({workspace_id: workspaceId});
24
- return await apiRequest<ListWebhookConnectionsResponseDto>(
46
+ const response = await checkedApiRequest(
47
+ listWebhookConnectionsResponseSchema,
25
48
  `/integrations/webhook/connections?${search.toString()}`,
26
49
  {signal},
27
50
  );
51
+ return response.connections.map(toWebhookConnection);
28
52
  }
29
53
 
30
54
  export async function createWebhookConnection(body: CreateWebhookConnectionBodyDto) {
31
- return await apiRequest<WebhookConnectionDto>('/integrations/webhook/connections', {
32
- method: 'POST',
33
- body,
34
- });
55
+ const response = await checkedApiRequest(
56
+ webhookConnectionDtoSchema,
57
+ '/integrations/webhook/connections',
58
+ {
59
+ method: 'POST',
60
+ body,
61
+ },
62
+ );
63
+ return toWebhookConnection(response);
35
64
  }
36
65
 
37
66
  export async function updateWebhookConnection({
@@ -41,23 +70,35 @@ export async function updateWebhookConnection({
41
70
  connectionId: string;
42
71
  body: UpdateWebhookConnectionBodyDto;
43
72
  }) {
44
- return await apiRequest<WebhookConnectionDto>(
73
+ const response = await checkedApiRequest(
74
+ webhookConnectionDtoSchema,
45
75
  `/integrations/webhook/connections/${encodeURIComponent(connectionId)}`,
46
76
  {method: 'PATCH', body},
47
77
  );
78
+ return toWebhookConnection(response);
48
79
  }
49
80
 
50
81
  export async function deleteWebhookConnection({connectionId}: {connectionId: string}) {
51
- await apiRequest<void>(`/integrations/webhook/connections/${encodeURIComponent(connectionId)}`, {
52
- method: 'DELETE',
53
- });
82
+ await checkedApiRequest(
83
+ emptyResponseSchema,
84
+ `/integrations/webhook/connections/${encodeURIComponent(connectionId)}`,
85
+ {
86
+ method: 'DELETE',
87
+ },
88
+ );
54
89
  }
55
90
 
56
91
  export function useWebhookConnectionsQuery(workspaceId: string | undefined) {
57
- return useQuery({
92
+ return useQuery(webhookConnectionsQueryOptions(workspaceId));
93
+ }
94
+
95
+ export function webhookConnectionsQueryOptions(
96
+ workspaceId: string | undefined,
97
+ ): WebhookConnectionsListQueryOptions {
98
+ return queryOptions({
58
99
  queryKey: workspaceId
59
100
  ? webhookConnectionsQueryKeys.list(workspaceId)
60
- : [...webhookConnectionsQueryKeys.all, 'list'],
101
+ : ([...webhookConnectionsQueryKeys.all, 'list'] as const),
61
102
  enabled: Boolean(workspaceId),
62
103
  queryFn: ({signal}) => listWebhookConnections({workspaceId: workspaceId ?? '', signal}),
63
104
  });
@@ -68,7 +109,7 @@ export function useCreateWebhookConnectionMutation() {
68
109
  return useMutation({
69
110
  mutationFn: createWebhookConnection,
70
111
  onSuccess: async (connection) => {
71
- await invalidateWebhookConnectionViews(queryClient, connection.workspace_id);
112
+ await invalidateWebhookConnectionViews(queryClient, connection.workspaceId);
72
113
  },
73
114
  });
74
115
  }
@@ -85,7 +126,7 @@ export function useUpdateWebhookConnectionMutation() {
85
126
  body: UpdateWebhookConnectionBodyDto;
86
127
  }) => updateWebhookConnection({connectionId, body}),
87
128
  onSuccess: async (connection) => {
88
- await invalidateWebhookConnectionViews(queryClient, connection.workspace_id);
129
+ await invalidateWebhookConnectionViews(queryClient, connection.workspaceId);
89
130
  },
90
131
  });
91
132
  }
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
+ export * from '@shipfox/integration-icons';
2
+ export * from '#core/models.js';
1
3
  export * from './components/connection-picker.js';
2
4
  export * from './components/integration-gallery.js';
3
5
  export * from './components/redirect-install-page.js';
4
6
  export * from './components/repository-picker.js';
5
7
  export * from './connection-status-badge.js';
6
8
  export * from './hooks/api/integrations.js';
7
- export * from './integration-icon.js';
8
9
  export * from './pages/gitea-install-page.js';
9
10
  export * from './pages/github-install-page.js';
10
11
  export * from './pages/linear-callback-page.js';
@@ -1,32 +1,37 @@
1
1
  import type {LinearCallbackQueryDto} from '@shipfox/api-integration-linear-dto';
2
2
  import {ApiError} from '@shipfox/client-api';
3
+ import {
4
+ type BrowserStorage,
5
+ type BrowserStorageKey,
6
+ createTypedBrowserStorage,
7
+ } from '@shipfox/client-ui';
3
8
 
4
9
  export const LINEAR_INSTALL_WORKSPACE_KEY = 'shipfox.linear-install.workspace-id';
5
10
 
6
- type WorkspaceStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
11
+ type WorkspaceStorage = BrowserStorage | undefined;
12
+
13
+ const linearInstallWorkspaceStorageKey = {
14
+ key: LINEAR_INSTALL_WORKSPACE_KEY,
15
+ lifetime: 'session',
16
+ principalScope: 'workspace',
17
+ serialize: (workspaceId: string) => workspaceId,
18
+ parse: (value: string) => value || undefined,
19
+ } satisfies BrowserStorageKey<string>;
7
20
 
8
21
  export function saveLinearInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
9
- try {
10
- storage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, workspaceId);
11
- } catch {
12
- // Storage only improves recovery navigation; callback input always comes from the URL.
13
- }
22
+ installWorkspaceStorage(storage).write(workspaceId);
14
23
  }
15
24
 
16
25
  export function readLinearInstallWorkspace(storage: WorkspaceStorage): string | undefined {
17
- try {
18
- return storage.getItem(LINEAR_INSTALL_WORKSPACE_KEY) ?? undefined;
19
- } catch {
20
- return undefined;
21
- }
26
+ return installWorkspaceStorage(storage).read();
22
27
  }
23
28
 
24
29
  export function clearLinearInstallWorkspace(storage: WorkspaceStorage): void {
25
- try {
26
- storage.removeItem(LINEAR_INSTALL_WORKSPACE_KEY);
27
- } catch {
28
- // A stale navigation hint cannot alter the callback request or its response.
29
- }
30
+ installWorkspaceStorage(storage).remove();
31
+ }
32
+
33
+ function installWorkspaceStorage(storage: WorkspaceStorage) {
34
+ return createTypedBrowserStorage(() => storage, linearInstallWorkspaceStorageKey);
30
35
  }
31
36
 
32
37
  export function parseLinearCallbackQuery(
@@ -5,16 +5,14 @@ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-fiel
5
5
  import {toast} from '@shipfox/react-ui/toast';
6
6
  import {Header, Text} from '@shipfox/react-ui/typography';
7
7
  import {useForm} from '@tanstack/react-form';
8
- import {useQueryClient} from '@tanstack/react-query';
9
8
  import {Link, useNavigate} from '@tanstack/react-router';
10
9
  import {useState} from 'react';
11
- import {integrationsQueryKeys, useCreateGiteaConnectionMutation} from '#hooks/api/integrations.js';
10
+ import {useCreateGiteaConnectionMutation} from '#hooks/api/integrations.js';
12
11
  import {giteaConnectErrorToFormError} from './gitea-form-errors.js';
13
12
 
14
13
  export function GiteaInstallPage() {
15
14
  const workspace = useActiveWorkspace();
16
15
  const connect = useCreateGiteaConnectionMutation();
17
- const queryClient = useQueryClient();
18
16
  const navigate = useNavigate();
19
17
  const [formError, setFormError] = useState<string | undefined>();
20
18
 
@@ -25,13 +23,6 @@ export function GiteaInstallPage() {
25
23
  const workspaceId = workspace.id;
26
24
  try {
27
25
  await connect.mutateAsync({workspace_id: workspaceId, org: value.org.trim()});
28
- await queryClient.invalidateQueries({
29
- queryKey: integrationsQueryKeys.sourceConnections(workspaceId),
30
- // The workspace home is the next active observer, not this page, so the
31
- // default 'active' refetch would no-op and leave the cache stale until
32
- // after the home redirects back here.
33
- refetchType: 'all',
34
- });
35
26
  toast.success('Gitea organization installed.');
36
27
  await navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
37
28
  } catch (error) {
@@ -1,13 +1,14 @@
1
- import type {LinearCallbackResponseDto} from '@shipfox/api-integration-linear-dto';
2
1
  import {useRefreshAuth} from '@shipfox/client-auth';
3
- import {createSingleFlight} from '@shipfox/client-ui';
2
+ import {useRouteSearch} from '@shipfox/client-shell/runtime';
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';
7
- import {useNavigate, useSearch} from '@tanstack/react-router';
6
+ import {useNavigate} from '@tanstack/react-router';
8
7
  import {useEffect, useMemo, useState} from 'react';
8
+ import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
9
9
  import {CallbackStatusShell} from '#components/callback-status-shell.js';
10
- import {integrationsQueryKeys, useCompleteLinearCallbackMutation} from '#hooks/api/integrations.js';
10
+ import type {IntegrationConnection} from '#core/models.js';
11
+ import {useCompleteLinearCallbackMutation} from '#hooks/api/integrations.js';
11
12
  import {
12
13
  classifyLinearCallbackError,
13
14
  clearLinearInstallWorkspace,
@@ -19,7 +20,7 @@ import {
19
20
 
20
21
  // Retain only recent completions: this bounds long-lived callback pages while
21
22
  // still covering StrictMode and immediate Back/Forward remounts.
22
- const callbackRequests = createSingleFlight<string, LinearCallbackResponseDto>({
23
+ const callbackRequests = createSingleFlight<string, IntegrationConnection>({
23
24
  maxTerminalResults: 32,
24
25
  });
25
26
  // Keeps the success toast firing once per distinct callback even though the
@@ -27,19 +28,12 @@ const callbackRequests = createSingleFlight<string, LinearCallbackResponseDto>({
27
28
  const toastedCallbacks = new Set<string>();
28
29
 
29
30
  export function LinearCallbackPage() {
30
- const search = useSearch({strict: false});
31
31
  const navigate = useNavigate();
32
32
  const refreshAuth = useRefreshAuth();
33
- const queryClient = useQueryClient();
33
+ const completeIntegrationCallback = useCompleteIntegrationCallback();
34
34
  const {mutateAsync: completeLinearCallback} = useCompleteLinearCallbackMutation();
35
- const params = useMemo(() => parseLinearCallbackQuery(search), [search]);
36
- const workspaceId = useMemo(() => {
37
- try {
38
- return readLinearInstallWorkspace(window.sessionStorage);
39
- } catch {
40
- return undefined;
41
- }
42
- }, []);
35
+ const params = useRouteSearch(parseLinearCallbackQuery);
36
+ const workspaceId = useMemo(() => readLinearInstallWorkspace(sessionStorageOrUndefined()), []);
43
37
  const [failure, setFailure] = useState<LinearCallbackFailure | undefined>();
44
38
  useEffect(() => {
45
39
  if (!params) return;
@@ -49,27 +43,21 @@ export function LinearCallbackPage() {
49
43
  const request = callbackRequests.run(
50
44
  key,
51
45
  async () =>
52
- await refreshAuth().then(
53
- async (session) =>
54
- await completeLinearCallback({query: params, token: session.accessToken}),
55
- ),
46
+ await completeIntegrationCallback({
47
+ input: params,
48
+ refreshAuth,
49
+ complete: async (query, token) => await completeLinearCallback({query, token}),
50
+ }),
56
51
  );
57
52
 
58
53
  request.then(
59
54
  async (connection) => {
60
55
  if (disposed) return;
61
56
  try {
62
- clearLinearInstallWorkspace(window.sessionStorage);
57
+ clearLinearInstallWorkspace(sessionStorageOrUndefined());
63
58
  } catch {
64
59
  // The successful API response remains the source of truth for navigation.
65
60
  }
66
- try {
67
- await queryClient.invalidateQueries({
68
- queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspace_id),
69
- });
70
- } catch {
71
- // Cache refresh is best effort: the successful callback is already committed server-side.
72
- }
73
61
  if (disposed) return;
74
62
  if (!toastedCallbacks.has(key)) {
75
63
  toastedCallbacks.add(key);
@@ -78,7 +66,7 @@ export function LinearCallbackPage() {
78
66
  try {
79
67
  await navigate({
80
68
  to: '/workspaces/$wid/settings/integrations',
81
- params: {wid: connection.workspace_id},
69
+ params: {wid: connection.workspaceId},
82
70
  replace: true,
83
71
  });
84
72
  } catch {
@@ -94,7 +82,7 @@ export function LinearCallbackPage() {
94
82
  return () => {
95
83
  disposed = true;
96
84
  };
97
- }, [completeLinearCallback, navigate, params, queryClient, refreshAuth]);
85
+ }, [completeIntegrationCallback, completeLinearCallback, navigate, params, refreshAuth]);
98
86
 
99
87
  if (!params) {
100
88
  return (
@@ -1,3 +1,4 @@
1
+ import {sessionStorageOrUndefined} from '@shipfox/client-ui';
1
2
  import {useCallback} from 'react';
2
3
  import {RedirectInstallPage} from '#components/redirect-install-page.js';
3
4
  import {useCreateLinearInstallMutation} from '#hooks/api/integrations.js';
@@ -17,7 +18,7 @@ export function LinearInstallPage() {
17
18
  loadingLabel="Connecting Linear"
18
19
  beforeRedirect={(workspaceId) => {
19
20
  try {
20
- saveLinearInstallWorkspace(window.sessionStorage, workspaceId);
21
+ saveLinearInstallWorkspace(sessionStorageOrUndefined(), workspaceId);
21
22
  } catch {
22
23
  // Storage can be disabled before the helper gets a usable Storage object.
23
24
  }
@@ -162,7 +162,11 @@ describe('SentryCallbackPage', () => {
162
162
 
163
163
  fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
164
164
 
165
- expect(await screen.findByText('Sentry rejected the code.')).toBeInTheDocument();
165
+ expect(
166
+ await screen.findByText(
167
+ 'Sentry did not accept this install. Start the install again from workspace settings.',
168
+ ),
169
+ ).toBeInTheDocument();
166
170
  expect(screen.getByRole('link', {name: 'Start over'})).toBeInTheDocument();
167
171
  expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument();
168
172
  });
@@ -1,16 +1,17 @@
1
- import type {SentryConnectResponseDto} from '@shipfox/api-integration-sentry-dto';
2
1
  import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
3
- import {createSingleFlight} from '@shipfox/client-ui';
2
+ import {useRouteSearch} from '@shipfox/client-shell/runtime';
3
+ import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
4
4
  import {Button, ButtonLink} from '@shipfox/react-ui/button';
5
5
  import {Callout} from '@shipfox/react-ui/callout';
6
6
  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';
11
- import {Link, useNavigate, useSearch} from '@tanstack/react-router';
10
+ import {Link, useNavigate} from '@tanstack/react-router';
12
11
  import {useEffect, useMemo, useRef, useState} from 'react';
13
- import {connectSentry, integrationsQueryKeys} from '#hooks/api/integrations.js';
12
+ import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
13
+ import type {IntegrationConnection} from '#core/models.js';
14
+ import {connectSentry} from '#hooks/api/integrations.js';
14
15
  import {
15
16
  classifySentryConnectError,
16
17
  clearSentryInstallWorkspace,
@@ -20,17 +21,19 @@ import {
20
21
  type SentryConnectFailure,
21
22
  } from '#sentry-callback.js';
22
23
 
23
- const connectRequests = createSingleFlight<string, SentryConnectResponseDto>();
24
+ const connectRequests = createSingleFlight<string, IntegrationConnection>();
24
25
 
25
26
  export function SentryCallbackPage() {
26
- const search = useSearch({strict: false});
27
27
  const navigate = useNavigate();
28
28
  const refreshAuth = useRefreshAuth();
29
29
  const {workspaces, isLoading} = useAuthState();
30
- const queryClient = useQueryClient();
30
+ const completeIntegrationCallback = useCompleteIntegrationCallback();
31
31
 
32
- const params = useMemo(() => parseSentryCallbackParams(search), [search]);
33
- const storedWorkspaceId = useMemo(() => readSentryInstallWorkspace(window.sessionStorage), []);
32
+ const params = useRouteSearch(parseSentryCallbackParams);
33
+ const storedWorkspaceId = useMemo(
34
+ () => readSentryInstallWorkspace(sessionStorageOrUndefined()),
35
+ [],
36
+ );
34
37
  const preselection = preselectSentryWorkspace(storedWorkspaceId, workspaces);
35
38
  const preselectedId = preselection.kind === 'pick' ? preselection.preselectedId : undefined;
36
39
 
@@ -101,24 +104,24 @@ export function SentryCallbackPage() {
101
104
  const request = connectRequests.run(
102
105
  key,
103
106
  async () =>
104
- await refreshAuth().then((session) =>
105
- connectSentry({
106
- body: {
107
- workspace_id: workspaceId,
108
- code: params.code,
109
- installation_id: params.installationId,
110
- },
111
- token: session.accessToken,
112
- }),
113
- ),
107
+ await completeIntegrationCallback({
108
+ input: workspaceId,
109
+ refreshAuth,
110
+ complete: async (selectedWorkspaceId, token) =>
111
+ await connectSentry({
112
+ body: {
113
+ workspace_id: selectedWorkspaceId,
114
+ code: params.code,
115
+ installation_id: params.installationId,
116
+ },
117
+ token,
118
+ }),
119
+ }),
114
120
  );
115
121
 
116
122
  request
117
123
  .then(async () => {
118
- clearSentryInstallWorkspace(window.sessionStorage);
119
- await queryClient.invalidateQueries({
120
- queryKey: integrationsQueryKeys.connectionsByWorkspace(workspaceId),
121
- });
124
+ clearSentryInstallWorkspace(sessionStorageOrUndefined());
122
125
  if (disposedRef.current) return;
123
126
  toast.success('Sentry installed.');
124
127
  await navigate({
@@ -133,7 +136,7 @@ export function SentryCallbackPage() {
133
136
  if (classified.kind === 'terminal') {
134
137
  // Only a fresh install (new grant code) can recover; the stored
135
138
  // handoff has served its purpose either way.
136
- clearSentryInstallWorkspace(window.sessionStorage);
139
+ clearSentryInstallWorkspace(sessionStorageOrUndefined());
137
140
  }
138
141
  setConnectingId(undefined);
139
142
  setFailure({workspaceId, failure: classified});
@@ -1,3 +1,4 @@
1
+ import {sessionStorageOrUndefined} from '@shipfox/client-ui';
1
2
  import {RedirectInstallPage} from '#components/redirect-install-page.js';
2
3
  import {createSentryInstall} from '#hooks/api/integrations.js';
3
4
  import {saveSentryInstallWorkspace} from '#sentry-callback.js';
@@ -11,7 +12,7 @@ export function SentryInstallPage() {
11
12
  // Sentry's redirect has no state param; the stored id lets the callback
12
13
  // pre-select this workspace. Saving never throws (see helper).
13
14
  beforeRedirect={(workspaceId) => {
14
- saveSentryInstallWorkspace(window.sessionStorage, workspaceId);
15
+ saveSentryInstallWorkspace(sessionStorageOrUndefined(), workspaceId);
15
16
  }}
16
17
  />
17
18
  );
@@ -1,13 +1,14 @@
1
- import type {SlackCallbackResponseDto} from '@shipfox/api-integration-slack-dto';
2
1
  import {useRefreshAuth} from '@shipfox/client-auth';
3
- import {createSingleFlight} from '@shipfox/client-ui';
2
+ import {useRouteSearch} from '@shipfox/client-shell/runtime';
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';
7
- import {useNavigate, useSearch} from '@tanstack/react-router';
6
+ import {useNavigate} from '@tanstack/react-router';
8
7
  import {useEffect, useMemo, useState} from 'react';
8
+ import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
9
9
  import {CallbackStatusShell} from '#components/callback-status-shell.js';
10
- import {integrationsQueryKeys, useCompleteSlackCallbackMutation} from '#hooks/api/integrations.js';
10
+ import type {IntegrationConnection} from '#core/models.js';
11
+ import {useCompleteSlackCallbackMutation} from '#hooks/api/integrations.js';
11
12
  import {
12
13
  classifySlackCallbackError,
13
14
  clearSlackInstallWorkspace,
@@ -19,20 +20,19 @@ import {
19
20
 
20
21
  // Retain only recent completions: this bounds long-lived callback pages while
21
22
  // still covering StrictMode and immediate Back/Forward remounts.
22
- const callbackRequests = createSingleFlight<string, SlackCallbackResponseDto>({
23
+ const callbackRequests = createSingleFlight<string, IntegrationConnection>({
23
24
  maxTerminalResults: 32,
24
25
  });
25
26
  const completedCallbacks = new Set<string>();
26
27
  const toastedCallbacks = new Set<string>();
27
28
 
28
29
  export function SlackCallbackPage() {
29
- const search = useSearch({strict: false});
30
30
  const navigate = useNavigate();
31
31
  const refreshAuth = useRefreshAuth();
32
- const queryClient = useQueryClient();
32
+ const completeIntegrationCallback = useCompleteIntegrationCallback();
33
33
  const {mutateAsync: completeSlackCallback} = useCompleteSlackCallbackMutation();
34
- const params = useMemo(() => parseSlackCallbackQuery(search), [search]);
35
- const workspaceId = useMemo(() => readSlackInstallWorkspace(window.sessionStorage), []);
34
+ const params = useRouteSearch(parseSlackCallbackQuery);
35
+ const workspaceId = useMemo(() => readSlackInstallWorkspace(sessionStorageOrUndefined()), []);
36
36
  const [failure, setFailure] = useState<SlackCallbackFailure>();
37
37
  const [completedWorkspaceId, setCompletedWorkspaceId] = useState<string>();
38
38
 
@@ -43,31 +43,25 @@ export function SlackCallbackPage() {
43
43
  const request = callbackRequests.run(
44
44
  key,
45
45
  async () =>
46
- await refreshAuth().then(
47
- async (session) =>
48
- await completeSlackCallback({query: params, token: session.accessToken}),
49
- ),
46
+ await completeIntegrationCallback({
47
+ input: params,
48
+ refreshAuth,
49
+ complete: async (query, token) => await completeSlackCallback({query, token}),
50
+ }),
50
51
  );
51
52
  request.then(
52
53
  async (connection) => {
53
54
  if (disposed) return;
54
55
  if (completedCallbacks.has(key)) {
55
- setCompletedWorkspaceId(connection.workspace_id);
56
+ setCompletedWorkspaceId(connection.workspaceId);
56
57
  return;
57
58
  }
58
59
  completedCallbacks.add(key);
59
60
  try {
60
- clearSlackInstallWorkspace(window.sessionStorage);
61
+ clearSlackInstallWorkspace(sessionStorageOrUndefined());
61
62
  } catch {
62
63
  // The successful API response remains the source of truth.
63
64
  }
64
- try {
65
- await queryClient.invalidateQueries({
66
- queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspace_id),
67
- });
68
- } catch {
69
- // Navigation can continue when cache refresh is unavailable.
70
- }
71
65
  if (disposed) return;
72
66
  if (!toastedCallbacks.has(key)) {
73
67
  toastedCallbacks.add(key);
@@ -76,11 +70,11 @@ export function SlackCallbackPage() {
76
70
  try {
77
71
  await navigate({
78
72
  to: '/workspaces/$wid/settings/integrations',
79
- params: {wid: connection.workspace_id},
73
+ params: {wid: connection.workspaceId},
80
74
  replace: true,
81
75
  });
82
76
  } catch {
83
- if (!disposed) setCompletedWorkspaceId(connection.workspace_id);
77
+ if (!disposed) setCompletedWorkspaceId(connection.workspaceId);
84
78
  }
85
79
  },
86
80
  (error: unknown) => {
@@ -90,7 +84,7 @@ export function SlackCallbackPage() {
90
84
  return () => {
91
85
  disposed = true;
92
86
  };
93
- }, [completeSlackCallback, navigate, params, queryClient, refreshAuth]);
87
+ }, [completeIntegrationCallback, completeSlackCallback, navigate, params, refreshAuth]);
94
88
 
95
89
  if (!params)
96
90
  return (
@@ -1,3 +1,4 @@
1
+ import {sessionStorageOrUndefined} from '@shipfox/client-ui';
1
2
  import {useCallback} from 'react';
2
3
  import {RedirectInstallPage} from '#components/redirect-install-page.js';
3
4
  import {useCreateSlackInstallMutation} from '#hooks/api/integrations.js';
@@ -15,7 +16,7 @@ export function SlackInstallPage() {
15
16
  errorFallbackMessage="Could not start Slack install."
16
17
  loadingLabel="Connecting Slack"
17
18
  beforeRedirect={(workspaceId) =>
18
- saveSlackInstallWorkspace(window.sessionStorage, workspaceId)
19
+ saveSlackInstallWorkspace(sessionStorageOrUndefined(), workspaceId)
19
20
  }
20
21
  />
21
22
  );
@@ -17,7 +17,11 @@ export function SourceControlOnboardingPage() {
17
17
  </header>
18
18
 
19
19
  <ProviderGrid
20
- providersQuery={providersQuery}
20
+ providers={providersQuery.data ?? []}
21
+ isPending={providersQuery.isPending}
22
+ isFetching={providersQuery.isFetching}
23
+ error={providersQuery.isError ? providersQuery.error : undefined}
24
+ onRetry={() => void providersQuery.refetch()}
21
25
  workspaceId={workspace.id}
22
26
  emptyMessage="Enable at least one source-control provider in the application settings."
23
27
  />
@@ -1,3 +1,4 @@
1
+ import {PROVIDER_ICONS} from '@shipfox/integration-icons';
1
2
  import type {IconName} from '@shipfox/react-ui/icon';
2
3
 
3
4
  // Literal union (not string) so `<Link to={catalog.setupPath}>` stays typed
@@ -25,31 +26,31 @@ export type ProviderCatalogEntry = RouteProviderCatalogEntry | ModalProviderCata
25
26
  export const PROVIDER_CATALOG: Record<string, ProviderCatalogEntry> = {
26
27
  github: {
27
28
  kind: 'redirect-install',
28
- iconName: 'github',
29
+ iconName: PROVIDER_ICONS.github,
29
30
  setupPath: '/workspaces/$wid/integrations/github',
30
31
  },
31
32
  sentry: {
32
33
  kind: 'redirect-install',
33
- iconName: 'sentry',
34
+ iconName: PROVIDER_ICONS.sentry,
34
35
  setupPath: '/workspaces/$wid/integrations/sentry',
35
36
  },
36
37
  linear: {
37
38
  kind: 'redirect-install',
38
- iconName: 'linear',
39
+ iconName: PROVIDER_ICONS.linear,
39
40
  setupPath: '/workspaces/$wid/integrations/linear',
40
41
  },
41
42
  slack: {
42
43
  kind: 'redirect-install',
43
- iconName: 'slack',
44
+ iconName: PROVIDER_ICONS.slack,
44
45
  setupPath: '/workspaces/$wid/integrations/slack',
45
46
  },
46
47
  gitea: {
47
48
  kind: 'direct-connect',
48
- iconName: 'gitea',
49
+ iconName: PROVIDER_ICONS.gitea,
49
50
  setupPath: '/workspaces/$wid/integrations/gitea',
50
51
  },
51
52
  webhook: {
52
53
  kind: 'modal-connect',
53
- iconName: 'webhookLine',
54
+ iconName: PROVIDER_ICONS.webhook,
54
55
  },
55
56
  };