Skip to content
This repository was archived by the owner on Mar 10, 2024. It is now read-only.

Commit 9821f7f

Browse files
committed
feat: add s3
1 parent 369a83e commit 9821f7f

20 files changed

+676
-23
lines changed

Diff for: apps/mgmt-ui/src/components/connectors/destination/DestinationDetailsPanel.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import BigQueryDestinationDetailsPanel from './BigQueryDestinationDetailsPanel';
22
import PostgresDestinationDetailsPanel from './PostgresDestinationDetailsPanel';
33
import RedshiftDestinationDetailsPanel from './RedshiftDestinationDetailsPanel';
4+
import S3DestinationDetailsPanel from './S3DestinationDetailsPanel';
45
import SnowflakeDestinationDetailsPanel from './SnowflakeDestinationDetailsPanel';
56

67
export type DestinationDetailsPanelProps = {
7-
type: 'postgres' | 'bigquery' | 'snowflake' | 'redshift';
8+
type: 'postgres' | 'bigquery' | 'snowflake' | 'redshift' | 's3';
89
isLoading: boolean;
910
};
1011

@@ -18,6 +19,8 @@ export default function DestinationDetailsPanel({ type, isLoading }: Destination
1819
return <SnowflakeDestinationDetailsPanel isLoading={isLoading} />;
1920
case 'redshift':
2021
return <RedshiftDestinationDetailsPanel isLoading={isLoading} />;
22+
case 's3':
23+
return <S3DestinationDetailsPanel isLoading={isLoading} />;
2124
default:
2225
return null;
2326
}

Diff for: apps/mgmt-ui/src/components/connectors/destination/DestinationTabPanelContainer.tsx

+12-2
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ const ICON_SIZE = 35;
1616
export type DestinationCardInfo = {
1717
icon?: React.ReactNode;
1818
name: string;
19-
type: 'postgres' | 'bigquery' | 'snowflake' | 'redshift' | 'supaglue';
19+
type: 'postgres' | 'bigquery' | 'snowflake' | 'redshift' | 'supaglue' | 's3';
2020
classType: 'analytical' | 'application';
2121
status: string;
2222
description: string;
2323
};
2424

2525
export type AnalyticalDestinationCardInfo = DestinationCardInfo & {
26-
type: 'bigquery' | 'snowflake' | 'redshift';
26+
type: 'bigquery' | 'snowflake' | 'redshift' | 's3';
2727
};
2828

