@shipfox/client-integrations 17.0.0 → 22.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 (106) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +39 -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 +155 -141
  10. package/dist/components/installed-integrations-section.js.map +1 -1
  11. package/dist/components/integration-gallery.stories.d.ts.map +1 -1
  12. package/dist/components/integration-gallery.stories.js +8 -3
  13. package/dist/components/integration-gallery.stories.js.map +1 -1
  14. package/dist/components/provider-grid.d.ts +0 -2
  15. package/dist/components/provider-grid.d.ts.map +1 -1
  16. package/dist/components/provider-grid.js +88 -104
  17. package/dist/components/provider-grid.js.map +1 -1
  18. package/dist/components/redirect-install-page.d.ts.map +1 -1
  19. package/dist/components/redirect-install-page.js +3 -2
  20. package/dist/components/redirect-install-page.js.map +1 -1
  21. package/dist/components/repository-picker.d.ts +1 -4
  22. package/dist/components/repository-picker.d.ts.map +1 -1
  23. package/dist/components/repository-picker.js +24 -37
  24. package/dist/components/repository-picker.js.map +1 -1
  25. package/dist/components/webhook/webhook-modals.stories.js +1 -1
  26. package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
  27. package/dist/pages/gitea-install-page.js +1 -1
  28. package/dist/pages/gitea-install-page.js.map +1 -1
  29. package/dist/pages/jira-callback-page.js +6 -6
  30. package/dist/pages/jira-callback-page.js.map +1 -1
  31. package/dist/pages/sentry-callback-page.js +6 -6
  32. package/dist/pages/sentry-callback-page.js.map +1 -1
  33. package/dist/pages/source-control-onboarding-page.js +1 -1
  34. package/dist/pages/source-control-onboarding-page.js.map +1 -1
  35. package/dist/routes/gitea.d.ts +3 -0
  36. package/dist/routes/gitea.js +3 -0
  37. package/dist/routes/gitea.js.map +1 -1
  38. package/dist/routes/github-callback.d.ts +3 -0
  39. package/dist/routes/github-callback.d.ts.map +1 -1
  40. package/dist/routes/github-callback.js +3 -0
  41. package/dist/routes/github-callback.js.map +1 -1
  42. package/dist/routes/github.d.ts +3 -0
  43. package/dist/routes/github.js +3 -0
  44. package/dist/routes/github.js.map +1 -1
  45. package/dist/routes/integrations-settings.d.ts +3 -0
  46. package/dist/routes/integrations-settings.js +3 -0
  47. package/dist/routes/integrations-settings.js.map +1 -1
  48. package/dist/routes/integrations.d.ts +3 -0
  49. package/dist/routes/integrations.js +3 -0
  50. package/dist/routes/integrations.js.map +1 -1
  51. package/dist/routes/jira-callback.d.ts +3 -0
  52. package/dist/routes/jira-callback.js +3 -0
  53. package/dist/routes/jira-callback.js.map +1 -1
  54. package/dist/routes/jira.d.ts +3 -0
  55. package/dist/routes/jira.js +3 -0
  56. package/dist/routes/jira.js.map +1 -1
  57. package/dist/routes/linear-callback.d.ts +3 -0
  58. package/dist/routes/linear-callback.js +3 -0
  59. package/dist/routes/linear-callback.js.map +1 -1
  60. package/dist/routes/linear.d.ts +3 -0
  61. package/dist/routes/linear.js +3 -0
  62. package/dist/routes/linear.js.map +1 -1
  63. package/dist/routes/sentry-callback.d.ts +3 -0
  64. package/dist/routes/sentry-callback.js +3 -0
  65. package/dist/routes/sentry-callback.js.map +1 -1
  66. package/dist/routes/sentry.d.ts +3 -0
  67. package/dist/routes/sentry.js +3 -0
  68. package/dist/routes/sentry.js.map +1 -1
  69. package/dist/routes/slack-callback.d.ts +3 -0
  70. package/dist/routes/slack-callback.js +3 -0
  71. package/dist/routes/slack-callback.js.map +1 -1
  72. package/dist/routes/slack.d.ts +3 -0
  73. package/dist/routes/slack.js +3 -0
  74. package/dist/routes/slack.js.map +1 -1
  75. package/dist/tsconfig.test.tsbuildinfo +1 -1
  76. package/package.json +8 -8
  77. package/src/components/callback-status-shell.tsx +4 -3
  78. package/src/components/connection-picker.tsx +2 -2
  79. package/src/components/installed-integrations-section.tsx +110 -98
  80. package/src/components/integration-gallery.stories.tsx +8 -3
  81. package/src/components/integration-gallery.test.tsx +3 -1
  82. package/src/components/provider-grid.tsx +69 -79
  83. package/src/components/redirect-install-page.test.tsx +4 -0
  84. package/src/components/redirect-install-page.tsx +3 -2
  85. package/src/components/repository-picker.test.tsx +27 -13
  86. package/src/components/repository-picker.tsx +22 -47
  87. package/src/components/webhook/webhook-modals.stories.tsx +1 -1
  88. package/src/pages/gitea-install-page.tsx +1 -1
  89. package/src/pages/jira-callback-page.tsx +7 -7
  90. package/src/pages/sentry-callback-page.tsx +6 -8
  91. package/src/pages/slack-callback-page.test.tsx +7 -0
  92. package/src/pages/source-control-onboarding-page.tsx +1 -1
  93. package/src/routes/gitea.tsx +4 -1
  94. package/src/routes/github-callback.tsx +1 -0
  95. package/src/routes/github.tsx +4 -1
  96. package/src/routes/integrations-settings.tsx +4 -1
  97. package/src/routes/integrations.tsx +4 -1
  98. package/src/routes/jira-callback.tsx +1 -0
  99. package/src/routes/jira.tsx +4 -1
  100. package/src/routes/linear-callback.tsx +1 -0
  101. package/src/routes/linear.tsx +4 -1
  102. package/src/routes/sentry-callback.tsx +1 -0
  103. package/src/routes/sentry.tsx +4 -1
  104. package/src/routes/slack-callback.tsx +1 -0
  105. package/src/routes/slack.tsx +4 -1
  106. 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": "17.0.0",
