@shipfox/client-integrations 21.0.0 → 22.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 (59) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/components/callback-status-shell.d.ts.map +1 -1
  4. package/dist/components/callback-status-shell.js +4 -3
  5. package/dist/components/callback-status-shell.js.map +1 -1
  6. package/dist/components/connection-picker.js +2 -2
  7. package/dist/components/connection-picker.js.map +1 -1
  8. package/dist/components/installed-integrations-section.d.ts.map +1 -1
  9. package/dist/components/installed-integrations-section.js +160 -153
  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 +5 -12
  13. package/dist/components/integration-gallery-for-workspace.js.map +1 -1
  14. package/dist/components/integration-gallery.stories.d.ts.map +1 -1
  15. package/dist/components/integration-gallery.stories.js +7 -2
  16. package/dist/components/integration-gallery.stories.js.map +1 -1
  17. package/dist/components/provider-grid.d.ts +0 -2
  18. package/dist/components/provider-grid.d.ts.map +1 -1
  19. package/dist/components/provider-grid.js +88 -104
  20. package/dist/components/provider-grid.js.map +1 -1
  21. package/dist/components/redirect-install-page.d.ts.map +1 -1
  22. package/dist/components/redirect-install-page.js +3 -2
  23. package/dist/components/redirect-install-page.js.map +1 -1
  24. package/dist/components/repository-picker.d.ts +1 -4
  25. package/dist/components/repository-picker.d.ts.map +1 -1
  26. package/dist/components/repository-picker.js +24 -37
  27. package/dist/components/repository-picker.js.map +1 -1
  28. package/dist/pages/gitea-install-page.js +1 -1
  29. package/dist/pages/gitea-install-page.js.map +1 -1
  30. package/dist/pages/jira-callback-page.js +4 -4
  31. package/dist/pages/jira-callback-page.js.map +1 -1
  32. package/dist/pages/sentry-callback-page.js +4 -4
  33. package/dist/pages/sentry-callback-page.js.map +1 -1
  34. package/dist/pages/source-control-onboarding-page.js +1 -1
  35. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  36. package/dist/routes/integrations-settings.d.ts +1 -2
  37. package/dist/routes/integrations-settings.d.ts.map +1 -1
  38. package/dist/routes/integrations-settings.js +12 -1
  39. package/dist/routes/integrations-settings.js.map +1 -1
  40. package/dist/tsconfig.test.tsbuildinfo +1 -1
  41. package/package.json +6 -6
  42. package/src/components/callback-status-shell.tsx +4 -3
  43. package/src/components/connection-picker.tsx +2 -2
  44. package/src/components/installed-integrations-section.tsx +110 -101
  45. package/src/components/integration-gallery-for-workspace.tsx +0 -3
  46. package/src/components/integration-gallery.stories.tsx +7 -2
  47. package/src/components/integration-gallery.test.tsx +9 -4
  48. package/src/components/provider-grid.tsx +69 -79
  49. package/src/components/redirect-install-page.test.tsx +4 -0
  50. package/src/components/redirect-install-page.tsx +3 -2
  51. package/src/components/repository-picker.test.tsx +27 -13
  52. package/src/components/repository-picker.tsx +22 -47
  53. package/src/pages/gitea-install-page.tsx +1 -1
  54. package/src/pages/jira-callback-page.tsx +4 -4
  55. package/src/pages/sentry-callback-page.tsx +3 -3
  56. package/src/pages/slack-callback-page.test.tsx +7 -0
  57. package/src/pages/source-control-onboarding-page.tsx +1 -1
  58. package/src/routes/integrations-settings.tsx +7 -1
  59. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-integrations",
3
3
  "license": "MIT",
4
- "version": "21.0.0",
4
+ "version": "22.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -36,11 +36,11 @@
36
36
  "@shipfox/api-integration-sentry-dto": "12.2.0",
37
37
  "@shipfox/api-integration-webhook-dto": "12.2.0",
