@shipfox/client-integrations 5.0.0 → 6.0.2
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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +84 -0
- package/dist/application/complete-integration-callback.d.ts +13 -0
- package/dist/application/complete-integration-callback.d.ts.map +1 -0
- package/dist/application/complete-integration-callback.js +26 -0
- package/dist/application/complete-integration-callback.js.map +1 -0
- package/dist/components/connection-picker.d.ts +2 -2
- package/dist/components/connection-picker.d.ts.map +1 -1
- package/dist/components/connection-picker.js +4 -4
- package/dist/components/connection-picker.js.map +1 -1
- package/dist/components/installed-integrations-section.d.ts +8 -6
- package/dist/components/installed-integrations-section.d.ts.map +1 -1
- package/dist/components/installed-integrations-section.js +22 -15
- package/dist/components/installed-integrations-section.js.map +1 -1
- package/dist/components/integration-gallery-for-workspace.d.ts +2 -2
- package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
- package/dist/components/integration-gallery-for-workspace.js +14 -7
- package/dist/components/integration-gallery-for-workspace.js.map +1 -1
- package/dist/components/integration-gallery.d.ts +2 -2
- package/dist/components/integration-gallery.d.ts.map +1 -1
- package/dist/components/integration-gallery.js.map +1 -1
- package/dist/components/integration-usage-events.d.ts +1 -3
- package/dist/components/integration-usage-events.d.ts.map +1 -1
- package/dist/components/integration-usage-events.js +1 -125
- package/dist/components/integration-usage-events.js.map +1 -1
- package/dist/components/integration-usage-modal.d.ts +2 -2
- package/dist/components/integration-usage-modal.d.ts.map +1 -1
- package/dist/components/integration-usage-modal.js +2 -2
- package/dist/components/integration-usage-modal.js.map +1 -1
- package/dist/components/provider-grid.d.ts +7 -4
- package/dist/components/provider-grid.d.ts.map +1 -1
- package/dist/components/provider-grid.js +15 -10
- package/dist/components/provider-grid.js.map +1 -1
- package/dist/components/redirect-install-page.d.ts +2 -3
- package/dist/components/redirect-install-page.d.ts.map +1 -1
- package/dist/components/redirect-install-page.js +1 -1
- package/dist/components/redirect-install-page.js.map +1 -1
- package/dist/components/repository-picker.d.ts +2 -2
- package/dist/components/repository-picker.d.ts.map +1 -1
- package/dist/components/repository-picker.js +4 -4
- package/dist/components/repository-picker.js.map +1 -1
- package/dist/components/webhook/webhook-create-modal.d.ts +2 -2
- package/dist/components/webhook/webhook-create-modal.d.ts.map +1 -1
- package/dist/components/webhook/webhook-create-modal.js +8 -8
- package/dist/components/webhook/webhook-create-modal.js.map +1 -1
- package/dist/components/webhook/webhook-modals.stories.d.ts.map +1 -1
- package/dist/components/webhook/webhook-modals.stories.js +7 -7
- package/dist/components/webhook/webhook-modals.stories.js.map +1 -1
- package/dist/components/webhook/webhook-usage-details.d.ts.map +1 -1
- package/dist/components/webhook/webhook-usage-details.js +2 -2
- package/dist/components/webhook/webhook-usage-details.js.map +1 -1
- package/dist/connection-status-badge.d.ts +2 -2
- package/dist/connection-status-badge.d.ts.map +1 -1
- package/dist/connection-status-badge.js +11 -15
- package/dist/connection-status-badge.js.map +1 -1
- package/dist/core/models.d.ts +66 -0
- package/dist/core/models.d.ts.map +1 -0
- package/dist/core/models.js +156 -0
- package/dist/core/models.js.map +1 -0
- package/dist/feature.d.ts +7 -0
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +10 -9
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/integration-mapper.d.ts +11 -0
- package/dist/hooks/api/integration-mapper.d.ts.map +1 -0
- package/dist/hooks/api/integration-mapper.js +54 -0
- package/dist/hooks/api/integration-mapper.js.map +1 -0
- package/dist/hooks/api/integrations.d.ts +45 -240
- package/dist/hooks/api/integrations.d.ts.map +1 -1
- package/dist/hooks/api/integrations.js +78 -31
- package/dist/hooks/api/integrations.js.map +1 -1
- package/dist/hooks/api/webhook-connections.d.ts +12 -64
- package/dist/hooks/api/webhook-connections.d.ts.map +1 -1
- package/dist/hooks/api/webhook-connections.js +17 -9
- package/dist/hooks/api/webhook-connections.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/linear-callback.d.ts +2 -1
- package/dist/linear-callback.d.ts.map +1 -1
- package/dist/linear-callback.js +17 -15
- package/dist/linear-callback.js.map +1 -1
- package/dist/pages/gitea-install-page.d.ts.map +1 -1
- package/dist/pages/gitea-install-page.js +1 -10
- package/dist/pages/gitea-install-page.js.map +1 -1
- package/dist/pages/linear-callback-page.d.ts.map +1 -1
- package/dist/pages/linear-callback-page.js +19 -32
- package/dist/pages/linear-callback-page.js.map +1 -1
- package/dist/pages/linear-install-page.d.ts.map +1 -1
- package/dist/pages/linear-install-page.js +2 -1
- package/dist/pages/linear-install-page.js.map +1 -1
- package/dist/pages/sentry-callback-page.d.ts.map +1 -1
- package/dist/pages/sentry-callback-page.js +22 -25
- package/dist/pages/sentry-callback-page.js.map +1 -1
- package/dist/pages/sentry-install-page.d.ts.map +1 -1
- package/dist/pages/sentry-install-page.js +2 -1
- package/dist/pages/sentry-install-page.js.map +1 -1
- package/dist/pages/slack-callback-page.d.ts.map +1 -1
- package/dist/pages/slack-callback-page.js +21 -28
- package/dist/pages/slack-callback-page.js.map +1 -1
- package/dist/pages/slack-install-page.d.ts.map +1 -1
- package/dist/pages/slack-install-page.js +2 -1
- package/dist/pages/slack-install-page.js.map +1 -1
- package/dist/pages/source-control-onboarding-page.d.ts.map +1 -1
- package/dist/pages/source-control-onboarding-page.js +5 -1
- package/dist/pages/source-control-onboarding-page.js.map +1 -1
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +7 -6
- package/dist/provider-catalog.js.map +1 -1
- package/dist/routes/github-callback.d.ts +16 -0
- package/dist/routes/github-callback.d.ts.map +1 -1
- package/dist/routes/github-callback.js +43 -21
- package/dist/routes/github-callback.js.map +1 -1
- package/dist/routes/linear-callback.d.ts +2 -0
- package/dist/routes/linear-callback.d.ts.map +1 -1
- package/dist/routes/linear-callback.js +2 -0
- package/dist/routes/linear-callback.js.map +1 -1
- package/dist/routes/sentry-callback.d.ts +2 -0
- package/dist/routes/sentry-callback.d.ts.map +1 -1
- package/dist/routes/sentry-callback.js +2 -0
- package/dist/routes/sentry-callback.js.map +1 -1
- package/dist/routes/slack-callback.d.ts +2 -0
- package/dist/routes/slack-callback.d.ts.map +1 -1
- package/dist/routes/slack-callback.js +2 -0
- package/dist/routes/slack-callback.js.map +1 -1
- package/dist/sentry-callback.d.ts +2 -1
- package/dist/sentry-callback.d.ts.map +1 -1
- package/dist/sentry-callback.js +18 -19
- package/dist/sentry-callback.js.map +1 -1
- package/dist/slack-callback.d.ts +2 -1
- package/dist/slack-callback.d.ts.map +1 -1
- package/dist/slack-callback.js +17 -15
- package/dist/slack-callback.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/application/complete-integration-callback.test.ts +41 -0
- package/src/application/complete-integration-callback.ts +39 -0
- package/src/components/connection-picker.tsx +7 -7
- package/src/components/installed-integrations-section.tsx +29 -24
- package/src/components/integration-gallery-for-workspace.tsx +23 -16
- package/src/components/integration-gallery.tsx +3 -3
- package/src/components/integration-usage-events.ts +1 -116
- package/src/components/integration-usage-modal.tsx +4 -4
- package/src/components/provider-grid.tsx +30 -18
- package/src/components/redirect-install-page.test.tsx +2 -6
- package/src/components/redirect-install-page.tsx +3 -2
- package/src/components/repository-picker.tsx +6 -6
- package/src/components/webhook/webhook-create-modal.tsx +12 -11
- package/src/components/webhook/webhook-modals.stories.tsx +10 -10
- package/src/components/webhook/webhook-usage-details.tsx +2 -4
- package/src/connection-status-badge.tsx +8 -14
- package/src/core/models.ts +200 -0
- package/src/feature.ts +12 -10
- package/src/hooks/api/integration-mapper.ts +64 -0
- package/src/hooks/api/integrations.test.ts +44 -22
- package/src/hooks/api/integrations.ts +229 -84
- package/src/hooks/api/webhook-connections.ts +58 -17
- package/src/index.ts +2 -1
- package/src/linear-callback.ts +24 -16
- package/src/pages/gitea-install-page.tsx +1 -10
- package/src/pages/linear-callback-page.tsx +18 -30
- package/src/pages/linear-install-page.tsx +2 -1
- package/src/pages/sentry-callback-page.test.tsx +5 -1
- package/src/pages/sentry-callback-page.tsx +28 -25
- package/src/pages/sentry-install-page.tsx +2 -1
- package/src/pages/slack-callback-page.tsx +20 -26
- package/src/pages/slack-install-page.tsx +2 -1
- package/src/pages/source-control-onboarding-page.tsx +5 -1
- package/src/provider-catalog.ts +7 -6
- package/src/routes/github-callback.test.ts +35 -1
- package/src/routes/github-callback.tsx +47 -21
- package/src/routes/linear-callback.tsx +5 -1
- package/src/routes/sentry-callback.tsx +5 -1
- package/src/routes/slack-callback.tsx +6 -1
- package/src/sentry-callback.test.ts +2 -1
- package/src/sentry-callback.ts +30 -20
- package/src/slack-callback.ts +24 -16
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vercel.json +5 -0
- package/dist/integration-icon.d.ts +0 -17
- package/dist/integration-icon.d.ts.map +0 -1
- package/dist/integration-icon.js +0 -22
- package/dist/integration-icon.js.map +0 -1
- package/src/integration-icon.react.test.tsx +0 -40
- package/src/integration-icon.test.ts +0 -19
- package/src/integration-icon.tsx +0 -26
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CreateWebhookConnectionBodyDto,
|
|
3
|
-
ListWebhookConnectionsResponseDto,
|
|
4
3
|
UpdateWebhookConnectionBodyDto,
|
|
5
|
-
WebhookConnectionDto,
|
|
6
4
|
} from '@shipfox/api-integration-webhook-dto';
|
|
7
|
-
import {
|
|
8
|
-
|
|
5
|
+
import {
|
|
6
|
+
listWebhookConnectionsResponseSchema,
|
|
7
|
+
webhookConnectionDtoSchema,
|
|
8
|
+
} from '@shipfox/api-integration-webhook-dto';
|
|
9
|
+
import {checkedApiRequest, emptyResponseSchema} from '@shipfox/client-api';
|
|
10
|
+
import {
|
|
11
|
+
type FetchQueryOptions,
|
|
12
|
+
type QueryClient,
|
|
13
|
+
queryOptions,
|
|
14
|
+
useMutation,
|
|
15
|
+
useQuery,
|
|
16
|
+
useQueryClient,
|
|
17
|
+
} from '@tanstack/react-query';
|
|
18
|
+
import type {WebhookConnection} from '#core/models.js';
|
|
19
|
+
import {toWebhookConnection} from './integration-mapper.js';
|
|
9
20
|
import {integrationsQueryKeys} from './integrations.js';
|
|
10
21
|
|
|
11
22
|
export const webhookConnectionsQueryKeys = {
|
|
@@ -13,6 +24,17 @@ export const webhookConnectionsQueryKeys = {
|
|
|
13
24
|
list: (workspaceId: string) => [...webhookConnectionsQueryKeys.all, 'list', workspaceId] as const,
|
|
14
25
|
};
|
|
15
26
|
|
|
27
|
+
type WebhookConnectionsListQueryKey =
|
|
28
|
+
| ReturnType<typeof webhookConnectionsQueryKeys.list>
|
|
29
|
+
| readonly ['webhook-connections', 'list'];
|
|
30
|
+
|
|
31
|
+
type WebhookConnectionsListQueryOptions = FetchQueryOptions<
|
|
32
|
+
WebhookConnection[],
|
|
33
|
+
Error,
|
|
34
|
+
WebhookConnection[],
|
|
35
|
+
WebhookConnectionsListQueryKey
|
|
36
|
+
>;
|
|
37
|
+
|
|
16
38
|
export async function listWebhookConnections({
|
|
17
39
|
workspaceId,
|
|
18
40
|
signal,
|
|
@@ -21,17 +43,24 @@ export async function listWebhookConnections({
|
|
|
21
43
|
signal?: AbortSignal;
|
|
22
44
|
}) {
|
|
23
45
|
const search = new URLSearchParams({workspace_id: workspaceId});
|
|
24
|
-
|
|
46
|
+
const response = await checkedApiRequest(
|
|
47
|
+
listWebhookConnectionsResponseSchema,
|
|
25
48
|
`/integrations/webhook/connections?${search.toString()}`,
|
|
26
49
|
{signal},
|
|
27
50
|
);
|
|
51
|
+
return response.connections.map(toWebhookConnection);
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
export async function createWebhookConnection(body: CreateWebhookConnectionBodyDto) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
const response = await checkedApiRequest(
|
|
56
|
+
webhookConnectionDtoSchema,
|
|
57
|
+
'/integrations/webhook/connections',
|
|
58
|
+
{
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body,
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
return toWebhookConnection(response);
|
|
35
64
|
}
|
|
36
65
|
|
|
37
66
|
export async function updateWebhookConnection({
|
|
@@ -41,23 +70,35 @@ export async function updateWebhookConnection({
|
|
|
41
70
|
connectionId: string;
|
|
42
71
|
body: UpdateWebhookConnectionBodyDto;
|
|
43
72
|
}) {
|
|
44
|
-
|
|
73
|
+
const response = await checkedApiRequest(
|
|
74
|
+
webhookConnectionDtoSchema,
|
|
45
75
|
`/integrations/webhook/connections/${encodeURIComponent(connectionId)}`,
|
|
46
76
|
{method: 'PATCH', body},
|
|
47
77
|
);
|
|
78
|
+
return toWebhookConnection(response);
|
|
48
79
|
}
|
|
49
80
|
|
|
50
81
|
export async function deleteWebhookConnection({connectionId}: {connectionId: string}) {
|
|
51
|
-
await
|
|
52
|
-
|
|
53
|
-
|
|
82
|
+
await checkedApiRequest(
|
|
83
|
+
emptyResponseSchema,
|
|
84
|
+
`/integrations/webhook/connections/${encodeURIComponent(connectionId)}`,
|
|
85
|
+
{
|
|
86
|
+
method: 'DELETE',
|
|
87
|
+
},
|
|
88
|
+
);
|
|
54
89
|
}
|
|
55
90
|
|
|
56
91
|
export function useWebhookConnectionsQuery(workspaceId: string | undefined) {
|
|
57
|
-
return useQuery(
|
|
92
|
+
return useQuery(webhookConnectionsQueryOptions(workspaceId));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function webhookConnectionsQueryOptions(
|
|
96
|
+
workspaceId: string | undefined,
|
|
97
|
+
): WebhookConnectionsListQueryOptions {
|
|
98
|
+
return queryOptions({
|
|
58
99
|
queryKey: workspaceId
|
|
59
100
|
? webhookConnectionsQueryKeys.list(workspaceId)
|
|
60
|
-
: [...webhookConnectionsQueryKeys.all, 'list'],
|
|
101
|
+
: ([...webhookConnectionsQueryKeys.all, 'list'] as const),
|
|
61
102
|
enabled: Boolean(workspaceId),
|
|
62
103
|
queryFn: ({signal}) => listWebhookConnections({workspaceId: workspaceId ?? '', signal}),
|
|
63
104
|
});
|
|
@@ -68,7 +109,7 @@ export function useCreateWebhookConnectionMutation() {
|
|
|
68
109
|
return useMutation({
|
|
69
110
|
mutationFn: createWebhookConnection,
|
|
70
111
|
onSuccess: async (connection) => {
|
|
71
|
-
await invalidateWebhookConnectionViews(queryClient, connection.
|
|
112
|
+
await invalidateWebhookConnectionViews(queryClient, connection.workspaceId);
|
|
72
113
|
},
|
|
73
114
|
});
|
|
74
115
|
}
|
|
@@ -85,7 +126,7 @@ export function useUpdateWebhookConnectionMutation() {
|
|
|
85
126
|
body: UpdateWebhookConnectionBodyDto;
|
|
86
127
|
}) => updateWebhookConnection({connectionId, body}),
|
|
87
128
|
onSuccess: async (connection) => {
|
|
88
|
-
await invalidateWebhookConnectionViews(queryClient, connection.
|
|
129
|
+
await invalidateWebhookConnectionViews(queryClient, connection.workspaceId);
|
|
89
130
|
},
|
|
90
131
|
});
|
|
91
132
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
export * from '@shipfox/integration-icons';
|
|
2
|
+
export * from '#core/models.js';
|
|
1
3
|
export * from './components/connection-picker.js';
|
|
2
4
|
export * from './components/integration-gallery.js';
|
|
3
5
|
export * from './components/redirect-install-page.js';
|
|
4
6
|
export * from './components/repository-picker.js';
|
|
5
7
|
export * from './connection-status-badge.js';
|
|
6
8
|
export * from './hooks/api/integrations.js';
|
|
7
|
-
export * from './integration-icon.js';
|
|
8
9
|
export * from './pages/gitea-install-page.js';
|
|
9
10
|
export * from './pages/github-install-page.js';
|
|
10
11
|
export * from './pages/linear-callback-page.js';
|
package/src/linear-callback.ts
CHANGED
|
@@ -1,32 +1,40 @@
|
|
|
1
1
|
import type {LinearCallbackQueryDto} from '@shipfox/api-integration-linear-dto';
|
|
2
2
|
import {ApiError} from '@shipfox/client-api';
|
|
3
|
+
import {
|
|
4
|
+
type BrowserStorage,
|
|
5
|
+
type BrowserStorageKey,
|
|
6
|
+
createTypedBrowserStorage,
|
|
7
|
+
} from '@shipfox/client-ui';
|
|
3
8
|
|
|
4
9
|
export const LINEAR_INSTALL_WORKSPACE_KEY = 'shipfox.linear-install.workspace-id';
|
|
5
10
|
|
|
6
|
-
type WorkspaceStorage =
|
|
11
|
+
type WorkspaceStorage = BrowserStorage | undefined;
|
|
12
|
+
|
|
13
|
+
const linearInstallWorkspaceStorageKey = {
|
|
14
|
+
key: LINEAR_INSTALL_WORKSPACE_KEY,
|
|
15
|
+
lifetime: 'session',
|
|
16
|
+
// This is a one-shot navigation hint, not private workspace state. It must
|
|
17
|
+
// remain readable on an OAuth callback before workspace hydration completes;
|
|
18
|
+
// the callback state and returned connection are the authoritative checks.
|
|
19
|
+
principalScope: 'global',
|
|
20
|
+
serialize: (workspaceId: string) => workspaceId,
|
|
21
|
+
parse: (value: string) => value || undefined,
|
|
22
|
+
} satisfies BrowserStorageKey<string>;
|
|
7
23
|
|
|
8
24
|
export function saveLinearInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
|
|
9
|
-
|
|
10
|
-
storage.setItem(LINEAR_INSTALL_WORKSPACE_KEY, workspaceId);
|
|
11
|
-
} catch {
|
|
12
|
-
// Storage only improves recovery navigation; callback input always comes from the URL.
|
|
13
|
-
}
|
|
25
|
+
installWorkspaceStorage(storage).write(workspaceId);
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
export function readLinearInstallWorkspace(storage: WorkspaceStorage): string | undefined {
|
|
17
|
-
|
|
18
|
-
return storage.getItem(LINEAR_INSTALL_WORKSPACE_KEY) ?? undefined;
|
|
19
|
-
} catch {
|
|
20
|
-
return undefined;
|
|
21
|
-
}
|
|
29
|
+
return installWorkspaceStorage(storage).read();
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
export function clearLinearInstallWorkspace(storage: WorkspaceStorage): void {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
installWorkspaceStorage(storage).remove();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function installWorkspaceStorage(storage: WorkspaceStorage) {
|
|
37
|
+
return createTypedBrowserStorage(() => storage, linearInstallWorkspaceStorageKey);
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export function parseLinearCallbackQuery(
|
|
@@ -5,16 +5,14 @@ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-fiel
|
|
|
5
5
|
import {toast} from '@shipfox/react-ui/toast';
|
|
6
6
|
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
7
7
|
import {useForm} from '@tanstack/react-form';
|
|
8
|
-
import {useQueryClient} from '@tanstack/react-query';
|
|
9
8
|
import {Link, useNavigate} from '@tanstack/react-router';
|
|
10
9
|
import {useState} from 'react';
|
|
11
|
-
import {
|
|
10
|
+
import {useCreateGiteaConnectionMutation} from '#hooks/api/integrations.js';
|
|
12
11
|
import {giteaConnectErrorToFormError} from './gitea-form-errors.js';
|
|
13
12
|
|
|
14
13
|
export function GiteaInstallPage() {
|
|
15
14
|
const workspace = useActiveWorkspace();
|
|
16
15
|
const connect = useCreateGiteaConnectionMutation();
|
|
17
|
-
const queryClient = useQueryClient();
|
|
18
16
|
const navigate = useNavigate();
|
|
19
17
|
const [formError, setFormError] = useState<string | undefined>();
|
|
20
18
|
|
|
@@ -25,13 +23,6 @@ export function GiteaInstallPage() {
|
|
|
25
23
|
const workspaceId = workspace.id;
|
|
26
24
|
try {
|
|
27
25
|
await connect.mutateAsync({workspace_id: workspaceId, org: value.org.trim()});
|
|
28
|
-
await queryClient.invalidateQueries({
|
|
29
|
-
queryKey: integrationsQueryKeys.sourceConnections(workspaceId),
|
|
30
|
-
// The workspace home is the next active observer, not this page, so the
|
|
31
|
-
// default 'active' refetch would no-op and leave the cache stale until
|
|
32
|
-
// after the home redirects back here.
|
|
33
|
-
refetchType: 'all',
|
|
34
|
-
});
|
|
35
26
|
toast.success('Gitea organization installed.');
|
|
36
27
|
await navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
|
|
37
28
|
} catch (error) {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type {LinearCallbackResponseDto} from '@shipfox/api-integration-linear-dto';
|
|
2
1
|
import {useRefreshAuth} from '@shipfox/client-auth';
|
|
3
|
-
import {
|
|
2
|
+
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
4
4
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
5
5
|
import {toast} from '@shipfox/react-ui/toast';
|
|
6
|
-
import {
|
|
7
|
-
import {useNavigate, useSearch} from '@tanstack/react-router';
|
|
6
|
+
import {useNavigate} from '@tanstack/react-router';
|
|
8
7
|
import {useEffect, useMemo, useState} from 'react';
|
|
8
|
+
import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
|
|
9
9
|
import {CallbackStatusShell} from '#components/callback-status-shell.js';
|
|
10
|
-
import {
|
|
10
|
+
import type {IntegrationConnection} from '#core/models.js';
|
|
11
|
+
import {useCompleteLinearCallbackMutation} from '#hooks/api/integrations.js';
|
|
11
12
|
import {
|
|
12
13
|
classifyLinearCallbackError,
|
|
13
14
|
clearLinearInstallWorkspace,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
|
|
20
21
|
// Retain only recent completions: this bounds long-lived callback pages while
|
|
21
22
|
// still covering StrictMode and immediate Back/Forward remounts.
|
|
22
|
-
const callbackRequests = createSingleFlight<string,
|
|
23
|
+
const callbackRequests = createSingleFlight<string, IntegrationConnection>({
|
|
23
24
|
maxTerminalResults: 32,
|
|
24
25
|
});
|
|
25
26
|
// Keeps the success toast firing once per distinct callback even though the
|
|
@@ -27,19 +28,12 @@ const callbackRequests = createSingleFlight<string, LinearCallbackResponseDto>({
|
|
|
27
28
|
const toastedCallbacks = new Set<string>();
|
|
28
29
|
|
|
29
30
|
export function LinearCallbackPage() {
|
|
30
|
-
const search = useSearch({strict: false});
|
|
31
31
|
const navigate = useNavigate();
|
|
32
32
|
const refreshAuth = useRefreshAuth();
|
|
33
|
-
const
|
|
33
|
+
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
34
34
|
const {mutateAsync: completeLinearCallback} = useCompleteLinearCallbackMutation();
|
|
35
|
-
const params =
|
|
36
|
-
const workspaceId = useMemo(() =>
|
|
37
|
-
try {
|
|
38
|
-
return readLinearInstallWorkspace(window.sessionStorage);
|
|
39
|
-
} catch {
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
}, []);
|
|
35
|
+
const params = useRouteSearch(parseLinearCallbackQuery);
|
|
36
|
+
const workspaceId = useMemo(() => readLinearInstallWorkspace(sessionStorageOrUndefined()), []);
|
|
43
37
|
const [failure, setFailure] = useState<LinearCallbackFailure | undefined>();
|
|
44
38
|
useEffect(() => {
|
|
45
39
|
if (!params) return;
|
|
@@ -49,27 +43,21 @@ export function LinearCallbackPage() {
|
|
|
49
43
|
const request = callbackRequests.run(
|
|
50
44
|
key,
|
|
51
45
|
async () =>
|
|
52
|
-
await
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
await completeIntegrationCallback({
|
|
47
|
+
input: params,
|
|
48
|
+
refreshAuth,
|
|
49
|
+
complete: async (query, token) => await completeLinearCallback({query, token}),
|
|
50
|
+
}),
|
|
56
51
|
);
|
|
57
52
|
|
|
58
53
|
request.then(
|
|
59
54
|
async (connection) => {
|
|
60
55
|
if (disposed) return;
|
|
61
56
|
try {
|
|
62
|
-
clearLinearInstallWorkspace(
|
|
57
|
+
clearLinearInstallWorkspace(sessionStorageOrUndefined());
|
|
63
58
|
} catch {
|
|
64
59
|
// The successful API response remains the source of truth for navigation.
|
|
65
60
|
}
|
|
66
|
-
try {
|
|
67
|
-
await queryClient.invalidateQueries({
|
|
68
|
-
queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspace_id),
|
|
69
|
-
});
|
|
70
|
-
} catch {
|
|
71
|
-
// Cache refresh is best effort: the successful callback is already committed server-side.
|
|
72
|
-
}
|
|
73
61
|
if (disposed) return;
|
|
74
62
|
if (!toastedCallbacks.has(key)) {
|
|
75
63
|
toastedCallbacks.add(key);
|
|
@@ -78,7 +66,7 @@ export function LinearCallbackPage() {
|
|
|
78
66
|
try {
|
|
79
67
|
await navigate({
|
|
80
68
|
to: '/workspaces/$wid/settings/integrations',
|
|
81
|
-
params: {wid: connection.
|
|
69
|
+
params: {wid: connection.workspaceId},
|
|
82
70
|
replace: true,
|
|
83
71
|
});
|
|
84
72
|
} catch {
|
|
@@ -94,7 +82,7 @@ export function LinearCallbackPage() {
|
|
|
94
82
|
return () => {
|
|
95
83
|
disposed = true;
|
|
96
84
|
};
|
|
97
|
-
}, [completeLinearCallback, navigate, params,
|
|
85
|
+
}, [completeIntegrationCallback, completeLinearCallback, navigate, params, refreshAuth]);
|
|
98
86
|
|
|
99
87
|
if (!params) {
|
|
100
88
|
return (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
1
2
|
import {useCallback} from 'react';
|
|
2
3
|
import {RedirectInstallPage} from '#components/redirect-install-page.js';
|
|
3
4
|
import {useCreateLinearInstallMutation} from '#hooks/api/integrations.js';
|
|
@@ -17,7 +18,7 @@ export function LinearInstallPage() {
|
|
|
17
18
|
loadingLabel="Connecting Linear"
|
|
18
19
|
beforeRedirect={(workspaceId) => {
|
|
19
20
|
try {
|
|
20
|
-
saveLinearInstallWorkspace(
|
|
21
|
+
saveLinearInstallWorkspace(sessionStorageOrUndefined(), workspaceId);
|
|
21
22
|
} catch {
|
|
22
23
|
// Storage can be disabled before the helper gets a usable Storage object.
|
|
23
24
|
}
|
|
@@ -162,7 +162,11 @@ describe('SentryCallbackPage', () => {
|
|
|
162
162
|
|
|
163
163
|
fireEvent.click(await screen.findByRole('button', {name: 'Install'}));
|
|
164
164
|
|
|
165
|
-
expect(
|
|
165
|
+
expect(
|
|
166
|
+
await screen.findByText(
|
|
167
|
+
'Sentry did not accept this install. Start the install again from workspace settings.',
|
|
168
|
+
),
|
|
169
|
+
).toBeInTheDocument();
|
|
166
170
|
expect(screen.getByRole('link', {name: 'Start over'})).toBeInTheDocument();
|
|
167
171
|
expect(screen.queryByRole('button', {name: 'Retry'})).not.toBeInTheDocument();
|
|
168
172
|
});
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import type {SentryConnectResponseDto} from '@shipfox/api-integration-sentry-dto';
|
|
2
1
|
import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
|
|
3
|
-
import {
|
|
2
|
+
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
4
4
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
5
5
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
6
6
|
import {Card} from '@shipfox/react-ui/card';
|
|
7
7
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
8
8
|
import {toast} from '@shipfox/react-ui/toast';
|
|
9
9
|
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
10
|
-
import {
|
|
11
|
-
import {Link, useNavigate, useSearch} from '@tanstack/react-router';
|
|
10
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
12
11
|
import {useEffect, useMemo, useRef, useState} from 'react';
|
|
13
|
-
import {
|
|
12
|
+
import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
|
|
13
|
+
import type {IntegrationConnection} from '#core/models.js';
|
|
14
|
+
import {connectSentry} from '#hooks/api/integrations.js';
|
|
14
15
|
import {
|
|
15
16
|
classifySentryConnectError,
|
|
16
17
|
clearSentryInstallWorkspace,
|
|
@@ -20,17 +21,19 @@ import {
|
|
|
20
21
|
type SentryConnectFailure,
|
|
21
22
|
} from '#sentry-callback.js';
|
|
22
23
|
|
|
23
|
-
const connectRequests = createSingleFlight<string,
|
|
24
|
+
const connectRequests = createSingleFlight<string, IntegrationConnection>();
|
|
24
25
|
|
|
25
26
|
export function SentryCallbackPage() {
|
|
26
|
-
const search = useSearch({strict: false});
|
|
27
27
|
const navigate = useNavigate();
|
|
28
28
|
const refreshAuth = useRefreshAuth();
|
|
29
29
|
const {workspaces, isLoading} = useAuthState();
|
|
30
|
-
const
|
|
30
|
+
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
31
31
|
|
|
32
|
-
const params =
|
|
33
|
-
const storedWorkspaceId = useMemo(
|
|
32
|
+
const params = useRouteSearch(parseSentryCallbackParams);
|
|
33
|
+
const storedWorkspaceId = useMemo(
|
|
34
|
+
() => readSentryInstallWorkspace(sessionStorageOrUndefined()),
|
|
35
|
+
[],
|
|
36
|
+
);
|
|
34
37
|
const preselection = preselectSentryWorkspace(storedWorkspaceId, workspaces);
|
|
35
38
|
const preselectedId = preselection.kind === 'pick' ? preselection.preselectedId : undefined;
|
|
36
39
|
|
|
@@ -101,24 +104,24 @@ export function SentryCallbackPage() {
|
|
|
101
104
|
const request = connectRequests.run(
|
|
102
105
|
key,
|
|
103
106
|
async () =>
|
|
104
|
-
await
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
await completeIntegrationCallback({
|
|
108
|
+
input: workspaceId,
|
|
109
|
+
refreshAuth,
|
|
110
|
+
complete: async (selectedWorkspaceId, token) =>
|
|
111
|
+
await connectSentry({
|
|
112
|
+
body: {
|
|
113
|
+
workspace_id: selectedWorkspaceId,
|
|
114
|
+
code: params.code,
|
|
115
|
+
installation_id: params.installationId,
|
|
116
|
+
},
|
|
117
|
+
token,
|
|
118
|
+
}),
|
|
119
|
+
}),
|
|
114
120
|
);
|
|
115
121
|
|
|
116
122
|
request
|
|
117
123
|
.then(async () => {
|
|
118
|
-
clearSentryInstallWorkspace(
|
|
119
|
-
await queryClient.invalidateQueries({
|
|
120
|
-
queryKey: integrationsQueryKeys.connectionsByWorkspace(workspaceId),
|
|
121
|
-
});
|
|
124
|
+
clearSentryInstallWorkspace(sessionStorageOrUndefined());
|
|
122
125
|
if (disposedRef.current) return;
|
|
123
126
|
toast.success('Sentry installed.');
|
|
124
127
|
await navigate({
|
|
@@ -133,7 +136,7 @@ export function SentryCallbackPage() {
|
|
|
133
136
|
if (classified.kind === 'terminal') {
|
|
134
137
|
// Only a fresh install (new grant code) can recover; the stored
|
|
135
138
|
// handoff has served its purpose either way.
|
|
136
|
-
clearSentryInstallWorkspace(
|
|
139
|
+
clearSentryInstallWorkspace(sessionStorageOrUndefined());
|
|
137
140
|
}
|
|
138
141
|
setConnectingId(undefined);
|
|
139
142
|
setFailure({workspaceId, failure: classified});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
1
2
|
import {RedirectInstallPage} from '#components/redirect-install-page.js';
|
|
2
3
|
import {createSentryInstall} from '#hooks/api/integrations.js';
|
|
3
4
|
import {saveSentryInstallWorkspace} from '#sentry-callback.js';
|
|
@@ -11,7 +12,7 @@ export function SentryInstallPage() {
|
|
|
11
12
|
// Sentry's redirect has no state param; the stored id lets the callback
|
|
12
13
|
// pre-select this workspace. Saving never throws (see helper).
|
|
13
14
|
beforeRedirect={(workspaceId) => {
|
|
14
|
-
saveSentryInstallWorkspace(
|
|
15
|
+
saveSentryInstallWorkspace(sessionStorageOrUndefined(), workspaceId);
|
|
15
16
|
}}
|
|
16
17
|
/>
|
|
17
18
|
);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type {SlackCallbackResponseDto} from '@shipfox/api-integration-slack-dto';
|
|
2
1
|
import {useRefreshAuth} from '@shipfox/client-auth';
|
|
3
|
-
import {
|
|
2
|
+
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {createSingleFlight, sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
4
4
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
5
5
|
import {toast} from '@shipfox/react-ui/toast';
|
|
6
|
-
import {
|
|
7
|
-
import {useNavigate, useSearch} from '@tanstack/react-router';
|
|
6
|
+
import {useNavigate} from '@tanstack/react-router';
|
|
8
7
|
import {useEffect, useMemo, useState} from 'react';
|
|
8
|
+
import {useCompleteIntegrationCallback} from '#application/complete-integration-callback.js';
|
|
9
9
|
import {CallbackStatusShell} from '#components/callback-status-shell.js';
|
|
10
|
-
import {
|
|
10
|
+
import type {IntegrationConnection} from '#core/models.js';
|
|
11
|
+
import {useCompleteSlackCallbackMutation} from '#hooks/api/integrations.js';
|
|
11
12
|
import {
|
|
12
13
|
classifySlackCallbackError,
|
|
13
14
|
clearSlackInstallWorkspace,
|
|
@@ -19,20 +20,19 @@ import {
|
|
|
19
20
|
|
|
20
21
|
// Retain only recent completions: this bounds long-lived callback pages while
|
|
21
22
|
// still covering StrictMode and immediate Back/Forward remounts.
|
|
22
|
-
const callbackRequests = createSingleFlight<string,
|
|
23
|
+
const callbackRequests = createSingleFlight<string, IntegrationConnection>({
|
|
23
24
|
maxTerminalResults: 32,
|
|
24
25
|
});
|
|
25
26
|
const completedCallbacks = new Set<string>();
|
|
26
27
|
const toastedCallbacks = new Set<string>();
|
|
27
28
|
|
|
28
29
|
export function SlackCallbackPage() {
|
|
29
|
-
const search = useSearch({strict: false});
|
|
30
30
|
const navigate = useNavigate();
|
|
31
31
|
const refreshAuth = useRefreshAuth();
|
|
32
|
-
const
|
|
32
|
+
const completeIntegrationCallback = useCompleteIntegrationCallback();
|
|
33
33
|
const {mutateAsync: completeSlackCallback} = useCompleteSlackCallbackMutation();
|
|
34
|
-
const params =
|
|
35
|
-
const workspaceId = useMemo(() => readSlackInstallWorkspace(
|
|
34
|
+
const params = useRouteSearch(parseSlackCallbackQuery);
|
|
35
|
+
const workspaceId = useMemo(() => readSlackInstallWorkspace(sessionStorageOrUndefined()), []);
|
|
36
36
|
const [failure, setFailure] = useState<SlackCallbackFailure>();
|
|
37
37
|
const [completedWorkspaceId, setCompletedWorkspaceId] = useState<string>();
|
|
38
38
|
|
|
@@ -43,31 +43,25 @@ export function SlackCallbackPage() {
|
|
|
43
43
|
const request = callbackRequests.run(
|
|
44
44
|
key,
|
|
45
45
|
async () =>
|
|
46
|
-
await
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
await completeIntegrationCallback({
|
|
47
|
+
input: params,
|
|
48
|
+
refreshAuth,
|
|
49
|
+
complete: async (query, token) => await completeSlackCallback({query, token}),
|
|
50
|
+
}),
|
|
50
51
|
);
|
|
51
52
|
request.then(
|
|
52
53
|
async (connection) => {
|
|
53
54
|
if (disposed) return;
|
|
54
55
|
if (completedCallbacks.has(key)) {
|
|
55
|
-
setCompletedWorkspaceId(connection.
|
|
56
|
+
setCompletedWorkspaceId(connection.workspaceId);
|
|
56
57
|
return;
|
|
57
58
|
}
|
|
58
59
|
completedCallbacks.add(key);
|
|
59
60
|
try {
|
|
60
|
-
clearSlackInstallWorkspace(
|
|
61
|
+
clearSlackInstallWorkspace(sessionStorageOrUndefined());
|
|
61
62
|
} catch {
|
|
62
63
|
// The successful API response remains the source of truth.
|
|
63
64
|
}
|
|
64
|
-
try {
|
|
65
|
-
await queryClient.invalidateQueries({
|
|
66
|
-
queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspace_id),
|
|
67
|
-
});
|
|
68
|
-
} catch {
|
|
69
|
-
// Navigation can continue when cache refresh is unavailable.
|
|
70
|
-
}
|
|
71
65
|
if (disposed) return;
|
|
72
66
|
if (!toastedCallbacks.has(key)) {
|
|
73
67
|
toastedCallbacks.add(key);
|
|
@@ -76,11 +70,11 @@ export function SlackCallbackPage() {
|
|
|
76
70
|
try {
|
|
77
71
|
await navigate({
|
|
78
72
|
to: '/workspaces/$wid/settings/integrations',
|
|
79
|
-
params: {wid: connection.
|
|
73
|
+
params: {wid: connection.workspaceId},
|
|
80
74
|
replace: true,
|
|
81
75
|
});
|
|
82
76
|
} catch {
|
|
83
|
-
if (!disposed) setCompletedWorkspaceId(connection.
|
|
77
|
+
if (!disposed) setCompletedWorkspaceId(connection.workspaceId);
|
|
84
78
|
}
|
|
85
79
|
},
|
|
86
80
|
(error: unknown) => {
|
|
@@ -90,7 +84,7 @@ export function SlackCallbackPage() {
|
|
|
90
84
|
return () => {
|
|
91
85
|
disposed = true;
|
|
92
86
|
};
|
|
93
|
-
}, [completeSlackCallback, navigate, params,
|
|
87
|
+
}, [completeIntegrationCallback, completeSlackCallback, navigate, params, refreshAuth]);
|
|
94
88
|
|
|
95
89
|
if (!params)
|
|
96
90
|
return (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {sessionStorageOrUndefined} from '@shipfox/client-ui';
|
|
1
2
|
import {useCallback} from 'react';
|
|
2
3
|
import {RedirectInstallPage} from '#components/redirect-install-page.js';
|
|
3
4
|
import {useCreateSlackInstallMutation} from '#hooks/api/integrations.js';
|
|
@@ -15,7 +16,7 @@ export function SlackInstallPage() {
|
|
|
15
16
|
errorFallbackMessage="Could not start Slack install."
|
|
16
17
|
loadingLabel="Connecting Slack"
|
|
17
18
|
beforeRedirect={(workspaceId) =>
|
|
18
|
-
saveSlackInstallWorkspace(
|
|
19
|
+
saveSlackInstallWorkspace(sessionStorageOrUndefined(), workspaceId)
|
|
19
20
|
}
|
|
20
21
|
/>
|
|
21
22
|
);
|
|
@@ -17,7 +17,11 @@ export function SourceControlOnboardingPage() {
|
|
|
17
17
|
</header>
|
|
18
18
|
|
|
19
19
|
<ProviderGrid
|
|
20
|
-
|
|
20
|
+
providers={providersQuery.data ?? []}
|
|
21
|
+
isPending={providersQuery.isPending}
|
|
22
|
+
isFetching={providersQuery.isFetching}
|
|
23
|
+
error={providersQuery.isError ? providersQuery.error : undefined}
|
|
24
|
+
onRetry={() => void providersQuery.refetch()}
|
|
21
25
|
workspaceId={workspace.id}
|
|
22
26
|
emptyMessage="Enable at least one source-control provider in the application settings."
|
|
23
27
|
/>
|
package/src/provider-catalog.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {PROVIDER_ICONS} from '@shipfox/integration-icons';
|
|
1
2
|
import type {IconName} from '@shipfox/react-ui/icon';
|
|
2
3
|
|
|
3
4
|
// Literal union (not string) so `<Link to={catalog.setupPath}>` stays typed
|
|
@@ -25,31 +26,31 @@ export type ProviderCatalogEntry = RouteProviderCatalogEntry | ModalProviderCata
|
|
|
25
26
|
export const PROVIDER_CATALOG: Record<string, ProviderCatalogEntry> = {
|
|
26
27
|
github: {
|
|
27
28
|
kind: 'redirect-install',
|
|
28
|
-
iconName:
|
|
29
|
+
iconName: PROVIDER_ICONS.github,
|
|
29
30
|
setupPath: '/workspaces/$wid/integrations/github',
|
|
30
31
|
},
|
|
31
32
|
sentry: {
|
|
32
33
|
kind: 'redirect-install',
|
|
33
|
-
iconName:
|
|
34
|
+
iconName: PROVIDER_ICONS.sentry,
|
|
34
35
|
setupPath: '/workspaces/$wid/integrations/sentry',
|
|
35
36
|
},
|
|
36
37
|
linear: {
|
|
37
38
|
kind: 'redirect-install',
|
|
38
|
-
iconName:
|
|
39
|
+
iconName: PROVIDER_ICONS.linear,
|
|
39
40
|
setupPath: '/workspaces/$wid/integrations/linear',
|
|
40
41
|
},
|
|
41
42
|
slack: {
|
|
42
43
|
kind: 'redirect-install',
|
|
43
|
-
iconName:
|
|
44
|
+
iconName: PROVIDER_ICONS.slack,
|
|
44
45
|
setupPath: '/workspaces/$wid/integrations/slack',
|
|
45
46
|
},
|
|
46
47
|
gitea: {
|
|
47
48
|
kind: 'direct-connect',
|
|
48
|
-
iconName:
|
|
49
|
+
iconName: PROVIDER_ICONS.gitea,
|
|
49
50
|
setupPath: '/workspaces/$wid/integrations/gitea',
|
|
50
51
|
},
|
|
51
52
|
webhook: {
|
|
52
53
|
kind: 'modal-connect',
|
|
53
|
-
iconName:
|
|
54
|
+
iconName: PROVIDER_ICONS.webhook,
|
|
54
55
|
},
|
|
55
56
|
};
|