4
+ "version": "22.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,20 +27,20 @@
27
27
  "dependencies": {
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "@tanstack/react-form": "^1.32.0",
30
- "@shipfox/api-integration-gitea-dto": "12.2.0",
31
30
  "@shipfox/api-integration-core-dto": "12.2.0",
31
+ "@shipfox/api-integration-gitea-dto": "12.2.0",
32
+ "@shipfox/api-integration-github-dto": "12.2.0",
32
33
  "@shipfox/api-integration-jira-dto": "12.2.0",
33
34
  "@shipfox/api-integration-linear-dto": "12.2.0",
34
- "@shipfox/api-integration-github-dto": "12.2.0",
35
35
  "@shipfox/api-integration-slack-dto": "12.2.0",
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": "17.0.0",
40
- "@shipfox/client-shell": "17.0.0",
41
- "@shipfox/client-ui": "17.0.0",
42
- "@shipfox/integration-icons": "0.3.3",
43
- "@shipfox/react-ui": "1.2.0"
39
+ "@shipfox/client-auth": "22.0.0",
40
+ "@shipfox/client-shell": "22.0.0",
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 bg-background-subtle-base px-row py-frame">
47
- <div className="mx-auto flex w-full max-w-[480px] 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,
@@ -59,39 +57,45 @@ export function InstalledIntegrationsSection({
59
57
  {isPending ? <InstalledSkeleton label="Loading integrations" /> : null}
60
58
 
61
59
  {error ? (
62
- <div className={cn(INSTALLED_SURFACE_CLASS, 'px-row')}>
60
+ <Panel>
63
61
  <QueryLoadError
64
62
  query={{isError: true, isFetching, data: undefined, error, refetch: onRetry}}
65
63
  subject="integrations"
64
+ variant="panel"
66
65
  />
67
- </div>
66
+ </Panel>
68
67
  ) : null}
69
68
 
70
69
  {!isPending && !error && connections.length === 0 ? (
71
- <div className={cn(INSTALLED_SURFACE_CLASS, 'px-row')}>
70
+ <Panel>
72
71
  <EmptyState
73
72
  icon="componentLine"
74
73
  title="No integrations installed yet"
75
74
  description="Install a provider below to get started."
75
+ variant="panel"
76
76
  />
77
- </div>
77
+ </Panel>
78
78
  ) : null}
79
79
 
80
80
  {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>
81
+ <Panel>
82
+ <PanelBody asChild>
83
+ <ul>
84
+ {connections.map((connection) => (
85
+ <InstalledRow
86
+ key={connection.id}
87
+ connection={connection}
88
+ workspaceSlug={workspaceSlug}
89
+ isMutating={isMutating}
90
+ onUse={onUse}
91
+ onSetActive={(nextActive) => onSetActive(connection, nextActive)}
92
+ onDelete={onDelete}
93
+ providerDisplayName={providerDisplayName}
94
+ />
95
+ ))}
96
+ </ul>
97
+ </PanelBody>
98
+ </Panel>
95
99
  ) : null}
96
100
  </section>
97
101
  );
@@ -120,91 +124,99 @@ function InstalledRow({
120
124
  const providerName = providerDisplayName(connection.provider);
121
125
 
122
126
  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}
127
+ <PanelRow asChild className="justify-start gap-cluster">
128
+ <li>
129
+ <IntegrationIcon
130
+ source={connection.provider}
131
+ aria-hidden
132
+ className={cn(
133
+ 'size-24 shrink-0',
134
+ muted ? 'text-foreground-neutral-disabled' : 'text-foreground-neutral-base',
135
+ )}
136
+ />
137
+ <div className="flex min-w-0 flex-1 flex-col gap-tight">
138
+ <div className="flex min-w-0 items-center gap-inline">
139
+ <Text
140
+ size="md"
141
+ bold
142
+ className={cn('truncate', muted ? 'text-foreground-neutral-disabled' : undefined)}
143
+ >
144
+ {connection.displayName}
145
+ </Text>
146
+ <ConnectionStatusBadge status={connection.lifecycleStatus} className="shrink-0" />
147
+ </div>
148
+ <Text size="sm" className="truncate text-foreground-neutral-muted">
149
+ Added {formatDate(connection.createdAt)}
140
150
  </Text>
141
- <ConnectionStatusBadge status={connection.lifecycleStatus} className="shrink-0" />
142
151
  </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>
152
+ <DropdownMenu>
153
+ <DropdownMenuTrigger asChild>
154
+ <IconButton
155
+ size="sm"
156
+ variant="transparent"
157
+ icon="more2Line"
158
+ aria-label={`Open ${connection.displayName} integration actions`}
159
+ />
160
+ </DropdownMenuTrigger>
161
+ <DropdownMenuContent align="end">
162
+ {connection.externalUrl ? (
163
+ <DropdownMenuItem asChild>
164
+ <a href={connection.externalUrl} target="_blank" rel="noreferrer noopener">
165
+ {providerName ? `Open in ${providerName}` : 'Open provider settings'}
166
+ </a>
167
+ </DropdownMenuItem>
168
+ ) : null}
169
+ <DropdownMenuItem onSelect={() => onUse(connection.id)}>
170
+ Use this integration
171
+ </DropdownMenuItem>
172
+ {workspaceSlug ? (
173
+ <DropdownMenuItem asChild>
174
+ <Link
175
+ to="/w/$workspaceSlug/settings/events"
176
+ params={{workspaceSlug}}
177
+ search={{source: [connection.slug], event: [recentEventsEvent]}}
178
+ >
179
+ View recent events
180
+ </Link>
181
+ </DropdownMenuItem>
182
+ ) : null}
183
+ <DropdownMenuSeparator />
184
+ <DropdownMenuItem disabled={isMutating} onSelect={() => onSetActive(!active)}>
185
+ {active ? 'Disable integration' : 'Enable integration'}
162
186
  </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>
187
+ <DropdownMenuItem disabled={isMutating} onSelect={() => onDelete(connection.id)}>
188
+ Delete integration
176
189
  </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>
190
+ </DropdownMenuContent>
191
+ </DropdownMenu>
192
+ </li>
193
+ </PanelRow>
188
194
  );
189
195
  }