2929
export const postgresDestinationCardInfo: DestinationCardInfo = {
@@ -62,6 +62,15 @@ export const redshiftDestinationCardInfo: DestinationCardInfo = {
6262
description: 'Configure your Redshift destination.',
6363
};
6464

65+
export const s3DestinationCardInfo: DestinationCardInfo = {
66+
icon: <Image alt="s3" src={RedshiftQueryIcon} width={ICON_SIZE} height={ICON_SIZE} />,
67+
name: 'S3',
68+
type: 's3',
69+
classType: 'analytical',
70+
status: '',
71+
description: 'Configure your S3 destination.',
72+
};
73+
6574
export const supaglueDestinationCardInfo: DestinationCardInfo = {
6675
icon: <Image alt="supaglue" src={SupaglueIcon} width={ICON_SIZE} height={ICON_SIZE} />,
6776
name: 'Supaglue',
@@ -77,6 +86,7 @@ export const destinationCardsInfo: DestinationCardInfo[] = [
7786
bigQueryDestinationCardInfo,
7887
snowflakeDestinationCardInfo,
7988
redshiftDestinationCardInfo,
89+
s3DestinationCardInfo,
8090
];
8191

8292
export default function DestinationTabPanelContainer() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/* eslint-disable @typescript-eslint/no-floating-promises */
2+
import { createDestination, testDestination, updateDestination } from '@/client';
3+
import Spinner from '@/components/Spinner';
4+
import { useNotification } from '@/context/notification';
5+
import { useActiveApplicationId } from '@/hooks/useActiveApplicationId';
6+
import { useDestinations } from '@/hooks/useDestinations';
7+
import getIcon from '@/utils/companyToIcon';
8+
import { Button, Stack, TextField, Typography } from '@mui/material';
9+
import Card from '@mui/material/Card';
10+
import type { DestinationSafeAny } from '@supaglue/types';
11+
import { useRouter } from 'next/router';
12+
import { useEffect, useState } from 'react';
13+
import { s3DestinationCardInfo } from './DestinationTabPanelContainer';
14+
import { ExistingPasswordTextField, type KnownOrUnknownValue } from './ExistingPasswordTextField';
15+
16+
export type S3DestinationDetailsPanelProps = {
17+
isLoading: boolean;
18+
};
19+
20+
export default function S3DestinationDetailsPanel({ isLoading }: S3DestinationDetailsPanelProps) {
21+
const activeApplicationId = useActiveApplicationId();
22+
const { addNotification } = useNotification();
23+
24+
const { destinations: existingDestinations = [], mutate } = useDestinations();
25+
26+
const destination = existingDestinations.find((existingDestination) => existingDestination.type === 's3');
27+
28+
const [name, setName] = useState<string>('');
29+
const [region, setRegion] = useState<string>('');
30+
const [bucket, setBucket] = useState<string>('');
31+
const [accessKeyId, setAccessKeyId] = useState<string>('');
32+
const [secretAccessKey, setSecretAccessKey] = useState<KnownOrUnknownValue>({ type: 'known', value: '' });
33+
const [isTesting, setIsTesting] = useState<boolean>(false);
34+
const [isTestSuccessful, setIsTestSuccessful] = useState<boolean>(false);
35+
const [isDirty, setIsDirty] = useState<boolean>(false);
36+
const router = useRouter();
37+
38+
const isNew = !destination?.id;
39+
40+
useEffect(() => {
41+
const timer = setTimeout(() => {
42+
setIsTesting(false);
43+
}, 3000); // sum of connection timeout + query timeout
44+
return () => clearTimeout(timer);
45+
}, [isTesting]);
46+
47+
useEffect(() => {
48+
if (destination?.type !== 's3') {
49+
return;
50+
}
51+
52+
setRegion(destination?.config?.region);
53+
setName(destination?.name);
54+
setBucket(destination?.config?.bucket);
55+
setAccessKeyId(destination?.config?.accessKeyId);
56+
setSecretAccessKey({ type: 'unknown' });
57+
}, [destination?.id]);
58+
59+
const createOrUpdateDestination = async (): Promise<DestinationSafeAny | undefined> => {
60+
if (destination) {
61+
const response = await updateDestination({
62+
...destination,
63+
name,
64+
type: 's3',
65+
config: {
66+
region,
67+
bucket,
68+
accessKeyId,
69+
secretAccessKey: secretAccessKey.type === 'known' ? secretAccessKey.value : undefined,
70+
},
71+
version: destination.version,
72+
});
73+
if (!response.ok) {
74+
addNotification({ message: response.errorMessage, severity: 'error' });
75+
return;
76+
}
77+
return response.data;
78+
}
79+
const response = await createDestination({
80+
applicationId: activeApplicationId,
81+
type: 's3',
82+
name,
83+
config: {
84+
region,
85+
bucket,
86+
accessKeyId,
87+
secretAccessKey: secretAccessKey.type === 'known' ? secretAccessKey.value : '', // TODO: shouldn't allow empty string
88+
},
89+
});
90+
if (!response.ok) {
91+
addNotification({ message: response.errorMessage, severity: 'error' });
92+
return;
93+
}
94+
return response.data;
95+
};
96+
97+
const SaveButton = () => {
98+
return (
99+
<Button
100+
disabled={!isTestSuccessful || name === '' || !isDirty}
101+
variant="contained"
102+
onClick={async () => {
103+
const newDestination = await createOrUpdateDestination();
104+
if (!newDestination) {
105+
return;
106+
}
107+
addNotification({ message: 'Successfully updated destination', severity: 'success' });
108+
const latestDestinations = [
109+
...existingDestinations.filter(({ id }) => id !== newDestination.id),
110+
newDestination,
111+
];
112+
mutate(latestDestinations, {
113+
optimisticData: latestDestinations,
114+
revalidate: false,
115+
populateCache: false,
116+
});
117+
setIsDirty(false);
118+
}}
119+
>
120+
Save
121+
</Button>
122+
);
123+
};
124+
125+
const TestButton = () => {
126+
return (
127+
<Button
128+
disabled={isTesting || !isDirty}
129+
variant="contained"
130+
color="secondary"
131+
onClick={async () => {
132+
setIsTesting(true);
133+
const response = await testDestination({
134+
id: destination?.id,
135+
applicationId: activeApplicationId,
136+
type: 's3',
137+
name,
138+
config: {
139+
region,
140+
bucket,
141+
accessKeyId,
142+
secretAccessKey: secretAccessKey.type === 'known' ? secretAccessKey.value : undefined,
143+
},
144+
} as any); // TODO: fix typing
145+
setIsTesting(false);
146+
if (!response.ok) {
147+
addNotification({ message: response.errorMessage, severity: 'error' });
148+
setIsTestSuccessful(false);
149+
return;
150+
}
151+
if (response.data && response.data.success) {
152+
addNotification({ message: 'Successfully tested destination', severity: 'success' });
153+
setIsTestSuccessful(true);
154+
} else {
155+
addNotification({ message: `Failed testing destination: ${response.data.message}`, severity: 'error' });
156+
setIsTestSuccessful(false);
157+
}
158+
}}
159+
>
160+
{isTesting ? 'Testing...' : 'Test'}
161+
</Button>
162+
);
163+
};
164+
165+
if (isLoading) {
166+
return <Spinner />;
167+
}
168+
169+
return (
170+
<Card>
171+
<Stack direction="column" className="gap-4" sx={{ padding: '2rem' }}>
172+
<Stack direction="row" className="items-center justify-between w-full">
173+
<Stack direction="row" className="items-center justify-center gap-2">
174+
{getIcon(s3DestinationCardInfo.type, 35)}
175+
<Stack direction="column">
176+
<Typography variant="subtitle1">{s3DestinationCardInfo.name}</Typography>
177+
</Stack>
178+
</Stack>
179+
</Stack>
180+
181+
<Stack className="gap-2">
182+
<Typography variant="subtitle1">Destination Name</Typography>
183+
<TextField
184+
required={true}
185+
error={!isNew && name === ''}
186+
value={name}
187+
size="small"
188+
label="Name (must be unique)"
189+
variant="outlined"
190+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
191+
setName(event.target.value);
192+
setIsDirty(true);
193+
setIsTestSuccessful(false);
194+
}}
195+
/>
196+
</Stack>
197+
198+
<Stack className="gap-2">
199+
<Typography variant="subtitle1">Credentials</Typography>
200+
<TextField
201+
required={true}
202+
error={!isNew && accessKeyId === ''}
203+
value={accessKeyId}
204+
size="small"
205+
label="Access Key ID"
206+
variant="outlined"
207+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
208+
setAccessKeyId(event.target.value);
209+
setIsDirty(true);
210+
setIsTestSuccessful(false);
211+
}}
212+
/>
213+
<ExistingPasswordTextField
214+
required={true}
215+
error={!isNew && secretAccessKey.type === 'known' && secretAccessKey.value === ''}
216+
value={secretAccessKey}
217+
size="small"
218+
label="Secret Access Key"
219+
variant="outlined"
220+
type="password"
221+
onChange={(value: KnownOrUnknownValue) => {
222+
setSecretAccessKey(value);
223+
setIsDirty(true);
224+
setIsTestSuccessful(false);
225+
}}
226+
/>
227+
</Stack>
228+
229+
<Stack className="gap-2">
230+
<Typography variant="subtitle1">Region</Typography>
231+
<TextField
232+
required={true}
233+
error={!isNew && region === ''}
234+
value={region}
235+
size="small"
236+
label="AWS Region"
237+
variant="outlined"
238+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
239+
setRegion(event.target.value);
240+
setIsDirty(true);
241+
setIsTestSuccessful(false);
242+
}}
243+
/>
244+
</Stack>
245+
246+
<Stack className="gap-2">
247+
<Typography variant="subtitle1">Bucket</Typography>
248+
<TextField
249+
required={true}
250+
error={!isNew && bucket === ''}
251+
value={bucket}
252+
size="small"
253+
label="Bucket"
254+
variant="outlined"
255+
helperText='Bucket name without "s3://" prefix or trailing slash.'
256+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
257+
setBucket(event.target.value);
258+
setIsDirty(true);
259+
setIsTestSuccessful(false);
260+
}}
261+
/>
262+
</Stack>
263+
264+
<Stack direction="row" className="gap-2 justify-between">
265+
<Button
266+
variant="outlined"
267+
onClick={() => {
268+
router.back();
269+
}}
270+
>
271+
Back
272+
</Button>
273+
<Stack direction="row" className="gap-2">
274+
<TestButton />
275+
<SaveButton />
276+
</Stack>
277+
</Stack>
278+
</Stack>
279+
</Card>
280+
);
281+
}

