@shipfox/client-integrations 12.0.1 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/dist/components/callback-status-shell.d.ts +4 -3
  4. package/dist/components/callback-status-shell.d.ts.map +1 -1
  5. package/dist/components/callback-status-shell.js +11 -11
  6. package/dist/components/callback-status-shell.js.map +1 -1
  7. package/dist/components/installed-integrations-section.d.ts +2 -1
  8. package/dist/components/installed-integrations-section.d.ts.map +1 -1
  9. package/dist/components/installed-integrations-section.js +7 -6
  10. package/dist/components/installed-integrations-section.js.map +1 -1
  11. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
  12. package/dist/components/integration-gallery-for-workspace.js +11 -2
  13. package/dist/components/integration-gallery-for-workspace.js.map +1 -1
  14. package/dist/components/integration-gallery.stories.js +10 -9
  15. package/dist/components/integration-gallery.stories.js.map +1 -1
  16. package/dist/components/provider-grid.d.ts +2 -2
  17. package/dist/components/provider-grid.d.ts.map +1 -1
  18. package/dist/components/provider-grid.js +4 -4
  19. package/dist/components/provider-grid.js.map +1 -1
  20. package/dist/components/redirect-install-page.d.ts +1 -1
  21. package/dist/components/redirect-install-page.js +2 -2
  22. package/dist/components/redirect-install-page.js.map +1 -1
  23. package/dist/components/webhook/webhook-modals.stories.js +2 -2
  24. package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
  25. package/dist/feature.d.ts +7 -7
  26. package/dist/feature.js +7 -7
  27. package/dist/feature.js.map +1 -1
  28. package/dist/hooks/api/integrations.js +1 -1
  29. package/dist/hooks/api/integrations.js.map +1 -1
  30. package/dist/pages/gitea-install-page.js +4 -4
  31. package/dist/pages/gitea-install-page.js.map +1 -1
  32. package/dist/pages/linear-callback-page.d.ts.map +1 -1
  33. package/dist/pages/linear-callback-page.js +58 -12
  34. package/dist/pages/linear-callback-page.js.map +1 -1
  35. package/dist/pages/sentry-callback-page.d.ts.map +1 -1
  36. package/dist/pages/sentry-callback-page.js +22 -9
  37. package/dist/pages/sentry-callback-page.js.map +1 -1
  38. package/dist/pages/slack-callback-page.d.ts.map +1 -1
  39. package/dist/pages/slack-callback-page.js +50 -18
  40. package/dist/pages/slack-callback-page.js.map +1 -1
  41. package/dist/pages/source-control-onboarding-page.js +1 -1
  42. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  43. package/dist/provider-catalog.d.ts +1 -1
  44. package/dist/provider-catalog.d.ts.map +1 -1
  45. package/dist/provider-catalog.js +5 -5
  46. package/dist/provider-catalog.js.map +1 -1
  47. package/dist/sentry-callback.js +2 -2
  48. package/dist/sentry-callback.js.map +1 -1
  49. package/dist/tsconfig.test.tsbuildinfo +1 -1
  50. package/dist/workspace-navigation.d.ts +13 -0
  51. package/dist/workspace-navigation.d.ts.map +1 -0
  52. package/dist/workspace-navigation.js +17 -0
  53. package/dist/workspace-navigation.js.map +1 -0
  54. package/package.json +13 -13
  55. package/src/components/callback-status-shell.tsx +14 -10
  56. package/src/components/installed-integrations-section.tsx +16 -9
  57. package/src/components/integration-gallery-for-workspace.tsx +20 -10
  58. package/src/components/integration-gallery.stories.tsx +10 -10
  59. package/src/components/integration-gallery.test.tsx +30 -15
  60. package/src/components/provider-grid.tsx +6 -6
  61. package/src/components/redirect-install-page.test.tsx +3 -3
  62. package/src/components/redirect-install-page.tsx +2 -2
  63. package/src/components/webhook/webhook-modals.stories.tsx +2 -2
  64. package/src/feature.ts +7 -7
  65. package/src/hooks/api/integrations.ts +1 -1
  66. package/src/pages/gitea-install-page.tsx +2 -2
  67. package/src/pages/linear-callback-page.test.tsx +25 -12
  68. package/src/pages/linear-callback-page.tsx +73 -12
  69. package/src/pages/sentry-callback-page.test.tsx +8 -5
  70. package/src/pages/sentry-callback-page.tsx +32 -8
  71. package/src/pages/slack-callback-page.test.tsx +78 -0
  72. package/src/pages/slack-callback-page.tsx +56 -18
  73. package/src/pages/source-control-onboarding-page.test.tsx +4 -4
  74. package/src/pages/source-control-onboarding-page.tsx +1 -1
  75. package/src/provider-catalog.test.ts +4 -4
  76. package/src/provider-catalog.ts +10 -10
  77. package/src/sentry-callback.ts +2 -2
  78. package/src/workspace-navigation.test.ts +43 -0
  79. package/src/workspace-navigation.ts +38 -0
  80. package/test/render.tsx +2 -1
  81. 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.1",