190
196
 
191
197
  function InstalledSkeleton({label}: {label: string}) {
192
198
  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>
199
+ <Panel>
200
+ <PanelBody asChild>
201
+ <ul role="status" aria-label={label}>
202
+ {[0, 1, 2].map((row) => (
203
+ <PanelRow
204
+ asChild
205
+ className="justify-start gap-cluster hover:bg-background-neutral-base"
206
+ key={row}
207
+ >
208
+ <li>
209
+ <Skeleton className="size-24 shrink-0" />
210
+ <div className="flex min-w-0 flex-1 flex-col gap-tight">
211
+ <Skeleton className="h-16 w-120" />
212
+ <Skeleton className="h-12 w-80" />
213
+ </div>
214
+ <Skeleton className="h-20 w-72 shrink-0" />
215
+ </li>
216
+ </PanelRow>
217
+ ))}
218
+ </ul>
219
+ </PanelBody>
220
+ </Panel>
209
221
  );
210
222
  }
@@ -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';
@@ -82,7 +82,7 @@ function IntegrationGalleryStory({scenario}: IntegrationGalleryStoryProps) {
82
82
  getParentRoute: () => workspaceRoute,
83
83
  path: 'settings/integrations',
84
84
  component: () => (
85
- <div className="mx-auto w-full max-w-[760px] bg-background-neutral-background p-24">
85
+ <div className="mx-auto w-full max-w-[760px] bg-background-subtle-base p-24">
86
86
  <IntegrationGallery workspaceId={WORKSPACE_ID} />
87
87
  </div>
88
88
  ),
@@ -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
  },
