@shipfox/client-integrations 0.2.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 (243) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +207 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/connection-picker.d.ts +7 -0
  10. package/dist/components/connection-picker.d.ts.map +1 -0
  11. package/dist/components/connection-picker.js +98 -0
  12. package/dist/components/connection-picker.js.map +1 -0
  13. package/dist/components/installed-integrations-section.d.ts +14 -0
  14. package/dist/components/installed-integrations-section.d.ts.map +1 -0
  15. package/dist/components/installed-integrations-section.js +203 -0
  16. package/dist/components/installed-integrations-section.js.map +1 -0
  17. package/dist/components/integration-delete-confirm-modal.d.ts +10 -0
  18. package/dist/components/integration-delete-confirm-modal.d.ts.map +1 -0
  19. package/dist/components/integration-delete-confirm-modal.js +61 -0
  20. package/dist/components/integration-delete-confirm-modal.js.map +1 -0
  21. package/dist/components/integration-gallery-for-workspace.d.ts +9 -0
  22. package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -0
  23. package/dist/components/integration-gallery-for-workspace.js +169 -0
  24. package/dist/components/integration-gallery-for-workspace.js.map +1 -0
  25. package/dist/components/integration-gallery.d.ts +8 -0
  26. package/dist/components/integration-gallery.d.ts.map +1 -0
  27. package/dist/components/integration-gallery.js +26 -0
  28. package/dist/components/integration-gallery.js.map +1 -0
  29. package/dist/components/integration-gallery.stories.d.ts +26 -0
  30. package/dist/components/integration-gallery.stories.d.ts.map +1 -0
  31. package/dist/components/integration-gallery.stories.js +335 -0
  32. package/dist/components/integration-gallery.stories.js.map +1 -0
  33. package/dist/components/integration-usage-events.d.ts +4 -0
  34. package/dist/components/integration-usage-events.d.ts.map +1 -0
  35. package/dist/components/integration-usage-events.js +127 -0
  36. package/dist/components/integration-usage-events.js.map +1 -0
  37. package/dist/components/integration-usage-modal.d.ts +16 -0
  38. package/dist/components/integration-usage-modal.d.ts.map +1 -0
  39. package/dist/components/integration-usage-modal.js +184 -0
  40. package/dist/components/integration-usage-modal.js.map +1 -0
  41. package/dist/components/provider-grid.d.ts +14 -0
  42. package/dist/components/provider-grid.d.ts.map +1 -0
  43. package/dist/components/provider-grid.js +152 -0
  44. package/dist/components/provider-grid.js.map +1 -0
  45. package/dist/components/redirect-install-page.d.ts +19 -0
  46. package/dist/components/redirect-install-page.d.ts.map +1 -0
  47. package/dist/components/redirect-install-page.js +68 -0
  48. package/dist/components/redirect-install-page.js.map +1 -0
  49. package/dist/components/repository-picker.d.ts +15 -0
  50. package/dist/components/repository-picker.d.ts.map +1 -0
  51. package/dist/components/repository-picker.js +78 -0
  52. package/dist/components/repository-picker.js.map +1 -0
  53. package/dist/components/webhook/copyable-value.d.ts +10 -0
  54. package/dist/components/webhook/copyable-value.d.ts.map +1 -0
  55. package/dist/components/webhook/copyable-value.js +65 -0
  56. package/dist/components/webhook/copyable-value.js.map +1 -0
  57. package/dist/components/webhook/webhook-create-modal.d.ts +10 -0
  58. package/dist/components/webhook/webhook-create-modal.d.ts.map +1 -0
  59. package/dist/components/webhook/webhook-create-modal.js +233 -0
  60. package/dist/components/webhook/webhook-create-modal.js.map +1 -0
  61. package/dist/components/webhook/webhook-form-errors.d.ts +11 -0
  62. package/dist/components/webhook/webhook-form-errors.d.ts.map +1 -0
  63. package/dist/components/webhook/webhook-form-errors.js +27 -0
  64. package/dist/components/webhook/webhook-form-errors.js.map +1 -0
  65. package/dist/components/webhook/webhook-modals.stories.d.ts +25 -0
  66. package/dist/components/webhook/webhook-modals.stories.d.ts.map +1 -0
  67. package/dist/components/webhook/webhook-modals.stories.js +222 -0
  68. package/dist/components/webhook/webhook-modals.stories.js.map +1 -0
  69. package/dist/components/webhook/webhook-public-endpoint-alert.d.ts +2 -0
  70. package/dist/components/webhook/webhook-public-endpoint-alert.d.ts.map +1 -0
  71. package/dist/components/webhook/webhook-public-endpoint-alert.js +20 -0
  72. package/dist/components/webhook/webhook-public-endpoint-alert.js.map +1 -0
  73. package/dist/components/webhook/webhook-usage-details.d.ts +7 -0
  74. package/dist/components/webhook/webhook-usage-details.d.ts.map +1 -0
  75. package/dist/components/webhook/webhook-usage-details.js +49 -0
  76. package/dist/components/webhook/webhook-usage-details.js.map +1 -0
  77. package/dist/connection-status-badge.d.ts +12 -0
  78. package/dist/connection-status-badge.d.ts.map +1 -0
  79. package/dist/connection-status-badge.js +34 -0
  80. package/dist/connection-status-badge.js.map +1 -0
  81. package/dist/feature.d.ts +37 -0
  82. package/dist/feature.d.ts.map +1 -0
  83. package/dist/feature.js +48 -0
  84. package/dist/feature.js.map +1 -0
  85. package/dist/hooks/api/integrations.d.ts +265 -0
  86. package/dist/hooks/api/integrations.d.ts.map +1 -0
  87. package/dist/hooks/api/integrations.js +229 -0
  88. package/dist/hooks/api/integrations.js.map +1 -0
  89. package/dist/hooks/api/webhook-connections.d.ts +91 -0
  90. package/dist/hooks/api/webhook-connections.d.ts.map +1 -0
  91. package/dist/hooks/api/webhook-connections.js +97 -0
  92. package/dist/hooks/api/webhook-connections.js.map +1 -0
  93. package/dist/index.d.ts +17 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +18 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/integration-icon.d.ts +17 -0
  98. package/dist/integration-icon.d.ts.map +1 -0
  99. package/dist/integration-icon.js +22 -0
  100. package/dist/integration-icon.js.map +1 -0
  101. package/dist/linear-callback.d.ts +17 -0
  102. package/dist/linear-callback.d.ts.map +1 -0
  103. package/dist/linear-callback.js +113 -0
  104. package/dist/linear-callback.js.map +1 -0
  105. package/dist/pages/gitea-form-errors.d.ts +11 -0
  106. package/dist/pages/gitea-form-errors.d.ts.map +1 -0
  107. package/dist/pages/gitea-form-errors.js +35 -0
  108. package/dist/pages/gitea-form-errors.js.map +1 -0
  109. package/dist/pages/gitea-install-page.d.ts +2 -0
  110. package/dist/pages/gitea-install-page.d.ts.map +1 -0
  111. package/dist/pages/gitea-install-page.js +139 -0
  112. package/dist/pages/gitea-install-page.js.map +1 -0
  113. package/dist/pages/github-install-page.d.ts +2 -0
  114. package/dist/pages/github-install-page.d.ts.map +1 -0
  115. package/dist/pages/github-install-page.js +11 -0
  116. package/dist/pages/github-install-page.js.map +1 -0
  117. package/dist/pages/linear-callback-page.d.ts +2 -0
  118. package/dist/pages/linear-callback-page.d.ts.map +1 -0
  119. package/dist/pages/linear-callback-page.js +192 -0
  120. package/dist/pages/linear-callback-page.js.map +1 -0
  121. package/dist/pages/linear-install-page.d.ts +2 -0
  122. package/dist/pages/linear-install-page.d.ts.map +1 -0
  123. package/dist/pages/linear-install-page.js +24 -0
  124. package/dist/pages/linear-install-page.js.map +1 -0
  125. package/dist/pages/sentry-callback-page.d.ts +2 -0
  126. package/dist/pages/sentry-callback-page.d.ts.map +1 -0
  127. package/dist/pages/sentry-callback-page.js +256 -0
  128. package/dist/pages/sentry-callback-page.js.map +1 -0
  129. package/dist/pages/sentry-install-page.d.ts +2 -0
  130. package/dist/pages/sentry-install-page.d.ts.map +1 -0
  131. package/dist/pages/sentry-install-page.js +17 -0
  132. package/dist/pages/sentry-install-page.js.map +1 -0
  133. package/dist/pages/source-control-onboarding-page.d.ts +2 -0
  134. package/dist/pages/source-control-onboarding-page.d.ts.map +1 -0
  135. package/dist/pages/source-control-onboarding-page.js +37 -0
  136. package/dist/pages/source-control-onboarding-page.js.map +1 -0
  137. package/dist/provider-catalog.d.ts +15 -0
  138. package/dist/provider-catalog.d.ts.map +1 -0
  139. package/dist/provider-catalog.js +28 -0
  140. package/dist/provider-catalog.js.map +1 -0
  141. package/dist/routes/gitea.d.ts +6 -0
  142. package/dist/routes/gitea.d.ts.map +1 -0
  143. package/dist/routes/gitea.js +7 -0
  144. package/dist/routes/gitea.js.map +1 -0
  145. package/dist/routes/github-callback.d.ts +6 -0
  146. package/dist/routes/github-callback.d.ts.map +1 -0
  147. package/dist/routes/github-callback.js +129 -0
  148. package/dist/routes/github-callback.js.map +1 -0
  149. package/dist/routes/github.d.ts +6 -0
  150. package/dist/routes/github.d.ts.map +1 -0
  151. package/dist/routes/github.js +7 -0
  152. package/dist/routes/github.js.map +1 -0
  153. package/dist/routes/integrations.d.ts +6 -0
  154. package/dist/routes/integrations.d.ts.map +1 -0
  155. package/dist/routes/integrations.js +7 -0
  156. package/dist/routes/integrations.js.map +1 -0
  157. package/dist/routes/linear-callback.d.ts +6 -0
  158. package/dist/routes/linear-callback.d.ts.map +1 -0
  159. package/dist/routes/linear-callback.js +7 -0
  160. package/dist/routes/linear-callback.js.map +1 -0
  161. package/dist/routes/linear.d.ts +6 -0
  162. package/dist/routes/linear.d.ts.map +1 -0
  163. package/dist/routes/linear.js +7 -0
  164. package/dist/routes/linear.js.map +1 -0
  165. package/dist/routes/sentry-callback.d.ts +6 -0
  166. package/dist/routes/sentry-callback.d.ts.map +1 -0
  167. package/dist/routes/sentry-callback.js +7 -0
  168. package/dist/routes/sentry-callback.js.map +1 -0
  169. package/dist/routes/sentry.d.ts +6 -0
  170. package/dist/routes/sentry.d.ts.map +1 -0
  171. package/dist/routes/sentry.js +7 -0
  172. package/dist/routes/sentry.js.map +1 -0
  173. package/dist/sentry-callback.d.ts +35 -0
  174. package/dist/sentry-callback.d.ts.map +1 -0
  175. package/dist/sentry-callback.js +132 -0
  176. package/dist/sentry-callback.js.map +1 -0
  177. package/dist/tsconfig.test.tsbuildinfo +1 -0
  178. package/package.json +120 -0
  179. package/src/components/connection-picker.tsx +89 -0
  180. package/src/components/installed-integrations-section.tsx +198 -0
  181. package/src/components/integration-delete-confirm-modal.tsx +62 -0
  182. package/src/components/integration-gallery-for-workspace.tsx +193 -0
  183. package/src/components/integration-gallery.stories.tsx +302 -0
  184. package/src/components/integration-gallery.test.tsx +692 -0
  185. package/src/components/integration-gallery.tsx +49 -0
  186. package/src/components/integration-usage-events.test.ts +80 -0
  187. package/src/components/integration-usage-events.ts +116 -0
  188. package/src/components/integration-usage-modal.tsx +178 -0
  189. package/src/components/provider-grid.tsx +163 -0
  190. package/src/components/redirect-install-page.test.tsx +98 -0
  191. package/src/components/redirect-install-page.tsx +68 -0
  192. package/src/components/repository-picker.tsx +102 -0
  193. package/src/components/webhook/copyable-value.test.tsx +41 -0
  194. package/src/components/webhook/copyable-value.tsx +72 -0
  195. package/src/components/webhook/webhook-create-modal.tsx +247 -0
  196. package/src/components/webhook/webhook-form-errors.test.ts +45 -0
  197. package/src/components/webhook/webhook-form-errors.ts +32 -0
  198. package/src/components/webhook/webhook-modals.stories.tsx +225 -0
  199. package/src/components/webhook/webhook-public-endpoint-alert.tsx +15 -0
  200. package/src/components/webhook/webhook-usage-details.tsx +44 -0
  201. package/src/connection-status-badge.tsx +38 -0
  202. package/src/feature.ts +47 -0
  203. package/src/hooks/api/integrations.test.ts +105 -0
  204. package/src/hooks/api/integrations.ts +287 -0
  205. package/src/hooks/api/webhook-connections.ts +115 -0
  206. package/src/index.ts +16 -0
  207. package/src/integration-icon.react.test.tsx +40 -0
  208. package/src/integration-icon.test.ts +19 -0
  209. package/src/integration-icon.tsx +26 -0
  210. package/src/linear-callback.test.ts +227 -0
  211. package/src/linear-callback.ts +136 -0
  212. package/src/pages/gitea-form-errors.test.ts +55 -0
  213. package/src/pages/gitea-form-errors.ts +36 -0
  214. package/src/pages/gitea-install-page.tsx +108 -0
  215. package/src/pages/github-install-page.tsx +11 -0
  216. package/src/pages/linear-callback-page.test.tsx +157 -0
  217. package/src/pages/linear-callback-page.tsx +175 -0
  218. package/src/pages/linear-install-page.tsx +26 -0
  219. package/src/pages/sentry-callback-page.test.tsx +205 -0
  220. package/src/pages/sentry-callback-page.tsx +238 -0
  221. package/src/pages/sentry-install-page.tsx +17 -0
  222. package/src/pages/source-control-onboarding-page.test.tsx +46 -0
  223. package/src/pages/source-control-onboarding-page.tsx +26 -0
  224. package/src/provider-catalog.test.ts +42 -0
  225. package/src/provider-catalog.ts +49 -0
  226. package/src/routes/gitea.tsx +4 -0
  227. package/src/routes/github-callback.tsx +114 -0
  228. package/src/routes/github.tsx +4 -0
  229. package/src/routes/integrations.tsx +4 -0
  230. package/src/routes/linear-callback.tsx +4 -0
  231. package/src/routes/linear.tsx +4 -0
  232. package/src/routes/sentry-callback.tsx +4 -0
  233. package/src/routes/sentry.tsx +4 -0
  234. package/src/sentry-callback.test.ts +239 -0
  235. package/src/sentry-callback.ts +142 -0
  236. package/test/render.tsx +111 -0
  237. package/test/setup.ts +3 -0
  238. package/tsconfig.build.json +9 -0
  239. package/tsconfig.build.tsbuildinfo +1 -0
  240. package/tsconfig.json +3 -0
  241. package/tsconfig.test.json +8 -0
  242. package/vercel.json +8 -0
  243. package/vitest.config.ts +75 -0
