Skip to content

Commit 76393f6

Browse files
authored
feat(ui): add connector presets (#1718)
1 parent 9d8b733 commit 76393f6

18 files changed

+410
-63
lines changed

apps/agentstack-ui/src/api/agentstack-client.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { buildApiClient } from 'agentstack-sdk';
77

88
import { ensureToken } from '#app/(auth)/rsc.tsx';
99
import { runtimeConfig } from '#contexts/App/runtime-config.ts';
10+
import { listConnectorsResponseSchema } from '#modules/connectors/api/types.ts';
1011
import { getBaseUrl } from '#utils/api/getBaseUrl.ts';
1112

1213
import { getProxyHeaders, handleFailedResponse } from './utils';
@@ -50,4 +51,13 @@ function buildAuthenticatedAgentstackClient() {
5051
return client;
5152
}
5253

53-
export const agentstackClient = buildAuthenticatedAgentstackClient();
54+
const baseAgentstackClient = buildAuthenticatedAgentstackClient();
55+
56+
export const agentstackClient = {
57+
...baseAgentstackClient,
58+
listConnectors: async () => {
59+
const response = await baseAgentstackClient.listConnectors();
60+
61+
return listConnectorsResponseSchema.parse(response);
62+
},
63+
};

apps/agentstack-ui/src/modules/connectors/api/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export async function createConnector(body: CreateConnectorRequest) {
2020
return ensureData(response);
2121
}
2222

23+
export async function deleteConnector(path: DeleteConnectorPath) {
24+
const response = await api.DELETE('/api/v1/connectors/{connector_id}', { params: { path } });
25+
26+
return ensureData(response);
27+
}
28+
2329
export async function connectConnector(path: ConnectConnectorPath) {
2430
const response = await api.POST('/api/v1/connectors/{connector_id}/connect', {
2531
params: { path },
@@ -35,8 +41,8 @@ export async function disconnectConnector(path: DisconnectConnectorPath) {
3541
return ensureData(response);
3642
}
3743

38-
export async function deleteConnector(path: DeleteConnectorPath) {
39-
const response = await api.DELETE('/api/v1/connectors/{connector_id}', { params: { path } });
44+
export async function listConnectorPresets() {
45+
const response = await api.GET('/api/v1/connectors/presets');
4046

4147
return ensureData(response);
4248
}

apps/agentstack-ui/src/modules/connectors/api/keys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
export const connectorKeys = {
77
all: () => ['connectors'] as const,
88
list: () => [...connectorKeys.all(), 'list'] as const,
9+
presetsList: () => [...connectorKeys.all(), 'presets'] as const,
910
};

apps/agentstack-ui/src/modules/connectors/api/mutations/useCreateConnector.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { useMutation } from '@tanstack/react-query';
77

88
import { createConnector } from '..';
99
import { connectorKeys } from '../keys';
10+
import type { Connector } from '../types';
1011

1112
interface Props {
12-
onSuccess?: () => void;
13+
onSuccess?: (connector: Connector | undefined) => void;
1314
}
1415

1516
export function useCreateConnector({ onSuccess }: Props = {}) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { useQuery } from '@tanstack/react-query';
7+
8+
import { listConnectorPresets } from '..';
9+
import { connectorKeys } from '../keys';
10+
11+
export function useListConnectorPresets() {
12+
const query = useQuery({
13+
queryKey: connectorKeys.presetsList(),
14+
queryFn: listConnectorPresets,
15+
});
16+
17+
return query;
18+
}

apps/agentstack-ui/src/modules/connectors/api/types.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,31 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import type { ApiPath, ApiRequest } from '#@types/utils.ts';
6+
import {
7+
connectorSchema as sdkConnectorSchema,
8+
listConnectorsResponseSchema as sdkListConnectorsResponseSchema,
9+
} from 'agentstack-sdk';
10+
import z from 'zod';
11+
12+
import type { ApiPath, ApiRequest, ApiResponse } from '#@types/utils.ts';
13+
14+
const connectorMetadataSchema = z
15+
.object({
16+
name: z.string().optional(),
17+
})
18+
.nullable();
19+
20+
export const connectorSchema = sdkConnectorSchema.extend({
21+
metadata: connectorMetadataSchema,
22+
});
23+
24+
export const listConnectorsResponseSchema = sdkListConnectorsResponseSchema.extend({
25+
items: z.array(connectorSchema),
26+
});
27+
28+
export type Connector = z.infer<typeof connectorSchema>;
29+
30+
export type ListConnectorsResponse = z.infer<typeof listConnectorsResponseSchema>;
731

832
export type CreateConnectorRequest = ApiRequest<'/api/v1/connectors'>;
933

@@ -13,4 +37,11 @@ export type DisconnectConnectorPath = ApiPath<'/api/v1/connectors/{connector_id}
1337

1438
export type DeleteConnectorPath = ApiPath<'/api/v1/connectors/{connector_id}', 'delete'>;
1539

16-
export type { Connector, ListConnectorsResponse } from 'agentstack-sdk';
40+
export type ListConnectorPresetsResponse = ApiResponse<'/api/v1/connectors/presets'>;
41+
42+
export type ConnectorPreset = Omit<ListConnectorPresetsResponse['items'][number], 'metadata'> & {
43+
metadata: {
44+
name?: string;
45+
description?: string;
46+
} | null;
47+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
.root {
7+
display: flex;
8+
flex-direction: column;
9+
row-gap: $spacing-05;
10+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { TextInput } from '@carbon/react';
7+
import { useId } from 'react';
8+
import { useFormContext } from 'react-hook-form';
9+
10+
import type { AddConnectorForm } from '../types';
11+
import classes from './AddConnectorFormFields.module.scss';
12+
13+
export function AddConnectorFormFields() {
14+
const id = useId();
15+
16+
const {
17+
register,
18+
formState: { errors },
19+
} = useFormContext<AddConnectorForm>();
20+
21+
return (
22+
<div className={classes.root}>
23+
<TextInput
24+
id={`${id}:name`}
25+
labelText="Name"
26+
invalid={Boolean(errors.name)}
27+
invalidText={errors.name?.message}
28+
{...register('name')}
29+
/>
30+
31+
<TextInput
32+
id={`${id}:url`}
33+
labelText="URL"
34+
invalid={Boolean(errors.url)}
35+
invalidText={errors.url?.message}
36+
{...register('url')}
37+
/>
38+
39+
<TextInput
40+
id={`${id}:client-id`}
41+
labelText="Client ID"
42+
invalid={Boolean(errors.clientId)}
43+
invalidText={errors.clientId?.message}
44+
{...register('clientId', { deps: ['clientSecret'] })}
45+
/>
46+
47+
<TextInput
48+
id={`${id}:client-secret`}
49+
labelText="Client secret"
50+
invalid={Boolean(errors.clientSecret)}
51+
invalidText={errors.clientSecret?.message}
52+
{...register('clientSecret')}
53+
/>
54+
</div>
55+
);
56+
}

apps/agentstack-ui/src/modules/connectors/components/AddConnectorModal.module.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
.stack {
6+
.body {
7+
min-block-size: rem(300px);
78
display: flex;
89
flex-direction: column;
910
row-gap: $spacing-05;
1011
}
12+
13+
.toggleBtn {
14+
padding-inline-end: rem(15px);
15+
}

apps/agentstack-ui/src/modules/connectors/components/AddConnectorModal.tsx

Lines changed: 42 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,27 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { Button, InlineLoading, ModalBody, ModalFooter, ModalHeader, TextInput } from '@carbon/react';
6+
import { Button, InlineLoading, ModalBody, ModalFooter, ModalHeader } from '@carbon/react';
77
import { zodResolver } from '@hookform/resolvers/zod';
8-
import { useCallback, useId } from 'react';
8+
import { useCallback, useState } from 'react';
99
import type { SubmitHandler } from 'react-hook-form';
10-
import { useForm } from 'react-hook-form';
10+
import { FormProvider, useForm } from 'react-hook-form';
1111

1212
import { Modal } from '#components/Modal/Modal.tsx';
1313
import type { ModalProps } from '#contexts/Modal/modal-context.ts';
1414

1515
import { useCreateConnector } from '../api/mutations/useCreateConnector';
1616
import { type AddConnectorForm, addConnectorFormSchema } from '../types';
17+
import { AddConnectorFormFields } from './AddConnectorFormFields';
1718
import classes from './AddConnectorModal.module.scss';
19+
import { ConnectorPresetsList } from './ConnectorPresetsList';
1820

1921
export function AddConnectorModal({ onRequestClose, ...modalProps }: ModalProps) {
20-
const id = useId();
22+
const [view, setView] = useState<View>(View.Browse);
2123

22-
const { mutate: createConnector, isPending } = useCreateConnector({ onSuccess: onRequestClose });
24+
const { mutate: createConnector, isPending } = useCreateConnector({ onSuccess: () => onRequestClose() });
2325

24-
const {
25-
register,
26-
handleSubmit,
27-
formState: { isValid, errors },
28-
} = useForm({
26+
const form = useForm({
2927
mode: 'onChange',
3028
resolver: zodResolver(addConnectorFormSchema),
3129
});
@@ -43,59 +41,55 @@ export function AddConnectorModal({ onRequestClose, ...modalProps }: ModalProps)
4341
[createConnector],
4442
);
4543

44+
const toggleView = useCallback(() => setView((view) => (view === View.Add ? View.Browse : View.Add)), []);
45+
46+
const {
47+
handleSubmit,
48+
formState: { isValid },
49+
} = form;
50+
51+
const isAddView = view === View.Add;
52+
4653
return (
4754
<Modal {...modalProps}>
4855
<ModalHeader buttonOnClick={() => onRequestClose()}>
4956
<h2>Add connector</h2>
5057
</ModalHeader>
5158

5259
<ModalBody>
53-
<form onSubmit={handleSubmit(onSubmit)}>
54-
<div className={classes.stack}>
55-
<TextInput
56-
id={`${id}:name`}
57-
labelText="Name"
58-
invalid={Boolean(errors.name)}
59-
invalidText={errors.name?.message}
60-
{...register('name')}
61-
/>
62-
63-
<TextInput
64-
id={`${id}:url`}
65-
labelText="URL"
66-
invalid={Boolean(errors.url)}
67-
invalidText={errors.url?.message}
68-
{...register('url')}
69-
/>
70-
71-
<TextInput
72-
id={`${id}:client-id`}
73-
labelText="Client ID"
74-
invalid={Boolean(errors.clientId)}
75-
invalidText={errors.clientId?.message}
76-
{...register('clientId', { deps: ['clientSecret'] })}
77-
/>
78-
79-
<TextInput
80-
id={`${id}:client-secret`}
81-
labelText="Client secret"
82-
invalid={Boolean(errors.clientSecret)}
83-
invalidText={errors.clientSecret?.message}
84-
{...register('clientSecret')}
85-
/>
86-
</div>
87-
</form>
60+
<div className={classes.body}>
61+
<Button kind="tertiary" size="sm" onClick={toggleView} className={classes.toggleBtn}>
62+
{isAddView ? 'Browse connectors' : 'Add custom connector'}
63+
</Button>
64+
65+
{isAddView ? (
66+
<FormProvider {...form}>
67+
<form onSubmit={handleSubmit(onSubmit)}>
68+
<AddConnectorFormFields />
69+
</form>
70+
</FormProvider>
71+
) : (
72+
<ConnectorPresetsList />
73+
)}
74+
</div>
8875
</ModalBody>
8976

9077
<ModalFooter>
9178
<Button kind="ghost" onClick={() => onRequestClose()}>
9279
Cancel
9380
</Button>
9481

95-
<Button onClick={handleSubmit(onSubmit)} disabled={isPending || !isValid}>
96-
{isPending ? <InlineLoading description="Adding&hellip;" /> : 'Add connector'}
97-
</Button>
82+
{isAddView && (
83+
<Button onClick={handleSubmit(onSubmit)} disabled={isPending || !isValid}>
84+
{isPending ? <InlineLoading description="Adding&hellip;" /> : 'Add connector'}
85+
</Button>
86+
)}
9887
</ModalFooter>
9988
</Modal>
10089
);
10190
}
91+
92+
enum View {
93+
Add = 'add',
94+
Browse = 'browse',
95+
}

0 commit comments

Comments
 (0)