@@ -600,7 +600,9 @@ describe('IntegrationGallery: available section', () => {
600
600
 
601
601
  const link = await screen.findByRole('link', {name: 'Install GitHub'});
602
602
 
603
- expect(link).toHaveClass('focus-visible:shadow-button-neutral-focus');
603
+ // Cells run edge to edge inside the panel, so the ring is inset rather than
604
+ // the outset button ring the panel's `overflow-hidden` would crop.
605
+ expect(link).toHaveClass('focus-visible:shadow-focus-inset');
604
606
  expect(link.className).not.toContain('shadow-button-secondary');
605
607
  expect(within(link).getByText('Install')).toBeVisible();
606
608
  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
- import {Card} from '@shipfox/react-ui/card';
4
3
  import {EmptyState} from '@shipfox/react-ui/empty-state';
5
- import {Icon} from '@shipfox/react-ui/icon';
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 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 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
- <Card 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
- </Card>
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
 
@@ -1,5 +1,6 @@
1
1
  import {ApiError} from '@shipfox/client-api';
2
2
  import {useActiveWorkspace} from '@shipfox/client-auth';
3
+ import {FocusedFrame} from '@shipfox/client-shell/runtime';
3
4
  import {ButtonLink} from '@shipfox/react-ui/button';
4
5
  import {Callout} from '@shipfox/react-ui/callout';
5
6
  import {FullPageLoader} from '@shipfox/react-ui/loader';
@@ -54,7 +55,7 @@ export function RedirectInstallPage({
54
55
 
55
56
  if (errorMessage) {
56
57
  return (
57
- <div className="mx-auto flex w-full max-w-[480px] flex-col gap-group">
58
+ <FocusedFrame className="flex flex-col gap-group">
58
59
  <Callout role="alert" type="error">
59
60
  <Text size="sm">{errorMessage}</Text>
60
61
  </Callout>
@@ -63,7 +64,7 @@ export function RedirectInstallPage({
63
64
  Back to integrations
64
65
  </Link>
65
66
  </ButtonLink>
66
- </div>
67
+ </FocusedFrame>
67
68
  );
68
69
  }
69
70