@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
@@ -0,0 +1,13 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ interface WorkspaceSlugCandidate {
3
+ id: string;
4
+ slug: string;
5
+ }
6
+ export declare function rememberCallbackKey(keys: Set<string>, key: string, maxSize?: number): void;
7
+ export declare function resolveWorkspaceSlug({ workspaceId, fallbackWorkspaces, queryClient, }: {
8
+ workspaceId: string;
9
+ fallbackWorkspaces: readonly WorkspaceSlugCandidate[];
10
+ queryClient: QueryClient;
11
+ }): Promise<string | undefined>;
12
+ export {};
13
+ //# sourceMappingURL=workspace-navigation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-navigation.d.ts","sourceRoot":"","sources":["../src/workspace-navigation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,uBAAuB,CAAC;AAEvD,UAAU,sBAAsB;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACd;AAMD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,SAAK,GAAG,IAAI,CAMtF;AAED,wBAAsB,oBAAoB,CAAC,EACzC,WAAW,EACX,kBAAkB,EAClB,WAAW,GACZ,EAAE;IACD,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,SAAS,sBAAsB,EAAE,CAAC;IACtD,WAAW,EAAE,WAAW,CAAC;CAC1B,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAS9B"}
@@ -0,0 +1,17 @@
1
+ import { listUserWorkspaces, userWorkspacesQueryKey } from '@shipfox/client-shell/runtime';
2
+ export function rememberCallbackKey(keys, key, maxSize = 32) {
3
+ keys.delete(key);
4
+ keys.add(key);
5
+ if (keys.size <= maxSize) return;
6
+ const oldest = keys.values().next().value;
7
+ if (typeof oldest === 'string') keys.delete(oldest);
8
+ }
9
+ export async function resolveWorkspaceSlug({ workspaceId, fallbackWorkspaces, queryClient }) {
10
+ const currentWorkspaces = await listUserWorkspaces().then(({ memberships })=>memberships).catch(()=>{
11
+ const refreshedWorkspaces = queryClient.getQueryData(userWorkspacesQueryKey);
12
+ return refreshedWorkspaces?.memberships ?? fallbackWorkspaces;
13
+ });
14
+ return currentWorkspaces.find(({ id })=>id === workspaceId)?.slug;
15
+ }
16
+
17
+ //# sourceMappingURL=workspace-navigation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/workspace-navigation.ts"],"sourcesContent":["import {listUserWorkspaces, userWorkspacesQueryKey} from '@shipfox/client-shell/runtime';\nimport type {QueryClient} from '@tanstack/react-query';\n\ninterface WorkspaceSlugCandidate {\n id: string;\n slug: string;\n}\n\ninterface UserWorkspacesQueryData {\n memberships: WorkspaceSlugCandidate[];\n}\n\nexport function rememberCallbackKey(keys: Set<string>, key: string, maxSize = 32): void {\n keys.delete(key);\n keys.add(key);\n if (keys.size <= maxSize) return;\n const oldest = keys.values().next().value;\n if (typeof oldest === 'string') keys.delete(oldest);\n}\n\nexport async function resolveWorkspaceSlug({\n workspaceId,\n fallbackWorkspaces,\n queryClient,\n}: {\n workspaceId: string;\n fallbackWorkspaces: readonly WorkspaceSlugCandidate[];\n queryClient: QueryClient;\n}): Promise<string | undefined> {\n const currentWorkspaces = await listUserWorkspaces()\n .then(({memberships}) => memberships)\n .catch(() => {\n const refreshedWorkspaces =\n queryClient.getQueryData<UserWorkspacesQueryData>(userWorkspacesQueryKey);\n return refreshedWorkspaces?.memberships ?? fallbackWorkspaces;\n });\n return currentWorkspaces.find(({id}) => id === workspaceId)?.slug;\n}\n"],"names":["listUserWorkspaces","userWorkspacesQueryKey","rememberCallbackKey","keys","key","maxSize","delete","add","size","oldest","values","next","value","resolveWorkspaceSlug","workspaceId","fallbackWorkspaces","queryClient","currentWorkspaces","then","memberships","catch","refreshedWorkspaces","getQueryData","find","id","slug"],"mappings":"AAAA,SAAQA,kBAAkB,EAAEC,sBAAsB,QAAO,gCAAgC;AAYzF,OAAO,SAASC,oBAAoBC,IAAiB,EAAEC,GAAW,EAAEC,UAAU,EAAE;IAC9EF,KAAKG,MAAM,CAACF;IACZD,KAAKI,GAAG,CAACH;IACT,IAAID,KAAKK,IAAI,IAAIH,SAAS;IAC1B,MAAMI,SAASN,KAAKO,MAAM,GAAGC,IAAI,GAAGC,KAAK;IACzC,IAAI,OAAOH,WAAW,UAAUN,KAAKG,MAAM,CAACG;AAC9C;AAEA,OAAO,eAAeI,qBAAqB,EACzCC,WAAW,EACXC,kBAAkB,EAClBC,WAAW,EAKZ;IACC,MAAMC,oBAAoB,MAAMjB,qBAC7BkB,IAAI,CAAC,CAAC,EAACC,WAAW,EAAC,GAAKA,aACxBC,KAAK,CAAC;QACL,MAAMC,sBACJL,YAAYM,YAAY,CAA0BrB;QACpD,OAAOoB,qBAAqBF,eAAeJ;IAC7C;IACF,OAAOE,kBAAkBM,IAAI,CAAC,CAAC,EAACC,EAAE,EAAC,GAAKA,OAAOV,cAAcW;AAC/D"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-integrations",
3
3
  "license": "MIT",