@@ -0,0 +1,68 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {useActiveWorkspace} from '@shipfox/client-auth';
3
+ import {ButtonLink} from '@shipfox/react-ui/button';
4
+ import {Callout} from '@shipfox/react-ui/callout';
5
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
6
+ import {Text} from '@shipfox/react-ui/typography';
7
+ import {Link} from '@tanstack/react-router';
8
+ import {useEffect, useRef, useState} from 'react';
9
+
10
+ interface RedirectInstallPageProps {
11
+ installRequest: (body: {workspace_id: string}) => Promise<{install_url: string}>;
12
+ errorFallbackMessage: string;
13
+ /**
14
+ * Runs before leaving the app (e.g. to persist the workspace id for a
15
+ * state-less provider callback). Must not throw — a failed side effect
16
+ * should never block the redirect.
17
+ */
18
+ beforeRedirect?: (workspaceId: string) => void;
19
+ /** Injectable for tests: jsdom's window.location cannot be stubbed. */
20
+ assignLocation?: (url: string) => void;
21
+ }
22
+
23
+ export function RedirectInstallPage({
24
+ installRequest,
25
+ errorFallbackMessage,
26
+ beforeRedirect,
27
+ assignLocation = (url) => window.location.assign(url),
28
+ }: RedirectInstallPageProps) {
29
+ const workspace = useActiveWorkspace();
30
+ const startedRef = useRef(false);
31
+ const [errorMessage, setErrorMessage] = useState<string | undefined>();
32
+
33
+ useEffect(() => {
34
+ if (startedRef.current) return;
35
+ startedRef.current = true;
36
+ // The side effect is best-effort persistence; a throw here must never
37
+ // block the install redirect, so swallow it and continue.
38
+ try {
39
+ beforeRedirect?.(workspace.id);
40
+ } catch {
41
+ // ignore
42
+ }
43
+ installRequest({workspace_id: workspace.id})
44
+ .then((response) => {
45
+ assignLocation(response.install_url);
46
+ })
47
+ .catch((error: unknown) => {
48
+ setErrorMessage(error instanceof ApiError ? error.message : errorFallbackMessage);
49
+ });
50
+ }, [workspace, installRequest, beforeRedirect, errorFallbackMessage, assignLocation]);
51
+
52
+ if (errorMessage) {
53
+ return (
54
+ <div className="mx-auto flex w-full max-w-[480px] flex-col gap-16">
55
+ <Callout role="alert" type="error">
56
+ <Text size="sm">{errorMessage}</Text>
57
+ </Callout>
58
+ <ButtonLink asChild variant="muted" className="w-fit">
59
+ <Link to="/workspaces/$wid/integrations" params={{wid: workspace.id}}>
60
+ Back to integrations
61
+ </Link>
62
+ </ButtonLink>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ return <FullPageLoader />;
68
+ }
@@ -0,0 +1,102 @@
1
+ import type {RepositoryDto} from '@shipfox/api-integration-core-dto';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Input} from '@shipfox/react-ui/input';
4
+ import {Label} from '@shipfox/react-ui/label';
5
+ import {RadioGroup, RadioGroupItem} from '@shipfox/react-ui/radio-group';
6
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
7
+ import {Text} from '@shipfox/react-ui/typography';
8
+ import {useId} from 'react';
9
+
10
+ export function RepositoryPicker({
11
+ repositories,
12
+ selectedRepositoryId,
13
+ onSelect,
14
+ isLoading,
15
+ isFetchingNextPage,
16
+ hasNextPage,
17
+ onLoadMore,
18
+ emptyMessage = 'No repositories found.',
19
+ searchValue,
20
+ onSearchChange,
21
+ searchDisabled,
22
+ }: {
23
+ repositories: RepositoryDto[];
24
+ selectedRepositoryId: string | undefined;
25
+ onSelect: (repositoryId: string) => void;
26
+ isLoading: boolean;
27
+ isFetchingNextPage?: boolean;
28
+ hasNextPage?: boolean;
29
+ onLoadMore?: () => void;
30
+ emptyMessage?: string;
31
+ searchValue?: string;
32
+ onSearchChange?: (value: string) => void;
33
+ searchDisabled?: boolean;
34
+ }) {
35
+ const labelId = useId();
36
+ const showSearch = onSearchChange !== undefined;
37
+
38
+ return (
39
+ <div className="flex flex-col gap-10">
40
+ <Label id={labelId} className="sr-only">
41
+ Repository
42
+ </Label>
43
+
44
+ {showSearch ? (
45
+ <Input
46
+ type="search"
47
+ placeholder="Search repositories…"
48
+ aria-label="Search repositories"
49
+ value={searchValue ?? ''}
50
+ onChange={(event) => onSearchChange?.(event.target.value)}
51
+ disabled={searchDisabled}
52
+ />
53
+ ) : null}
54
+
55
+ {isLoading ? <Skeleton className="h-58 w-full" /> : null}
56
+
57
+ {!isLoading && repositories.length === 0 ? (
58
+ <div className="rounded-8 border border-border-neutral-base bg-background-subtle-base p-14">
59
+ <Text size="sm">{emptyMessage}</Text>
60
+ </div>
61
+ ) : null}
62
+
63
+ {repositories.length > 0 ? (
64
+ <RadioGroup
65
+ aria-labelledby={labelId}
66
+ value={selectedRepositoryId ?? ''}
67
+ onValueChange={onSelect}
68
+ className="grid grid-cols-2 gap-8 min-[1200px]:grid-cols-3 max-[760px]:grid-cols-1"
69
+ >
70
+ {repositories.map((repository) => (
71
+ <RadioGroupItem
72
+ key={repository.external_repository_id}
73
+ value={repository.external_repository_id}
74
+ className="p-12"
75
+ >
76
+ <span className="flex min-w-0 items-center justify-between gap-10">
77
+ <Text as="span" size="sm" bold className="truncate">
78
+ {repository.full_name}
79
+ </Text>
80
+ <Text as="span" size="xs" className="shrink-0 text-foreground-neutral-muted">
81
+ {repository.default_branch}
82
+ </Text>
83
+ </span>
84
+ </RadioGroupItem>
85
+ ))}
86
+ </RadioGroup>
87
+ ) : null}
88
+
89
+ {hasNextPage && onLoadMore ? (
90
+ <Button
91
+ type="button"
92
+ variant="secondary"
93
+ size="sm"
94
+ isLoading={isFetchingNextPage ?? false}
95
+ onClick={onLoadMore}
96
+ >
97
+ Load more
98
+ </Button>
99
+ ) : null}
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,41 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import {toast} from '@shipfox/react-ui/toast';
4
+ import {fireEvent, render, screen, waitFor} from '@testing-library/react';
5
+ import {CopyableValue} from './copyable-value.js';
6
+
7
+ const WEBHOOK_URL = 'https://api.example.test/webhook/77777777-7777-4777-8777-777777777777';
8
+ const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ if (clipboardDescriptor) {
13
+ Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
14
+ } else {
15
+ Reflect.deleteProperty(navigator, 'clipboard');
16
+ }
17
+ });
18
+
19
+ describe('CopyableValue', () => {
20
+ test('keeps long values readable for manual selection', () => {
21
+ render(<CopyableValue label="inbound URL" value={WEBHOOK_URL} />);
22
+
23
+ const value = screen.getByText(WEBHOOK_URL);
24
+
25
+ expect(value).toHaveClass('break-all');
26
+ expect(value).not.toHaveClass('truncate');
27
+ });
28
+
29
+ test('shows an error toast when browser copy APIs fail', async () => {
30
+ Object.defineProperty(navigator, 'clipboard', {
31
+ configurable: true,
32
+ value: {writeText: vi.fn().mockRejectedValue(new Error('denied'))},
33
+ });
34
+ const errorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id');
35
+ render(<CopyableValue label="inbound URL" value={WEBHOOK_URL} />);
36
+
37
+ fireEvent.click(screen.getByRole('button', {name: 'Copy inbound URL'}));
38
+
39
+ await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('Could not copy.'));
40
+ });
41
+ });
@@ -0,0 +1,72 @@
1
+ import {Button} from '@shipfox/react-ui/button';
2
+ import {useCopyToClipboard} from '@shipfox/react-ui/hooks';
3
+ import {toast} from '@shipfox/react-ui/toast';
4
+ import {Code, Text} from '@shipfox/react-ui/typography';
5
+ import {cn} from '@shipfox/react-ui/utils';
6
+ import type {ReactNode} from 'react';
7
+ import {useEffect, useRef, useState} from 'react';
8
+
9
+ interface CopyableValueProps {
10
+ label: string;
11
+ value: string;
12
+ note?: ReactNode;
13
+ className?: string;
14
+ }
15
+
16
+ export function CopyableValue({label, value, note, className}: CopyableValueProps) {
17
+ const [copied, setCopied] = useState(false);
18
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
19
+ const {copy} = useCopyToClipboard({
20
+ text: value,
21
+ onCopy: () => {
22
+ setCopied(true);
23
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
24
+ timeoutRef.current = setTimeout(() => setCopied(false), 2000);
25
+ },
26
+ });
27
+
28
+ useEffect(() => {
29
+ return () => {
30
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
31
+ };
32
+ }, []);
33
+
34
+ async function handleCopy() {
35
+ try {
36
+ await copy();
37
+ } catch {
38
+ toast.error('Could not copy.');
39
+ }
40
+ }
41
+
42
+ return (
43
+ <div className={cn('flex w-full min-w-0 flex-col gap-8', className)}>
44
+ <div className="flex min-w-0 items-center gap-8">
45
+ <Code
46
+ as="span"
47
+ variant="paragraph"
48
+ className="min-w-0 flex-1 break-all rounded-4 border border-border-neutral-base bg-background-components-base px-8 py-6 text-foreground-neutral-base"
49
+ >
50
+ {value}
51
+ </Code>
52
+ <Button
53
+ type="button"
54
+ variant="secondary"
55
+ size="sm"
56
+ iconLeft={copied ? 'check' : 'copy'}
57
+ aria-label={`Copy ${label}`}
58
+ onClick={() => {
59
+ void handleCopy();
60
+ }}
61
+ >
62
+ Copy
63
+ </Button>
64
+ </div>
65
+ {note ? (
66
+ <Text size="sm" className="text-foreground-neutral-muted">
67
+ {note}
68
+ </Text>
69
+ ) : null}
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,247 @@
1
+ import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
+ import {
3
+ createWebhookConnectionBodySchema,
4
+ WEBHOOK_RESERVED_SLUGS,
5
+ type WebhookConnectionDto,
6
+ webhookSlugSchema,
7
+ } from '@shipfox/api-integration-webhook-dto';
8
+ import {displayNameFieldError} from '@shipfox/client-ui';
9
+ import {Button} from '@shipfox/react-ui/button';
10
+ import {Callout} from '@shipfox/react-ui/callout';
11
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
12
+ import {
13
+ Modal,
14
+ ModalBody,
15
+ ModalContent,
16
+ ModalFooter,
17
+ ModalHeader,
18
+ ModalTitle,
19
+ } from '@shipfox/react-ui/modal';
20
+ import {toast} from '@shipfox/react-ui/toast';
21
+ import {Code, Text} from '@shipfox/react-ui/typography';
22
+ import {useForm} from '@tanstack/react-form';
23
+ import {useEffect, useRef, useState} from 'react';
24
+ import {useCreateWebhookConnectionMutation} from '#hooks/api/webhook-connections.js';
25
+ import {webhookCreateErrorToFormError} from './webhook-form-errors.js';
26
+
27
+ interface WebhookCreateModalProps {
28
+ workspaceId: string;
29
+ open: boolean;
30
+ onOpenChange: (open: boolean) => void;
31
+ onCreated: (connection: IntegrationConnectionDto) => void;
32
+ }
33
+
34
+ export function WebhookCreateModal({
35
+ workspaceId,
36
+ open,
37
+ onOpenChange,
38
+ onCreated,
39
+ }: WebhookCreateModalProps) {
40
+ const createWebhook = useCreateWebhookConnectionMutation();
41
+ const [formError, setFormError] = useState<string | undefined>();
42
+ const [hasManualSlug, setHasManualSlug] = useState(false);
43
+ const nameInputRef = useRef<HTMLInputElement | null>(null);
44
+
45
+ const form = useForm({
46
+ defaultValues: {name: '', slug: ''},
47
+ onSubmit: async ({value}) => {
48
+ setFormError(undefined);
49
+ try {
50
+ const body = createWebhookConnectionBodySchema.parse({
51
+ workspace_id: workspaceId,
52
+ name: value.name.trim(),
53
+ slug: value.slug.trim(),
54
+ });
55
+ const connection = await createWebhook.mutateAsync(body);
56
+ onCreated(toIntegrationConnection(connection));
57
+ toast.success('Webhook created.');
58
+ onOpenChange(false);
59
+ } catch (error) {
60
+ const mapped = webhookCreateErrorToFormError(error);
61
+ if (mapped.kind === 'field') {
62
+ form.setFieldMeta(mapped.field, (prev) => ({
63
+ ...prev,
64
+ errorMap: {...prev.errorMap, onServer: mapped.message},
65
+ }));
66
+ } else {
67
+ setFormError(mapped.message);
68
+ }
69
+ }
70
+ },
71
+ });
72
+
73
+ useEffect(() => {
74
+ if (!open) {
75
+ setFormError(undefined);
76
+ setHasManualSlug(false);
77
+ form.reset();
78
+ return;
79
+ }
80
+ requestAnimationFrame(() => nameInputRef.current?.focus());
81
+ }, [form, open]);
82
+
83
+ const handleOpenChange = (nextOpen: boolean) => {
84
+ if (createWebhook.isPending && !nextOpen) return;
85
+ onOpenChange(nextOpen);
86
+ };
87
+
88
+ return (
89
+ <Modal open={open} onOpenChange={handleOpenChange}>
90
+ <ModalContent aria-describedby={undefined}>
91
+ <ModalTitle className="sr-only">Add webhook</ModalTitle>
92
+ <ModalHeader title="Add webhook" showClose={!createWebhook.isPending} />
93
+ <form
94
+ noValidate
95
+ onSubmit={(event) => {
96
+ event.preventDefault();
97
+ event.stopPropagation();
98
+ void form.handleSubmit();
99
+ }}
100
+ >
101
+ <ModalBody className="gap-16">
102
+ <Text size="sm" className="text-foreground-neutral-muted">
103
+ Create a named inbound webhook URL for this workspace.
104
+ </Text>
105
+ {formError ? (
106
+ <Callout role="alert" type="error">
107
+ {formError}
108
+ </Callout>
109
+ ) : null}
110
+ <form.Field
111
+ name="name"
112
+ validators={{
113
+ onBlur: ({value}) => webhookNameFieldError(value),
114
+ onSubmit: ({value}) => webhookNameFieldError(value),
115
+ }}
116
+ >
117
+ {(field) => (
118
+ <FormField
119
+ label="Name"
120
+ id="webhook-name"
121
+ error={fieldError(field)}
122
+ className="w-full"
123
+ >
124
+ <FormFieldInput
125
+ ref={nameInputRef}
126
+ name="name"
127
+ value={field.state.value}
128
+ onChange={(event) => {
129
+ const nextName = event.target.value;
130
+ field.handleChange(nextName);
131
+ if (!hasManualSlug) {
132
+ form.setFieldValue(
133
+ 'slug',
134
+ nextName.trim() ? suggestWebhookSlug(nextName) : '',
135
+ );
136
+ }
137
+ }}
138
+ onBlur={field.handleBlur}
139
+ placeholder="Stripe production"
140
+ disabled={createWebhook.isPending}
141
+ />
142
+ </FormField>
143
+ )}
144
+ </form.Field>
145
+ <form.Field
146
+ name="slug"
147
+ validators={{
148
+ onBlur: ({value}) => webhookSlugFieldError(value),
149
+ onSubmit: ({value}) => webhookSlugFieldError(value),
150
+ }}
151
+ >
152
+ {(field) => (
153
+ <FormField
154
+ label="Slug"
155
+ id="webhook-slug"
156
+ error={fieldError(field)}
157
+ className="w-full"
158
+ >
159
+ <FormFieldInput
160
+ name="slug"
161
+ value={field.state.value}
162
+ onChange={(event) => {
163
+ const nextSlug = event.target.value;
164
+ setHasManualSlug(nextSlug.trim().length > 0);
165
+ field.handleChange(nextSlug);
166
+ if (nextSlug.trim().length === 0 && form.state.values.name.trim()) {
167
+ form.setFieldValue('slug', suggestWebhookSlug(form.state.values.name));
168
+ }
169
+ }}
170
+ onBlur={field.handleBlur}
171
+ placeholder="webhook-stripe-production"
172
+ disabled={createWebhook.isPending}
173
+ />
174
+ <Text size="sm" className="text-foreground-neutral-muted">
175
+ Reference in workflows with{' '}
176
+ <Code as="span">
177
+ source: {field.state.value || 'webhook-stripe-production'}
178
+ </Code>
179
+ .
180
+ </Text>
181
+ </FormField>
182
+ )}
183
+ </form.Field>
184
+ </ModalBody>
185
+ <ModalFooter>
186
+ <Button
187
+ type="button"
188
+ variant="secondary"
189
+ disabled={createWebhook.isPending}
190
+ onClick={() => onOpenChange(false)}
191
+ >
192
+ Cancel
193
+ </Button>
194
+ <Button type="submit" isLoading={createWebhook.isPending}>
195
+ Create
196
+ </Button>
197
+ </ModalFooter>
198
+ </form>
199
+ </ModalContent>
200
+ </Modal>
201
+ );
202
+ }
203
+
204
+ function webhookNameFieldError(value: string): string | undefined {
205
+ return displayNameFieldError(
206
+ value.trim(),
207
+ 'Webhook name',
208
+ createWebhookConnectionBodySchema.shape.name,
209
+ );
210
+ }
211
+
212
+ function webhookSlugFieldError(value: string): string | undefined {
213
+ const trimmed = value.trim();
214
+ if (trimmed.length === 0) return 'Slug is required.';
215
+ if (WEBHOOK_RESERVED_SLUGS.includes(trimmed as (typeof WEBHOOK_RESERVED_SLUGS)[number])) {
216
+ return 'That slug is reserved.';
217
+ }
218
+ if (webhookSlugSchema.safeParse(trimmed).success) return undefined;
219
+ return 'Use lowercase letters, numbers, hyphens, or underscores.';
220
+ }
221
+
222
+ function suggestWebhookSlug(name: string): string {
223
+ const prefix = 'webhook';
224
+ const suffix = name
225
+ .toLowerCase()
226
+ .replaceAll(/[^a-z0-9]+/g, '-')
227
+ .replaceAll(/^-+|-+$/g, '')
228
+ .slice(0, 100 - prefix.length - 1)
229
+ .replaceAll(/-+$/g, '');
230
+
231
+ return suffix ? `${prefix}-${suffix}` : prefix;
232
+ }
233
+
234
+ function toIntegrationConnection(connection: WebhookConnectionDto): IntegrationConnectionDto {
235
+ return {
236
+ id: connection.id,
237
+ workspace_id: connection.workspace_id,
238
+ provider: 'webhook',
239
+ external_account_id: connection.slug,
240
+ slug: connection.slug,
241
+ display_name: connection.name,
242
+ lifecycle_status: connection.lifecycle_status,
243
+ capabilities: [],
244
+ created_at: connection.created_at,
245
+ updated_at: connection.updated_at,
246
+ };
247
+ }
@@ -0,0 +1,45 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {webhookCreateErrorToFormError} from './webhook-form-errors.js';
3
+
4
+ function apiError(code: string, status = 400, message = `${code} server message`) {
5
+ return new ApiError({code, message, status});
6
+ }
7
+
8
+ describe('webhookCreateErrorToFormError', () => {
9
+ test('routes slug-already-exists to the slug field', () => {
10
+ const result = webhookCreateErrorToFormError(apiError('slug-already-exists', 409));
11
+
12
+ expect(result).toEqual({
13
+ kind: 'field',
14
+ field: 'slug',
15
+ message: 'A webhook with this slug already exists.',
16
+ });
17
+ });
18
+
19
+ test('routes provider errors to a form-level alert with the server message', () => {
20
+ const result = webhookCreateErrorToFormError(apiError('validation-failed', 400));
21
+
22
+ expect(result).toEqual({kind: 'form', message: 'validation-failed server message'});
23
+ });
24
+
25
+ test('hides the raw URL in a network-error behind a friendly form-level message', () => {
26
+ const result = webhookCreateErrorToFormError(apiError('network-error', 0));
27
+
28
+ expect(result).toEqual({
29
+ kind: 'form',
30
+ message: "We couldn't reach the server. Check your connection and try again.",
31
+ });
32
+ });
33
+
34
+ test('routes a non-ApiError to a generic form-level alert', () => {
35
+ const result = webhookCreateErrorToFormError(new Error('boom'));
36
+
37
+ expect(result).toEqual({kind: 'form', message: 'boom'});
38
+ });
39
+
40
+ test('falls back for a non-Error value', () => {
41
+ const result = webhookCreateErrorToFormError('nope');
42
+
43
+ expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
44
+ });
45
+ });
@@ -0,0 +1,32 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export type WebhookCreateField = 'slug';
4
+
5
+ export type WebhookCreateFormErrorMapping =
6
+ | {kind: 'field'; field: WebhookCreateField; message: string}
7
+ | {kind: 'form'; message: string};
8
+
9
+ export function webhookCreateErrorToFormError(error: unknown): WebhookCreateFormErrorMapping {
10
+ const code = error instanceof ApiError ? error.code : undefined;
11
+
12
+ if (code === 'slug-already-exists') {
13
+ return {
14
+ kind: 'field',
15
+ field: 'slug',
16
+ message: 'A webhook with this slug already exists.',
17
+ };
18
+ }
19
+
20
+ return {kind: 'form', message: webhookCreateErrorMessage(error)};
21
+ }
22
+
23
+ function webhookCreateErrorMessage(error: unknown): string {
24
+ if (error instanceof ApiError) {
25
+ if (error.code === 'network-error') {
26
+ return "We couldn't reach the server. Check your connection and try again.";
27
+ }
28
+ return error.message;
29
+ }
30
+ if (error instanceof Error) return error.message;
31
+ return 'Something went wrong. Try again.';
32
+ }