38
38
  "@shipfox/client-api": "6.0.1",
39
- "@shipfox/client-auth": "21.0.0",
40
- "@shipfox/client-shell": "21.0.0",
41
- "@shipfox/client-ui": "21.0.0",
42
- "@shipfox/integration-icons": "0.3.4",
43
- "@shipfox/react-ui": "2.0.0"
39
+ "@shipfox/client-auth": "22.0.1",
40
+ "@shipfox/client-shell": "22.0.1",
41
+ "@shipfox/client-ui": "22.0.0",
42
+ "@shipfox/integration-icons": "0.3.5",
43
+ "@shipfox/react-ui": "2.1.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@tanstack/react-query": "^5.101.0",
@@ -1,3 +1,4 @@
1
+ import {FocusedFrame} from '@shipfox/client-shell/runtime';
1
2
  import {ButtonLink} from '@shipfox/react-ui/button';
2
3
  import {Callout} from '@shipfox/react-ui/callout';
3
4
  import {Text} from '@shipfox/react-ui/typography';
@@ -43,8 +44,8 @@ export function CallbackStatusShell({
43
44
  : undefined;
44
45
 
45
46
  return (
46
- <main className="flex min-h-screen px-row py-frame">
47
- <div className="mx-auto flex w-full flex-col justify-center gap-section">
47
+ <main className="flex min-h-screen px-frame py-frame">
48
+ <FocusedFrame className="flex flex-col justify-center gap-section">
48
49
  <h2 ref={headingRef} tabIndex={-1} className="text-24 font-semibold outline-none">
49
50
  {title}
50
51
  </h2>
@@ -71,7 +72,7 @@ export function CallbackStatusShell({
71
72
  ) : null}
72
73
  {settings}
73
74
  </div>
74
- </div>
75
+ </FocusedFrame>
75
76
  </main>
76
77
  );
77
78
  }
@@ -19,15 +19,15 @@ export function ConnectionPicker({
19
19
  const labelId = useId();
20
20
 
21
21
  return (
22
- <div className="flex flex-col gap-inline">
22
+ <div className="flex min-w-0 flex-col">
23
23
  <Label id={labelId} className="sr-only">
24
24
  Source integration
25
25
  </Label>
26
26
  <RadioGroup
27
+ variant="cell"
27
28
  aria-labelledby={labelId}
28
29
  value={selectedConnectionId ?? ''}
29
30
  onValueChange={onSelect}
30
- className="grid grid-cols-2 gap-inline max-[760px]:grid-cols-1"
31
31
  >
32
32
  {connections.map((connection) => (
33
33
  <RadioGroupItem key={connection.id} value={connection.id}>
@@ -9,6 +9,7 @@ import {
9
9
  DropdownMenuTrigger,
10
10
  } from '@shipfox/react-ui/dropdown-menu';
11
11
  import {EmptyState} from '@shipfox/react-ui/empty-state';
12
+ import {Panel, PanelBody, PanelRow} from '@shipfox/react-ui/panel';
12
13
  import {Skeleton} from '@shipfox/react-ui/skeleton';
13
14
  import {Header, Text} from '@shipfox/react-ui/typography';
14
15
  import {cn, formatDate} from '@shipfox/react-ui/utils';
@@ -31,9 +32,6 @@ interface InstalledIntegrationsSectionProps {
31
32
  providerDisplayName: (provider: string) => string | undefined;
32
33
  }
33
34
 
34
- const INSTALLED_SURFACE_CLASS =
35
- 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
36
-
37
35
  export function InstalledIntegrationsSection({
38
36
  workspaceSlug,
39
37
  connections,
@@ -51,47 +49,50 @@ export function InstalledIntegrationsSection({
51
49
  <section className="flex flex-col gap-group" aria-label="Installed integrations">
52
50
  <div className="flex flex-col gap-tight">
53
51
  <Header variant="h3">Installed integrations</Header>
54
- <Text size="sm" className="text-foreground-neutral-muted">
55
- Provider accounts installed in this workspace.
56
- </Text>
57
52
  </div>
58
53
 
59
54
  {isPending ? <InstalledSkeleton label="Loading integrations" /> : null}
60
55
 
61
56
  {error ? (
62
- <div className={cn(INSTALLED_SURFACE_CLASS, 'px-row')}>
57
+ <Panel>
63
58
  <QueryLoadError
64
59
  query={{isError: true, isFetching, data: undefined, error, refetch: onRetry}}
65
60
  subject="integrations"
61
+ variant="panel"
66
62
  />
67
- </div>
63
+ </Panel>
68
64
  ) : null}
69
65
 
70
66
  {!isPending && !error && connections.length === 0 ? (
71
- <div className={cn(INSTALLED_SURFACE_CLASS, 'px-row')}>
67
+ <Panel>
72
68
  <EmptyState
73
69
  icon="componentLine"
74
70
  title="No integrations installed yet"
75
71
  description="Install a provider below to get started."
72
+ variant="panel"
76
73
  />
77
- </div>
74
+ </Panel>
78
75
  ) : null}
79
76
 
80
77
  {connections.length > 0 ? (
81
- <ul className={cn('divide-y divide-border-neutral-base', INSTALLED_SURFACE_CLASS)}>
82
- {connections.map((connection) => (
83
- <InstalledRow
84
- key={connection.id}
85
- connection={connection}
86
- workspaceSlug={workspaceSlug}
87
- isMutating={isMutating}
88
- onUse={onUse}
89
- onSetActive={(nextActive) => onSetActive(connection, nextActive)}
90
- onDelete={onDelete}
91
- providerDisplayName={providerDisplayName}
92
- />
93
- ))}
94
- </ul>
78
+ <Panel>
79
+ <PanelBody asChild>
80
+ <ul>
81
+ {connections.map((connection) => (
82
+ <InstalledRow
83
+ key={connection.id}
84
+ connection={connection}
85
+ workspaceSlug={workspaceSlug}
86
+ isMutating={isMutating}
87
+ onUse={onUse}
88
+ onSetActive={(nextActive) => onSetActive(connection, nextActive)}
89
+ onDelete={onDelete}
90
+ providerDisplayName={providerDisplayName}
91
+ />
92
+ ))}
93
+ </ul>
94
+ </PanelBody>
95
+ </Panel>
95
96
  ) : null}
96
97
  </section>
97
98
  );
@@ -120,91 +121,99 @@ function InstalledRow({
120
121
  const providerName = providerDisplayName(connection.provider);
121
122
 
122
123
  return (
123
- <li className="flex items-center gap-cluster px-row py-row transition-colors hover:bg-background-components-hover">
124
- <IntegrationIcon
125
- source={connection.provider}
126
- aria-hidden
127
- className={cn(
128
- 'size-24 shrink-0',
129
- muted ? 'text-foreground-neutral-disabled' : 'text-foreground-neutral-base',
130
- )}
131
- />
132
- <div className="flex min-w-0 flex-1 flex-col gap-tight">
133
- <div className="flex min-w-0 items-center gap-inline">
134
- <Text
135
- size="md"
136
- bold
137
- className={cn('truncate', muted ? 'text-foreground-neutral-disabled' : undefined)}
138
- >
139
- {connection.displayName}
124
+ <PanelRow asChild className="justify-start gap-cluster">
125
+ <li>
126
+ <IntegrationIcon
127
+ source={connection.provider}
128
+ aria-hidden
129
+ className={cn(
130
+ 'size-24 shrink-0',
131
+ muted ? 'text-foreground-neutral-disabled' : 'text-foreground-neutral-base',
132
+ )}
133
+ />
134
+ <div className="flex min-w-0 flex-1 flex-col gap-tight">
135
+ <div className="flex min-w-0 items-center gap-inline">
136
+ <Text
137
+ size="md"
138
+ bold
139
+ className={cn('truncate', muted ? 'text-foreground-neutral-disabled' : undefined)}
140
+ >
141
+ {connection.displayName}
142
+ </Text>
143
+ <ConnectionStatusBadge status={connection.lifecycleStatus} className="shrink-0" />
144
+ </div>
145
+ <Text size="sm" className="truncate text-foreground-neutral-muted">
146
+ Added {formatDate(connection.createdAt)}
140
147
  </Text>
141
- <ConnectionStatusBadge status={connection.lifecycleStatus} className="shrink-0" />
142
148
  </div>
143
- <Text size="sm" className="truncate text-foreground-neutral-muted">
144
- Added {formatDate(connection.createdAt)}
145
- </Text>
146
- </div>
147
- <DropdownMenu>
148
- <DropdownMenuTrigger asChild>
149
- <IconButton
150
- size="sm"
151
- variant="transparent"
152
- icon="more2Line"
153
- aria-label={`Open ${connection.displayName} integration actions`}
154
- />
155
- </DropdownMenuTrigger>
156
- <DropdownMenuContent align="end">
157
- {connection.externalUrl ? (
158
- <DropdownMenuItem asChild>
159
- <a href={connection.externalUrl} target="_blank" rel="noreferrer noopener">
160
- {providerName ? `Open in ${providerName}` : 'Open provider settings'}
161
- </a>
149
+ <DropdownMenu>
150
+ <DropdownMenuTrigger asChild>
151
+ <IconButton
152
+ size="sm"
153
+ variant="transparent"
154
+ icon="more2Line"
155
+ aria-label={`Open ${connection.displayName} integration actions`}
156
+ />
157
+ </DropdownMenuTrigger>
158
+ <DropdownMenuContent align="end">
159
+ {connection.externalUrl ? (
160
+ <DropdownMenuItem asChild>
161
+ <a href={connection.externalUrl} target="_blank" rel="noreferrer noopener">
162
+ {providerName ? `Open in ${providerName}` : 'Open provider settings'}
163
+ </a>
164
+ </DropdownMenuItem>
165
+ ) : null}
166
+ <DropdownMenuItem onSelect={() => onUse(connection.id)}>
167
+ Use this integration
168
+ </DropdownMenuItem>
169
+ {workspaceSlug ? (
170
+ <DropdownMenuItem asChild>
171
+ <Link
172
+ to="/w/$workspaceSlug/settings/events"
173
+ params={{workspaceSlug}}
174
+ search={{source: [connection.slug], event: [recentEventsEvent]}}
175
+ >
176
+ View recent events
177
+ </Link>
178
+ </DropdownMenuItem>
179
+ ) : null}
180
+ <DropdownMenuSeparator />
181
+ <DropdownMenuItem disabled={isMutating} onSelect={() => onSetActive(!active)}>
182
+ {active ? 'Disable integration' : 'Enable integration'}
162
183
  </DropdownMenuItem>
163
- ) : null}
164
- <DropdownMenuItem onSelect={() => onUse(connection.id)}>
165
- Use this integration
166
- </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>
184
+ <DropdownMenuItem disabled={isMutating} onSelect={() => onDelete(connection.id)}>
185
+ Delete integration
176
186
  </DropdownMenuItem>
177
- ) : null}
178
- <DropdownMenuSeparator />
179
- <DropdownMenuItem disabled={isMutating} onSelect={() => onSetActive(!active)}>
180
- {active ? 'Disable integration' : 'Enable integration'}
181
- </DropdownMenuItem>
182
- <DropdownMenuItem disabled={isMutating} onSelect={() => onDelete(connection.id)}>
183
- Delete integration
184
- </DropdownMenuItem>
185
- </DropdownMenuContent>
186
- </DropdownMenu>
187
- </li>
187
+ </DropdownMenuContent>
188
+ </DropdownMenu>
189
+ </li>
190
+ </PanelRow>
188
191
  );
189
192
  }
190
193
 
191
194
  function InstalledSkeleton({label}: {label: string}) {
192
195
  return (
193
- <ul
194
- role="status"
195
- aria-label={label}
196
- className={cn('divide-y divide-border-neutral-base', INSTALLED_SURFACE_CLASS)}
197
- >
198
- {[0, 1, 2].map((row) => (
199
- <li key={row} className="flex items-center gap-cluster px-row py-row">
200
- <Skeleton className="size-24 shrink-0" />
201
- <div className="flex min-w-0 flex-1 flex-col gap-tight">
202
- <Skeleton className="h-16 w-120" />
203
- <Skeleton className="h-12 w-80" />
204
- </div>
205
- <Skeleton className="h-20 w-72 shrink-0" />
206
- </li>
207
- ))}
208
- </ul>
196
+ <Panel>
197
+ <PanelBody asChild>
198
+ <ul role="status" aria-label={label}>
199
+ {[0, 1, 2].map((row) => (
200
+ <PanelRow
201
+ asChild
202
+ className="justify-start gap-cluster hover:bg-background-neutral-base"
203
+ key={row}
204
+ >
205
+ <li>
206
+ <Skeleton className="size-24 shrink-0" />
207
+ <div className="flex min-w-0 flex-1 flex-col gap-tight">
208
+ <Skeleton className="h-16 w-120" />
209
+ <Skeleton className="h-12 w-80" />
210
+ </div>
211
+ <Skeleton className="h-20 w-72 shrink-0" />
212
+ </li>
213
+ </PanelRow>
214
+ ))}
215
+ </ul>
216
+ </PanelBody>
217
+ </Panel>
209
218
  );
210
219
  }
@@ -148,9 +148,6 @@ export function IntegrationGalleryForWorkspace({
148
148
  <section className="flex flex-col gap-group" aria-label="Available integrations">
149
149
  <div className="flex flex-col gap-tight">
150
150
  <Header variant="h3">Available integrations</Header>
151
- <Text size="sm" className="text-foreground-neutral-muted">
152
- Providers available to install in this workspace.
153
- </Text>
154
151
  </div>
155
152
 
156
153
  {workspaceSlug ? (
@@ -17,7 +17,7 @@ import {
17
17
  } from '@tanstack/react-router';
18
18
  import {createStore, Provider as JotaiProvider} from 'jotai';
19
19
  import {useMemo} from 'react';
20
- import {expect, screen, userEvent, within} from 'storybook/test';
20
+ import {expect, screen, userEvent, waitFor, within} from 'storybook/test';
21
21
  import {IntegrationGallery} from './integration-gallery.js';
22
22
 
23
23
  const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
@@ -162,7 +162,12 @@ export const UsageModalCloseRestoresInteraction: Story = {
162
162
  await screen.findByRole('dialog', {name: 'Use acme-corp'});
163
163
  await user.click(await screen.findByRole('button', {name: 'Done'}));
164
164
  expect(screen.queryByRole('dialog', {name: 'Use acme-corp'})).not.toBeInTheDocument();
165
- expect(document.body.style.pointerEvents).not.toBe('none');
165
+ // The dialog unmounts before its dismissable layer releases the body, so
166
+ // wait for the release rather than asserting it on the same tick: clicking
167
+ // while `pointer-events: none` is still set is swallowed silently.
168
+ await waitFor(() => {
169
+ expect(document.body.style.pointerEvents).not.toBe('none');
170
+ });
166
171
  await user.click(actionsButton);
167
172
  await screen.findByRole('menuitem', {name: 'Use this integration'});
168
173
  },
@@ -244,7 +244,9 @@ describe('IntegrationGallery — installed section', () => {
244
244
 
245
245
  expect(await screen.findByText('acme-one')).toBeVisible();
246
246
  expect(screen.getByText('acme-two')).toBeVisible();
247
- expect(screen.getByText('Provider accounts installed in this workspace.')).toBeVisible();
247
+ expect(
248
+ screen.queryByText('Provider accounts installed in this workspace.'),
249
+ ).not.toBeInTheDocument();
248
250
  });
249
251
 
250
252
  test('sorts stably by provider name then created_at regardless of input order', async () => {
@@ -592,7 +594,9 @@ describe('IntegrationGallery: available section', () => {
592
594
  expect(await screen.findByRole('link', {name: 'Install GitHub'})).toBeVisible();
593
595
  expect(screen.getByRole('link', {name: 'Install Sentry'})).toBeVisible();
594
596
  expect(screen.getByRole('button', {name: 'Add Webhook'})).toBeVisible();
595
- expect(screen.getByText('Providers available to install in this workspace.')).toBeVisible();
597
+ expect(
598
+ screen.queryByText('Providers available to install in this workspace.'),
599
+ ).not.toBeInTheDocument();
596
600
  });
597
601
 
598
602
  test('exposes each available tile as a single link with no nested button', async () => {
@@ -600,8 +604,9 @@ describe('IntegrationGallery: available section', () => {
600
604
 
601
605
  const link = await screen.findByRole('link', {name: 'Install GitHub'});
602
606
 
603
- expect(link).toHaveClass('focus-visible:shadow-button-neutral-focus');
604
- expect(link).toHaveClass('shadow-button-neutral');
607
+ // Cells run edge to edge inside the panel, so the ring is inset rather than
608
+ // the outset button ring the panel's `overflow-hidden` would crop.
609
+ expect(link).toHaveClass('focus-visible:shadow-focus-inset');
605
610
  expect(link.className).not.toContain('shadow-button-secondary');
606
611
  expect(within(link).getByText('Install')).toBeVisible();
607
612
  expect(within(link).queryByRole('button')).not.toBeInTheDocument();
@@ -1,11 +1,9 @@
1
1
  import {QueryLoadError} from '@shipfox/client-ui';
2
2
  import {IntegrationIcon} from '@shipfox/integration-icons';
3
3
  import {EmptyState} from '@shipfox/react-ui/empty-state';
4
- import {Icon} from '@shipfox/react-ui/icon';
5
- import {Panel} from '@shipfox/react-ui/panel';
4
+ import {Panel, PanelBody, PanelCell, PanelCellAction, PanelGrid} from '@shipfox/react-ui/panel';
6
5
  import {Skeleton} from '@shipfox/react-ui/skeleton';
7
6
  import {Text} from '@shipfox/react-ui/typography';
8
- import {cn} from '@shipfox/react-ui/utils';
9
7
  import {Link} from '@tanstack/react-router';
10
8
  import type {IntegrationProvider} from '#core/models.js';
11
9
  import {PROVIDER_CATALOG} from '#provider-catalog.js';
@@ -23,11 +21,6 @@ export interface ProviderGridProps {
23
21
  onOpenProvider?: ((provider: string) => void) | undefined;
24
22
  }
25
23
 
26
- export const PROVIDER_GRID_CLASS = 'grid grid-cols-2 gap-cluster max-[760px]:grid-cols-1';
27
-
28
- export const PROVIDER_SURFACE_CLASS =
29
- 'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
30
-
31
24
  export function ProviderGrid({
32
25
  workspaceSlug,
33
26
  providers,
@@ -46,7 +39,7 @@ export function ProviderGrid({
46
39
 
47
40
  if (error) {
48
41
  return (
49
- <div className={cn(PROVIDER_SURFACE_CLASS, 'px-row')}>
42
+ <Panel>
50
43
  <QueryLoadError
51
44
  query={{
52
45
  isError: true,
@@ -56,39 +49,44 @@ export function ProviderGrid({
56
49
  refetch: onRetry ?? (() => undefined),
57
50
  }}
58
51
  subject={errorSubject}
52
+ variant="panel"
59
53
  />
60
- </div>
54
+ </Panel>
61
55
  );
62
56
  }
63
57
 
64
58
  if (installableProviders.length === 0) {
65
59
  return (
66
- <div className={cn(PROVIDER_SURFACE_CLASS, 'px-row')}>
60
+ <Panel>
67
61
  <EmptyState
68
62
  icon="componentLine"
69
63
  title="No integrations available"
70
64
  description={emptyMessage}
65
+ variant="panel"
71
66
  />
72
- </div>
67
+ </Panel>
73
68
  );
74
69
  }
75
70
 
76
71
  return (
77
- <ul className={PROVIDER_GRID_CLASS}>
78
- {installableProviders.map((provider) => (
79
- <li key={provider.provider}>
80
- <ProviderCard
81
- provider={provider}
82
- workspaceSlug={workspaceSlug}
83
- onOpenProvider={onOpenProvider}
84
- />
85
- </li>
86
- ))}
87
- </ul>
72
+ <Panel>
73
+ <PanelBody>
74
+ <PanelGrid aria-label="Available integrations">
75
+ {installableProviders.map((provider) => (
76
+ <ProviderCell
77
+ key={provider.provider}
78
+ provider={provider}
79
+ workspaceSlug={workspaceSlug}
80
+ onOpenProvider={onOpenProvider}
81
+ />
82
+ ))}
83
+ </PanelGrid>
84
+ </PanelBody>
85
+ </Panel>
88
86
  );
89
87
  }
90
88
 
91
- function ProviderCard({
89
+ function ProviderCell({
92
90
  provider,
93
91
  workspaceSlug,
94
92
  onOpenProvider,
@@ -102,74 +100,66 @@ function ProviderCard({
102
100
 
103
101
  if (catalog.kind === 'modal-connect') {
104
102
  return (
105
- <button
106
- type="button"
107
- aria-label={`Add ${provider.displayName}`}
108
- onClick={() => onOpenProvider?.(provider.provider)}
109
- className="group flex h-full w-full min-w-0 items-center justify-between gap-cluster rounded-8 border border-border-neutral-base bg-background-neutral-base p-panel-compact text-left shadow-button-neutral transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
110
- >
111
- <ProviderCardContent provider={provider} action="Add" />
112
- </button>
103
+ <PanelCell>
104
+ <PanelCellAction
105
+ action="Add"
106
+ aria-label={`Add ${provider.displayName}`}
107
+ onClick={() => onOpenProvider?.(provider.provider)}
108
+ >
109
+ <ProviderCellContent provider={provider} />
110
+ </PanelCellAction>
111
+ </PanelCell>
113
112
  );
114
113
  }
115
114
 
116
115
  return (
117
- <Link
118
- to={catalog.setupPath}
119
- params={{workspaceSlug}}
120
- aria-label={`Install ${provider.displayName}`}
121
- className="group flex h-full min-w-0 items-center justify-between gap-cluster rounded-8 border border-border-neutral-base bg-background-neutral-base p-panel-compact shadow-button-neutral transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
122
- >
123
- <ProviderCardContent provider={provider} action="Install" />
124
- </Link>
116
+ <PanelCell>
117
+ <PanelCellAction asChild action="Install">
118
+ <Link
119
+ to={catalog.setupPath}
120
+ params={{workspaceSlug}}
121
+ aria-label={`Install ${provider.displayName}`}
122
+ >
123
+ <ProviderCellContent provider={provider} />
124
+ </Link>
125
+ </PanelCellAction>
126
+ </PanelCell>
125
127
  );
126
128
  }
127
129
 
128
- function ProviderCardContent({
129
- provider,
130
- action,
131
- }: {
132
- provider: IntegrationProvider;
133
- action: 'Add' | 'Install';
134
- }) {
130
+ function ProviderCellContent({provider}: {provider: IntegrationProvider}) {
135
131
  return (
136
- <>
137
- <span className="flex min-w-0 items-center gap-cluster">
138
- <IntegrationIcon
139
- source={provider.provider}
140
- aria-hidden
141
- className="size-24 shrink-0 text-foreground-neutral-base"
142
- />
143
- <Text as="span" size="md" bold className="truncate">
144
- {provider.displayName}
145
- </Text>
146
- </span>
147
- <span className="flex shrink-0 items-center gap-tight text-foreground-neutral-muted transition-colors group-hover:text-foreground-highlight-interactive">
148
- <Text as="span" size="sm">
149
- {action}
150
- </Text>
151
- <Icon name="chevronRight" className="size-16" />
152
- </span>
153
- </>
132
+ <span className="flex min-w-0 items-center gap-cluster">
133
+ <IntegrationIcon
134
+ source={provider.provider}
135
+ aria-hidden
136
+ className="size-24 shrink-0 text-foreground-neutral-base"
137
+ />
138
+ <Text as="span" size="md" bold className="truncate">
139
+ {provider.displayName}
140
+ </Text>
141
+ </span>
154
142
  );
155
143
  }
156
144
 
157
145
  function ProviderGridSkeleton({label}: {label: string}) {
158
146
  return (
159
- <ul role="status" aria-label={label} className={PROVIDER_GRID_CLASS}>
160
- {[0, 1, 2, 3].map((tile) => (
161
- <li key={tile}>
162
- <Panel className="h-full p-panel-compact">
163
- <div className="flex items-center justify-between gap-cluster">
164
- <div className="flex min-w-0 items-center gap-cluster">
165
- <Skeleton className="size-24 shrink-0" />
166
- <Skeleton className="h-16 w-100" />
147
+ <Panel>
148
+ <PanelBody>
149
+ <PanelGrid role="status" aria-label={label}>
150
+ {[0, 1, 2, 3].map((tile) => (
151
+ <PanelCell key={tile}>
152
+ <div className="flex items-center justify-between gap-cluster px-row py-row">
153
+ <div className="flex min-w-0 items-center gap-cluster">
154
+ <Skeleton className="size-24 shrink-0" />
155
+ <Skeleton className="h-16 w-100" />
156
+ </div>
157
+ <Skeleton className="h-16 w-64 shrink-0" />
167
158
  </div>
168
- <Skeleton className="h-16 w-64 shrink-0" />
169
- </div>
170
- </Panel>
171
- </li>
172
- ))}
173
- </ul>
159
+ </PanelCell>
160
+ ))}
161
+ </PanelGrid>
162
+ </PanelBody>
163
+ </Panel>
174
164
  );
175
165
  }
@@ -1,11 +1,14 @@
1
1
  // @vitest-environment jsdom
2
2
  import '@testing-library/jest-dom/vitest';
3
3
  import {ApiError} from '@shipfox/client-api';
4
+ import {FOCUSED_FRAME_CONTENT_CLASS_NAME} from '@shipfox/client-shell/runtime';
4
5
  import {screen, waitFor} from '@testing-library/react';
5
6
  import {StrictMode} from 'react';
6
7
  import {INTEGRATIONS_TEST_WID, renderIntegrationsPage} from '#test/render.js';
7
8
  import {RedirectInstallPage} from './redirect-install-page.js';
8
9
 
10
+ const FOCUSED_FRAME_CONTENT_CLASSES = FOCUSED_FRAME_CONTENT_CLASS_NAME.split(' ');
11
+
9
12
  function renderInstallPage(
10
13
  props: Parameters<typeof RedirectInstallPage>[0],
11
14
  options?: {strict?: boolean},
@@ -62,6 +65,7 @@ describe('RedirectInstallPage', () => {
62
65
 
63
66
  // Alert mounts via framer-motion (opacity 0 in jsdom), so assert presence.
64
67
  expect(await screen.findByText('Sentry app not configured')).toBeInTheDocument();
68
+ expect(screen.getByRole('alert').parentElement).toHaveClass(...FOCUSED_FRAME_CONTENT_CLASSES);
65
69
  expect(screen.getByRole('link', {name: 'Back to integrations'})).toBeVisible();
66
70
  });
67
71