4
- "version": "12.0.2",
4
+ "version": "14.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,19 +27,20 @@
27
27
  "dependencies": {
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "@tanstack/react-form": "^1.32.0",
30
- "@shipfox/api-integration-core-dto": "9.0.2",
31
- "@shipfox/api-integration-gitea-dto": "9.0.2",
32
- "@shipfox/api-integration-github-dto": "9.0.2",
33
- "@shipfox/api-integration-linear-dto": "9.0.2",
34
- "@shipfox/api-integration-slack-dto": "9.0.2",
35
- "@shipfox/api-integration-sentry-dto": "9.0.2",
36
- "@shipfox/api-integration-webhook-dto": "9.0.2",
30
+ "@shipfox/api-integration-core-dto": "12.0.0",
31
+ "@shipfox/api-integration-gitea-dto": "12.0.0",
32
+ "@shipfox/api-integration-github-dto": "12.0.0",
33
+ "@shipfox/api-integration-jira-dto": "12.0.0",
34
+ "@shipfox/api-integration-linear-dto": "12.0.0",
35
+ "@shipfox/api-integration-slack-dto": "12.0.0",
36
+ "@shipfox/api-integration-sentry-dto": "12.0.0",
37
+ "@shipfox/api-integration-webhook-dto": "12.0.0",
37
38
  "@shipfox/client-api": "6.0.1",
38
- "@shipfox/client-auth": "12.0.2",
39
- "@shipfox/client-shell": "12.0.2",
40
- "@shipfox/client-ui": "6.0.2",
41
- "@shipfox/integration-icons": "0.2.2",
42
- "@shipfox/react-ui": "0.3.7"
39
+ "@shipfox/client-auth": "14.0.0",
40
+ "@shipfox/client-shell": "14.0.0",
41
+ "@shipfox/client-ui": "14.0.0",
42
+ "@shipfox/integration-icons": "0.3.0",
43
+ "@shipfox/react-ui": "0.5.0"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "@tanstack/react-query": "^5.101.0",
@@ -1,7 +1,10 @@
1
1
  import {QueryClient} from '@tanstack/react-query';
2
2
  import type {IntegrationConnection} from '#core/models.js';
3
3
  import {integrationsQueryKeys} from '#hooks/api/integrations.js';
4
- import {completeIntegrationCallback} from './complete-integration-callback.js';
4
+ import {
5
+ completeIntegrationCallback,
6
+ completeIntegrationCallbackResult,
7
+ } from './complete-integration-callback.js';
5
8
 
6
9
  const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
7
10
 
@@ -38,4 +41,44 @@ describe('completeIntegrationCallback', () => {
38
41
  queryKey: integrationsQueryKeys.connectionsByWorkspace(WORKSPACE_ID),
39
42
  });
40
43
  });