4
+ "version": "13.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,19 +27,19 @@
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-github-dto": "12.0.0",
32
+ "@shipfox/api-integration-linear-dto": "12.0.0",
33
+ "@shipfox/api-integration-gitea-dto": "12.0.0",
34
+ "@shipfox/api-integration-slack-dto": "12.0.0",
35
+ "@shipfox/api-integration-webhook-dto": "12.0.0",
37
36
  "@shipfox/client-api": "6.0.1",
38
- "@shipfox/client-auth": "12.0.1",
39
- "@shipfox/client-shell": "12.0.1",
40
- "@shipfox/client-ui": "6.0.2",
41
- "@shipfox/integration-icons": "0.2.2",
42
- "@shipfox/react-ui": "0.3.7"
37
+ "@shipfox/client-shell": "13.0.0",
38
+ "@shipfox/integration-icons": "0.2.3",
39
+ "@shipfox/api-integration-sentry-dto": "12.0.0",
40
+ "@shipfox/client-ui": "13.0.0",
41
+ "@shipfox/client-auth": "13.0.0",
42
+ "@shipfox/react-ui": "0.4.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@tanstack/react-query": "^5.101.0",
@@ -7,24 +7,26 @@ 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: '/w/$workspaceSlug/integrations/linear' | '/w/$workspaceSlug/integrations/slack';
21
23
  }) {
22
24
  const headingRef = useRef<HTMLHeadingElement>(null);
23
25
  useEffect(() => headingRef.current?.focus(), []);
24
26
  const recoveryVariant = startOver || switchAccount ? 'muted' : 'base';
25
- const settings = workspaceId ? (
27
+ const settings = workspaceSlug ? (
26
28
  <ButtonLink asChild variant={recoveryVariant} className="min-h-44 w-full sm:w-fit">
27
- <Link to="/workspaces/$wid/settings/integrations" params={{wid: workspaceId}}>
29
+ <Link to="/w/$workspaceSlug/settings/integrations" params={{workspaceSlug}}>
28
30
  Back to integrations
29
31
  </Link>
30
32
  </ButtonLink>
@@ -33,7 +35,9 @@ export function CallbackStatusShell({
33
35
  <Link to="/">Back to Shipfox</Link>
34
36
  </ButtonLink>
35
37
  );
36
- const logoutRedirect = workspaceId ? installPath.replace('$wid', workspaceId) : undefined;
38
+ const logoutRedirect = workspaceSlug
39
+ ? installPath.replace('$workspaceSlug', workspaceSlug)
40
+ : undefined;
37
41
 
38
42
  return (
39
43
  <main className="flex min-h-screen bg-background-subtle-base px-16 py-32">
@@ -41,7 +45,7 @@ export function CallbackStatusShell({
41
45
  <h2 ref={headingRef} tabIndex={-1} className="text-24 font-semibold outline-none">
42
46
  {title}
43
47
  </h2>
44
- <Callout role="alert" type="error">
48
+ <Callout role={status === 'error' ? 'alert' : 'status'} type={status}>
45
49
  <Text size="sm">{message}</Text>
46
50
  </Callout>
47
51
  <div className="flex flex-col gap-8 sm:flex-row sm:items-center">
@@ -49,15 +53,15 @@ export function CallbackStatusShell({
49
53
  <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
50
54
  <Link
51
55
  to="/auth/logout"
52
- search={workspaceId ? {redirect: logoutRedirect ?? '/'} : undefined}
56
+ search={logoutRedirect ? {redirect: logoutRedirect} : undefined}
53
57
  >
54
58
  Switch account
55
59
  </Link>
56
60
  </ButtonLink>
57
61
  ) : null}
58
- {startOver && workspaceId ? (
62
+ {startOver && workspaceSlug ? (
59
63
  <ButtonLink asChild className="min-h-44 w-full sm:w-fit">
60
- <Link to={installPath} params={{wid: workspaceId}}>
64
+ <Link to={installPath} params={{workspaceSlug}}>
61
65
  Start over
62
66
  </Link>
63
67
  </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
 
@@ -11,12 +11,12 @@ import type {IntegrationProvider} from '#core/models.js';
11
11
  import {PROVIDER_CATALOG} from '#provider-catalog.js';
12
12
 
13
13
  export interface ProviderGridProps {
14
+ workspaceSlug: string;
14
15
  providers: IntegrationProvider[];
15
16
  isPending: boolean;
16
17
  isFetching?: boolean;
17
18
  error?: Error | null | undefined;
18
19
  onRetry?: () => void;
19
- workspaceId: string;
20
20
  emptyMessage: string;
21
21
  loadingLabel?: string;
22
22
  errorSubject?: string;
@@ -29,12 +29,12 @@ export const PROVIDER_SURFACE_CLASS =
29
29
  'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
30
30
 
31
31
  export function ProviderGrid({
32
+ workspaceSlug,
32
33
  providers,
33
34
  isPending,
34
35
  isFetching = false,
35
36
  error,
36
37
  onRetry,
37
- workspaceId,
38
38
  emptyMessage,
39
39
  loadingLabel = 'Loading providers',
40
40
  errorSubject = 'available integrations',
@@ -79,7 +79,7 @@ export function ProviderGrid({
79
79
  <li key={provider.provider}>
80
80
  <ProviderCard
81
81
  provider={provider}
82
- workspaceId={workspaceId}
82
+ workspaceSlug={workspaceSlug}
83
83
  onOpenProvider={onOpenProvider}
84
84
  />
85
85
  </li>
@@ -90,11 +90,11 @@ export function ProviderGrid({
90
90
 
91
91
  function ProviderCard({
92
92
  provider,
93
- workspaceId,
93
+ workspaceSlug,
94
94
  onOpenProvider,
95
95
  }: {
96
96
  provider: IntegrationProvider;
97
- workspaceId: string;
97
+ workspaceSlug: string;
98
98
  onOpenProvider?: ((provider: string) => void) | undefined;
99
99
  }) {
100
100
  const catalog = PROVIDER_CATALOG[provider.provider];
@@ -116,7 +116,7 @@ function ProviderCard({
116
116
  return (
117
117
  <Link
118
118
  to={catalog.setupPath}
119
- params={{wid: workspaceId}}
119
+ params={{workspaceSlug}}
120
120
  aria-label={`Install ${provider.displayName}`}
121
121
  className="group flex h-full min-w-0 items-center justify-between gap-12 rounded-8 border border-border-neutral-base bg-background-neutral-base p-16 transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
122
122
  >
@@ -11,8 +11,8 @@ function renderInstallPage(
11
11
  options?: {strict?: boolean},
12
12
  ) {
13
13
  return renderIntegrationsPage({
14
- path: `/workspaces/${INTEGRATIONS_TEST_WID}/integrations/github`,
15
- routePath: '/workspaces/$wid/integrations/github',
14
+ path: '/w/acme/integrations/github',
15
+ routePath: '/w/$workspaceSlug/integrations/github',
16
16
  element: options?.strict ? (
17
17
  <StrictMode>
18
18
  <RedirectInstallPage {...props} />
@@ -20,7 +20,7 @@ function renderInstallPage(
20
20
  ) : (
21
21
  <RedirectInstallPage {...props} />
22
22
  ),
23
- extraRoutes: ['/workspaces/$wid/integrations'],
23
+ extraRoutes: ['/w/$workspaceSlug/integrations'],
24
24
  });
25
25
  }
26
26
 
@@ -14,7 +14,7 @@ interface RedirectInstallPageProps {
14
14
  loadingLabel?: string;
15
15
  /**
16
16
  * Runs before leaving the app (e.g. to persist the workspace id for a
17
- * state-less provider callback). Must not throw a failed side effect
17
+ * state-less provider callback. It must not throw. A failed side effect
18
18
  * should never block the redirect.
19
19
  */
20
20
  beforeRedirect?: (workspaceId: string) => void;
@@ -59,7 +59,7 @@ export function RedirectInstallPage({
59
59
  <Text size="sm">{errorMessage}</Text>
60
60
  </Callout>
61
61
  <ButtonLink asChild variant="muted" className="w-fit">
62
- <Link to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
62
+ <Link to="/w/$workspaceSlug/integrations" params={{workspaceSlug: workspace.slug}}>
63
63
  Back to integrations
64
64
  </Link>
65
65
  </ButtonLink>
@@ -176,7 +176,7 @@ function createStoryRouter(scenario: Scenario) {
176
176
  const rootRoute = createRootRoute({component: Outlet});
177
177
  const workspaceRoute = createRoute({
178
178
  getParentRoute: () => rootRoute,
179
- path: '/workspaces/$wid',
179
+ path: '/w/$workspaceSlug',
180
180
  component: Outlet,
181
181
  });
182
182
  const integrationsRoute = createRoute({
@@ -192,7 +192,7 @@ function createStoryRouter(scenario: Scenario) {
192
192
 
193
193
  return createRouter({
194
194
  history: createMemoryHistory({
195
- initialEntries: [`/workspaces/${WORKSPACE_ID}/settings/integrations`],
195
+ initialEntries: ['/w/acme/settings/integrations'],
196
196
  }),
197
197
  routeTree: rootRoute.addChildren([
198
198
  workspaceRoute.addChildren([integrationsRoute, eventsRoute]),
package/src/feature.ts CHANGED
@@ -34,37 +34,37 @@ export const integrationsFeature = defineClientFeature({
34
34
  impl: '@shipfox/client-integrations/routes/slack-callback',
35
35
  },
36
36
  {
37
- path: '/workspaces/$wid/integrations',
37
+ path: '/w/$workspaceSlug/integrations',
38
38
  parent: 'workspaceLayout',
39
39
  impl: '@shipfox/client-integrations/routes/integrations',
40
40
  },
41
41
  {
42
- path: '/workspaces/$wid/integrations/gitea',
42
+ path: '/w/$workspaceSlug/integrations/gitea',
43
43
  parent: 'workspaceLayout',
44
44
  impl: '@shipfox/client-integrations/routes/gitea',
45
45
  },
46
46
  {
47
- path: '/workspaces/$wid/integrations/github',
47
+ path: '/w/$workspaceSlug/integrations/github',
48
48
  parent: 'workspaceLayout',
49
49
  impl: '@shipfox/client-integrations/routes/github',
50
50
  },
51
51
  {
52
- path: '/workspaces/$wid/integrations/linear',
52
+ path: '/w/$workspaceSlug/integrations/linear',
53
53
  parent: 'workspaceLayout',
54
54
  impl: '@shipfox/client-integrations/routes/linear',
55
55
  },
56
56
  {
57
- path: '/workspaces/$wid/integrations/sentry',
57
+ path: '/w/$workspaceSlug/integrations/sentry',
58
58
  parent: 'workspaceLayout',
59
59
  impl: '@shipfox/client-integrations/routes/sentry',
60
60
  },
61
61
  {
62
- path: '/workspaces/$wid/integrations/slack',
62
+ path: '/w/$workspaceSlug/integrations/slack',
63
63
  parent: 'workspaceLayout',
64
64
  impl: '@shipfox/client-integrations/routes/slack',
65
65
  },
66
66
  {
67
- path: '/workspaces/$wid/settings/integrations',
67
+ path: '/w/$workspaceSlug/settings/integrations',
68
68
  parent: 'workspaceSettings',
69
69
  impl: '@shipfox/client-integrations/routes/integrations-settings',
70
70
  },
@@ -175,7 +175,7 @@ export async function listSourceConnections({
175
175
  });
176
176
  // The endpoint returns every lifecycle status (the settings hub needs that),
177
177
  // but source-control consumers (onboarding redirect, project creation) only
178
- // act on usable connections a disabled/error one must read as "not there".
178
+ // act on usable connections. A disabled/error one must read as "not there".
179
179
  return connections.filter(isUsableConnection);
180
180
  }
181
181
 
@@ -24,7 +24,7 @@ export function GiteaInstallPage() {
24
24
  try {
25
25
  await connect.mutateAsync({workspace_id: workspaceId, org: value.org.trim()});
26
26
  toast.success('Gitea organization installed.');
27
- await navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
27
+ await navigate({to: '/w/$workspaceSlug', params: {workspaceSlug: workspace.slug}});
28
28
  } catch (error) {
29
29
  const mapped = giteaConnectErrorToFormError(error);
30
30
  if (mapped.kind === 'field') {
@@ -88,7 +88,7 @@ export function GiteaInstallPage() {
88
88
  Install
89
89
  </Button>
90
90
  <ButtonLink asChild variant="muted">
91
- <Link to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
91
+ <Link to="/w/$workspaceSlug/integrations" params={{workspaceSlug: workspace.slug}}>
92
92
  Cancel
93
93
  </Link>
94
94
  </ButtonLink>