Diff for: apps/mgmt-ui/src/components/syncs/syncConfig/SyncConfigDetailsPanel.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ function SyncConfigDetailsPanelImpl({ syncConfigId }: SyncConfigDetailsPanelImpl
135135
fullSyncEveryNIncrementals: fullSyncEveryNIncrementals ?? undefined,
136136
autoStartOnConnection,
137137
},
138-
commonObjects: commonObjects.map((object) => ({ object }) as CommonObjectConfig),
138+
commonObjects: commonObjects.map((object) => ({ object } as CommonObjectConfig)),
139139
standardObjects: standardObjects.map((object) => ({ object })),
140140
customObjects: customObjects.map((object) => ({ object })),
141141
entities: entityIds.map((entityId) => ({ entityId })),
@@ -167,7 +167,7 @@ function SyncConfigDetailsPanelImpl({ syncConfigId }: SyncConfigDetailsPanelImpl
167167
strategy,
168168
autoStartOnConnection,
169169
},
170-
commonObjects: commonObjects.map((object) => ({ object }) as CommonObjectConfig),
170+
commonObjects: commonObjects.map((object) => ({ object } as CommonObjectConfig)),
171171
standardObjects: standardObjects.map((object) => ({ object })),
172172
customObjects: customObjects.map((object) => ({ object })),
173173
entities: entityIds.map((entityId) => ({ entityId })),
@@ -484,7 +484,9 @@ function SyncConfigDetailsPanelImpl({ syncConfigId }: SyncConfigDetailsPanelImpl
484484
<TextField
485485
{...params}
486486
label="Custom objects"
487-
helperText={`Custom objects in ${selectedProvider?.name}. (Note: names are case-sensitive. Press enter or comma to add multiple fields. ${
487+
helperText={`Custom objects in ${
488+
selectedProvider?.name
489+
}. (Note: names are case-sensitive. Press enter or comma to add multiple fields. ${
488490
selectedProvider?.name === 'salesforce' ? 'For Salesforce, these should all end with __c.' : ''
489491
})`}
490492
/>

Diff for: docs/docs/api/v2/mgmt/create-destination.api.mdx

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: docs/docs/api/v2/mgmt/get-destination.api.mdx

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: docs/docs/api/v2/mgmt/get-destinations.api.mdx

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: docs/docs/api/v2/mgmt/update-destination.api.mdx

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)