44
+
45
+ test('does not invalidate a connection view for a result without a connection', async () => {
46
+ const queryClient = new QueryClient();
47
+ const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
48
+ const refreshAuth = vi.fn().mockResolvedValue({accessToken: 'access-token'});
49
+ const complete = vi.fn().mockResolvedValue({sites: []});
50
+
51
+ const result = await completeIntegrationCallbackResult({
52
+ input: {code: 'grant-code'},
53
+ refreshAuth,
54
+ complete,
55
+ getConnection: () => undefined,
56
+ queryClient,
57
+ });
58
+
59
+ expect(result).toEqual({sites: []});
60
+ expect(complete).toHaveBeenCalledWith({code: 'grant-code'}, 'access-token');
61
+ expect(invalidateQueries).not.toHaveBeenCalled();
62
+ });
63
+
64
+ test('invalidates a connection view for a result with a connection', async () => {
65
+ const queryClient = new QueryClient();
66
+ const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
67
+ const refreshAuth = vi.fn().mockResolvedValue({accessToken: 'access-token'});
68
+ const complete = vi.fn(async () => connection);
69
+
70
+ const result = await completeIntegrationCallbackResult({
71
+ input: {code: 'grant-code'},
72
+ refreshAuth,
73
+ complete,
74
+ getConnection: (value) => value,
75
+ queryClient,
76
+ });
77
+
78
+ expect(result).toBe(connection);
79
+ expect(complete).toHaveBeenCalledWith({code: 'grant-code'}, 'access-token');
80
+ expect(invalidateQueries).toHaveBeenCalledWith({
81
+ queryKey: integrationsQueryKeys.connectionsByWorkspace(WORKSPACE_ID),
82
+ });
83
+ });
41
84
  });
@@ -10,22 +10,65 @@ export interface CompleteIntegrationCallbackOptions<TInput> {
10
10
  queryClient: QueryClient;
11
11
  }
12
12
 
13
+ export interface CompleteIntegrationCallbackResultOptions<TInput, TResult> {
14
+ input: TInput;
15
+ refreshAuth: () => Promise<{accessToken: string}>;
16
+ complete: (input: TInput, accessToken: string) => Promise<TResult>;
17
+ getConnection: (result: TResult) => IntegrationConnection | undefined;
18
+ queryClient: QueryClient;
19
+ }
20
+
13
21
  export async function completeIntegrationCallback<TInput>({
14
22
  input,
15
23
  refreshAuth,
16
24
  complete,
17
25
  queryClient,
18
26
  }: CompleteIntegrationCallbackOptions<TInput>): Promise<IntegrationConnection> {
27
+ return await completeIntegrationCallbackResultInternal({
28
+ input,
29
+ refreshAuth,
30
+ complete,
31
+ getConnection: (result) => result,
32
+ queryClient,
33
+ });
34
+ }
35
+
36
+ export async function completeIntegrationCallbackResult<TInput, TResult>({
37
+ input,
38
+ refreshAuth,
39
+ complete,
40
+ getConnection,
41
+ queryClient,
42
+ }: CompleteIntegrationCallbackResultOptions<TInput, TResult>): Promise<TResult> {
43
+ return await completeIntegrationCallbackResultInternal({
44
+ input,
45
+ refreshAuth,
46
+ complete,
47
+ getConnection,
48
+ queryClient,
49
+ });
50
+ }
51
+
52
+ async function completeIntegrationCallbackResultInternal<TInput, TResult>({
53
+ input,
54
+ refreshAuth,
55
+ complete,
56
+ getConnection,
57
+ queryClient,
58
+ }: CompleteIntegrationCallbackResultOptions<TInput, TResult>): Promise<TResult> {
19
59
  const session = await refreshAuth();
20
- const connection = await complete(input, session.accessToken);
21
- try {
22
- await queryClient.invalidateQueries({
23
- queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspaceId),
24
- });
25
- } catch {
26
- // Cache refresh is best effort: the successful callback is already committed server-side.
60
+ const result = await complete(input, session.accessToken);
61
+ const connection = getConnection(result);
62
+ if (connection) {
63
+ try {
64
+ await queryClient.invalidateQueries({
65
+ queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspaceId),
66
+ });
67
+ } catch {
68
+ // Cache refresh is best effort: the callback is already committed server-side.
69
+ }
27
70
  }
28
- return connection;
71
+ return result;
29
72
  }
30
73
 
31
74
  export function useCompleteIntegrationCallback() {
@@ -37,3 +80,13 @@ export function useCompleteIntegrationCallback() {
37
80
  [queryClient],
38
81
  );
39
82
  }
83
+
84
+ export function useCompleteIntegrationCallbackResult() {
85
+ const queryClient = useQueryClient();
86
+ return useCallback(
87
+ async <TInput, TResult>(
88
+ options: Omit<CompleteIntegrationCallbackResultOptions<TInput, TResult>, 'queryClient'>,
89
+ ): Promise<TResult> => completeIntegrationCallbackResult({...options, queryClient}),
90
+ [queryClient],
91
+ );
92
+ }
@@ -7,24 +7,29 @@ import {useEffect, useRef} from 'react';
7
7
  export function CallbackStatusShell({
8
8
  title,
9
9
  message,
10
+ status = 'error',
10
11
  startOver,
11
12
  switchAccount,
12
- workspaceId,
13
+ workspaceSlug,
13
14
  installPath,
14
15
  }: {
15
16
  title: string;
16
17
  message: string;
18
+ status?: 'error' | 'success';
17
19
  startOver?: boolean;
18
20
  switchAccount?: boolean;
19
- workspaceId?: string | undefined;
20
- installPath: '/workspaces/$wid/integrations/linear' | '/workspaces/$wid/integrations/slack';
21
+ workspaceSlug?: string | undefined;
22
+ installPath:
23
+ | '/w/$workspaceSlug/integrations/linear'
24
+ | '/w/$workspaceSlug/integrations/slack'
25
+ | '/w/$workspaceSlug/integrations/jira';
21
26
  }) {
22
27
  const headingRef = useRef<HTMLHeadingElement>(null);
23
28
  useEffect(() => headingRef.current?.focus(), []);
24
29
  const recoveryVariant = startOver || switchAccount ? 'muted' : 'base';
25
- const settings = workspaceId ? (
30
+ const settings = workspaceSlug ? (
26
31
  <ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
27
- <Link to="/workspaces/$wid/settings/integrations" params={{wid: workspaceId}}>
32
+ <Link to="/w/$workspaceSlug/settings/integrations" params={{workspaceSlug}}>
28
33
  Back to integrations
29
34
  </Link>
30
35
  </ButtonLink>
@@ -33,7 +38,9 @@ export function CallbackStatusShell({
33
38
  <Link to="/">Back to Shipfox</Link>
34
39
  </ButtonLink>
35
40
  );
36
- const logoutRedirect = workspaceId ? installPath.replace('$wid', workspaceId) : undefined;
41
+ const logoutRedirect = workspaceSlug
42
+ ? installPath.replace('$workspaceSlug', workspaceSlug)
43
+ : undefined;
37
44
 
38
45
  return (
39
46
  <main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
@@ -41,7 +48,7 @@ export function CallbackStatusShell({
41
48
  <h2 ref={headingRef} tabIndex={-1} className="text-24 font-semibold outline-none">
42
49
  {title}
43
50
  </h2>
44
- <Callout role="alert" type="error">
51
+ <Callout role={status === 'error' ? 'alert' : 'status'} type={status}>
45
52
  <Text size="sm">{message}</Text>
46
53
  </Callout>
47
54
  <div className="flex flex-col gap-8 sm:flex-row sm:items-center">
@@ -49,15 +56,15 @@ export function CallbackStatusShell({
49
56
  <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
50
57
  <Link
51
58
  to="/auth/logout"
52
- search={workspaceId ? {redirect: logoutRedirect ?? '/'} : undefined}
59
+ search={logoutRedirect ? {redirect: logoutRedirect} : undefined}
53
60
  >
54
61
  Switch account
55
62
  </Link>
56
63
  </ButtonLink>
57
64
  ) : null}
58
- {startOver && workspaceId ? (
65
+ {startOver && workspaceSlug ? (
59
66
  <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
60
- <Link to={installPath} params={{wid: workspaceId}}>
67
+ <Link to={installPath} params={{workspaceSlug}}>
61
68
  Start over
62
69
  </Link>
63
70
  </ButtonLink>
@@ -18,6 +18,7 @@ import {type IntegrationConnection, isUsableConnection} from '#core/models.js';
18
18
  import {usageEventsForConnection} from './integration-usage-events.js';
19
19
 
20
20
  interface InstalledIntegrationsSectionProps {
21
+ workspaceSlug?: string | undefined;
21
22
  connections: IntegrationConnection[];
22
23
  isPending: boolean;
23
24
  isFetching: boolean;
@@ -34,6 +35,7 @@ const INSTALLED_SURFACE_CLASS =
34
35
  'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
35
36
 
36
37
  export function InstalledIntegrationsSection({
38
+ workspaceSlug,
37
39
  connections,
38
40
  isPending,
39
41
  isFetching,
@@ -81,6 +83,7 @@ export function InstalledIntegrationsSection({
81
83
  <InstalledRow
82
84
  key={connection.id}
83
85
  connection={connection}
86
+ workspaceSlug={workspaceSlug}
84
87
  isMutating={isMutating}
85
88
  onUse={onUse}
86
89
  onSetActive={(nextActive) => onSetActive(connection, nextActive)}
@@ -96,6 +99,7 @@ export function InstalledIntegrationsSection({
96
99
 
97
100
  function InstalledRow({
98
101
  connection,
102
+ workspaceSlug,
99
103
  isMutating,
100
104
  onUse,
101
105
  onSetActive,
@@ -103,6 +107,7 @@ function InstalledRow({
103
107
  providerDisplayName,
104
108
  }: {
105
109
  connection: IntegrationConnection;
110
+ workspaceSlug?: string | undefined;
106
111
  isMutating: boolean;
107
112
  onUse: (connectionId: string) => void;
108
113
  onSetActive: (active: boolean) => void;
@@ -159,15 +164,17 @@ function InstalledRow({
159
164
  <DropdownMenuItem onSelect={() => onUse(connection.id)}>
160
165
  Use this integration
161
166
  </DropdownMenuItem>
162
- <DropdownMenuItem asChild>
163
- <Link
164
- to="/workspaces/$wid/settings/events"
165
- params={{wid: connection.workspaceId}}
166
- search={{source: [connection.slug], event: [recentEventsEvent]}}
167
- >
168
- View recent events
169
- </Link>
170
- </DropdownMenuItem>
167
+ {workspaceSlug ? (
168
+ <DropdownMenuItem asChild>
169
+ <Link
170
+ to="/w/$workspaceSlug/settings/events"
171
+ params={{workspaceSlug}}
172
+ search={{source: [connection.slug], event: [recentEventsEvent]}}
173
+ >
174
+ View recent events
175
+ </Link>
176
+ </DropdownMenuItem>
177
+ ) : null}
171
178
  <DropdownMenuSeparator />
172
179
  <DropdownMenuItem disabled={isMutating} onSelect={() => onSetActive(!active)}>
173
180
  {active ? 'Disable integration' : 'Enable integration'}
@@ -1,3 +1,4 @@
1
+ import {useAuthState} from '@shipfox/client-auth';
1
2
  import {toast} from '@shipfox/react-ui/toast';
2
3
  import {Header, Text} from '@shipfox/react-ui/typography';
3
4
  import {useState} from 'react';
@@ -35,6 +36,8 @@ export function IntegrationGalleryForWorkspace({
35
36
  emptyProvidersMessage,
36
37
  workspaceId,
37
38
  }: IntegrationGalleryForWorkspaceProps) {
39
+ const {workspaces} = useAuthState();
40
+ const workspaceSlug = workspaces.find((workspace) => workspace.id === workspaceId)?.slug;
38
41
  const [createProvider, setCreateProvider] = useState<string | undefined>();
39
42
  const [usageConnectionId, setUsageConnectionId] = useState<string | undefined>();
40
43
  const [createdUsageConnection, setCreatedUsageConnection] = useState<
@@ -122,6 +125,7 @@ export function IntegrationGalleryForWorkspace({
122
125
  return (
123
126
  <div className="flex flex-col gap-24">
124
127
  <InstalledIntegrationsSection
128
+ workspaceSlug={workspaceSlug}
125
129
  connections={sortedConnections}
126
130
  isPending={connectionsQuery.isPending}
127
131
  isFetching={connectionsQuery.isFetching}
@@ -149,16 +153,22 @@ export function IntegrationGalleryForWorkspace({
149
153
  </Text>
150
154
  </div>
151
155
 
152
- <ProviderGrid
153
- providers={providers}
154
- isPending={providersQuery.isPending}
155
- isFetching={providersQuery.isFetching}
156
- error={providersQuery.isError ? providersQuery.error : undefined}
157
- onRetry={() => void providersQuery.refetch()}
158
- workspaceId={workspaceId}
159
- emptyMessage={emptyProvidersMessage}
160
- onOpenProvider={setCreateProvider}
161
- />
156
+ {workspaceSlug ? (
157
+ <ProviderGrid
158
+ workspaceSlug={workspaceSlug}
159
+ providers={providers}
160
+ isPending={providersQuery.isPending}
161
+ isFetching={providersQuery.isFetching}
162
+ error={providersQuery.isError ? providersQuery.error : undefined}
163
+ onRetry={() => void providersQuery.refetch()}
164
+ emptyMessage={emptyProvidersMessage}
165
+ onOpenProvider={setCreateProvider}
166
+ />
167
+ ) : (
168
+ <Text size="sm" className="text-foreground-neutral-muted" aria-live="polite">
169
+ Loading workspace details…
170
+ </Text>
171
+ )}
162
172
  </section>
163
173
  <WebhookCreateModal
164
174
  workspaceId={workspaceId}
@@ -20,14 +20,14 @@ import {useMemo} from 'react';
20
20
  import {IntegrationGallery} from './integration-gallery.js';
21
21
 
22
22
  const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
23
- const WORKSPACE_PATH = `/workspaces/${WORKSPACE_ID}/settings/integrations`;
23
+ const WORKSPACE_PATH = '/w/acme/settings/integrations';
24
24
  const SETUP_PATHS = [
25
- '/workspaces/$wid/integrations/github',
26
- '/workspaces/$wid/integrations/sentry',
27
- '/workspaces/$wid/integrations/linear',
28
- '/workspaces/$wid/integrations/slack',
29
- '/workspaces/$wid/integrations/gitea',
30
- '/workspaces/$wid/settings/events',
25
+ '/w/$workspaceSlug/integrations/github',
26
+ '/w/$workspaceSlug/integrations/sentry',
27
+ '/w/$workspaceSlug/integrations/linear',
28
+ '/w/$workspaceSlug/integrations/slack',
29
+ '/w/$workspaceSlug/integrations/gitea',
30
+ '/w/$workspaceSlug/settings/events',
31
31
  ] as const;
32
32
 
33
33
  type Scenario =
@@ -66,7 +66,7 @@ function IntegrationGalleryStory({scenario}: IntegrationGalleryStoryProps) {
66
66
  const nextStore = createStore();
67
67
  nextStore.set(authStateAtom, {
68
68
  status: 'authenticated',
69
- workspaces: [{id: WORKSPACE_ID, name: 'Acme', membershipId: 'membership-1'}],
69
+ workspaces: [{id: WORKSPACE_ID, name: 'Acme', slug: 'acme', membershipId: 'membership-1'}],
70
70
  });
71
71
  return nextStore;
72
72
  }, []);
@@ -74,7 +74,7 @@ function IntegrationGalleryStory({scenario}: IntegrationGalleryStoryProps) {
74
74
  const rootRoute = createRootRoute({component: Outlet});
75
75
  const workspaceRoute = createRoute({
76
76
  getParentRoute: () => rootRoute,
77
- path: '/workspaces/$wid',
77
+ path: '/w/$workspaceSlug',
78
78
  component: Outlet,
79
79
  });
80
80
  const galleryRoute = createRoute({
@@ -89,7 +89,7 @@ function IntegrationGalleryStory({scenario}: IntegrationGalleryStoryProps) {
89
89
  const setupRoutes = SETUP_PATHS.map((path) =>
90
90
  createRoute({
91
91
  getParentRoute: () => workspaceRoute,
92
- path: path.replace('/workspaces/$wid/', ''),
92
+ path: path.replace('/w/$workspaceSlug/', ''),
93
93
  component: () => <div />,
94
94
  }),
95
95
  );
@@ -3,7 +3,12 @@ import '@testing-library/jest-dom/vitest';
3
3
  import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
4
4
  import {configureApiClient} from '@shipfox/client-api';
5
5
  import {fireEvent, screen, waitFor, within} from '@testing-library/react';
6
- import {INTEGRATIONS_TEST_WID, jsonResponse, renderIntegrationsPage} from '#test/render.js';
6
+ import {
7
+ INTEGRATIONS_TEST_WID,
8
+ jsonResponse,
9
+ renderIntegrationsPage,
10
+ testWorkspace,
11
+ } from '#test/render.js';
7
12
  import {IntegrationGallery} from './integration-gallery.js';
8
13
 
9
14
  if (!HTMLElement.prototype.hasPointerCapture) {
@@ -28,9 +33,9 @@ if (!HTMLElement.prototype.releasePointerCapture) {
28
33
  }
29
34
 
30
35
  const SETUP_ROUTES = [
31
- '/workspaces/$wid/integrations/github',
32
- '/workspaces/$wid/integrations/sentry',
33
- '/workspaces/$wid/settings/events',
36
+ '/w/$workspaceSlug/integrations/github',
37
+ '/w/$workspaceSlug/integrations/sentry',
38
+ '/w/$workspaceSlug/settings/events',
34
39
  ];
35
40
  const EXTERNAL_SETTINGS_ACTION_RE = /Open.*settings/u;
36
41
 
@@ -183,16 +188,18 @@ function fetchForGallery(options: FetchOptions = {}) {
183
188
  function renderGallery(
184
189
  props: Parameters<typeof IntegrationGallery>[0] = {},
185
190
  options: FetchOptions = {},
191
+ renderOptions: {workspaces?: Parameters<typeof renderIntegrationsPage>[0]['workspaces']} = {},
186
192
  ) {
187
193
  configureApiClient({
188
194
  baseUrl: 'https://api.example.test',
189
195
  fetchImpl: fetchForGallery(options),
190
196
  });
191
197
  return renderIntegrationsPage({
192
- path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
193
- routePath: '/workspaces/$wid/integrations',
198
+ path: '/w/acme/integrations',
199
+ routePath: '/w/$workspaceSlug/integrations',
194
200
  element: <IntegrationGallery workspaceId={INTEGRATIONS_TEST_WID} {...props} />,
195
201
  extraRoutes: SETUP_ROUTES,
202
+ ...(renderOptions.workspaces ? {workspaces: renderOptions.workspaces} : {}),
196
203
  });
197
204
  }
198
205
 
@@ -203,11 +210,19 @@ const openActions = async (name: string) => {
203
210
  };
204
211
 
205
212
  const SORTED_NAMES_RE = /acme-(early|late)/;
206
- // The meta line carries the date only the provider is named once, by the
213
+ // The meta line carries the date only: the provider is named once, by the
207
214
  // icon and the account name, never repeated as body text in the row.
208
215
  const ADDED_META_RE = /^Added /;
216
+ const GITHUB_LINK_RE = /GitHub/u;
209
217
 
210
218
  describe('IntegrationGallery — installed section', () => {
219
+ test('waits for an explicit workspace id to hydrate before showing install links', async () => {
220
+ renderGallery({}, {}, {workspaces: [testWorkspace({id: 'stale-workspace', slug: 'acme'})]});
221
+
222
+ expect(await screen.findByText('Loading workspace details…')).toBeVisible();
223
+ expect(screen.queryByRole('link', {name: GITHUB_LINK_RE})).not.toBeInTheDocument();
224
+ });
225
+
211
226
  test('renders two distinct cards for two connections sharing one provider', async () => {
212
227
  renderGallery(
213
228
  {},
@@ -375,8 +390,8 @@ describe('IntegrationGallery — installed section', () => {
375
390
  });
376
391
  configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
377
392
  renderIntegrationsPage({
378
- path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
379
- routePath: '/workspaces/$wid/integrations',
393
+ path: '/w/acme/integrations',
394
+ routePath: '/w/$workspaceSlug/integrations',
380
395
  element: <IntegrationGallery />,
381
396
  extraRoutes: SETUP_ROUTES,
382
397
  });
@@ -410,8 +425,8 @@ describe('IntegrationGallery — installed section', () => {
410
425
  });
411
426
  configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
412
427
  renderIntegrationsPage({
413
- path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
414
- routePath: '/workspaces/$wid/integrations',
428
+ path: '/w/acme/integrations',
429
+ routePath: '/w/$workspaceSlug/integrations',
415
430
  element: <IntegrationGallery />,
416
431
  extraRoutes: SETUP_ROUTES,
417
432
  });
@@ -441,8 +456,8 @@ describe('IntegrationGallery — installed section', () => {
441
456
  const fetchImpl = fetchForGallery({connections: [webhookConnection]});
442
457
  configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
443
458
  renderIntegrationsPage({
444
- path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations`,
445
- routePath: '/workspaces/$wid/integrations',
459
+ path: '/w/acme/integrations',
460
+ routePath: '/w/$workspaceSlug/integrations',
446
461
  element: <IntegrationGallery />,
447
462
  extraRoutes: SETUP_ROUTES,
448
463
  });
@@ -511,7 +526,7 @@ describe('IntegrationGallery — installed section', () => {
511
526
  expect(within(installedRegion()).getByText(ADDED_META_RE)).toBeVisible();
512
527
  });
513
528
 
514
- test('names the provider once per row not repeated in the meta line', async () => {
529
+ test('names the provider once per row: not repeated in the meta line', async () => {
515
530
  renderGallery({}, {connections: [githubConnection]});
516
531
 
517
532
  await screen.findByText('acme-corp');
@@ -549,7 +564,7 @@ describe('IntegrationGallery — installed section', () => {
549
564
  });
550
565
  });
551
566
 
552
- describe('IntegrationGallery available section', () => {
567
+ describe('IntegrationGallery: available section', () => {
553
568
  test('lists every provider with an Install link, including installed ones', async () => {
554
569
  renderGallery({}, {connections: [githubConnection]});
555
570
 
@@ -1,4 +1,5 @@
1
1
  import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
+ import {jiraWebhookEventNames} from '@shipfox/api-integration-jira-dto';
2
3
  import {linearWebhookEventNames} from '@shipfox/api-integration-linear-dto';
3
4
  import {WEBHOOK_RECEIVED_EVENT} from '@shipfox/api-integration-webhook-dto';
4
5
  import {usageEventsForConnection} from './integration-usage-events.js';
@@ -67,6 +68,18 @@ describe('usageEventsForConnection', () => {
67
68
  expect(events.map((event) => event.value)).toEqual(linearWebhookEventNames);
68
69
  });
69
70
 
71
+ it('uses Jira webhook names directly', () => {
72
+ const connection = {
73
+ ...baseConnection,
74
+ provider: 'jira',
75
+ capabilities: ['agent_tools'],
76
+ } satisfies IntegrationConnectionDto;
77
+
78
+ const events = usageEventsForConnection(connection);
79
+
80
+ expect(events.map((event) => event.value)).toEqual(jiraWebhookEventNames);
81
+ });
82
+
70
83
  it('falls back to a generic received event for uncataloged providers', () => {
71
84
  const connection = {
72
85
  ...baseConnection,