Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/agentstack-ui/src/api/agentstack-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { buildApiClient } from 'agentstack-sdk';

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

import { getProxyHeaders, handleFailedResponse } from './utils';
Expand Down Expand Up @@ -50,4 +51,13 @@ function buildAuthenticatedAgentstackClient() {
return client;
}

export const agentstackClient = buildAuthenticatedAgentstackClient();
const baseAgentstackClient = buildAuthenticatedAgentstackClient();

export const agentstackClient = {
...baseAgentstackClient,
listConnectors: async () => {
const response = await baseAgentstackClient.listConnectors();

return listConnectorsResponseSchema.parse(response);
},
};
10 changes: 8 additions & 2 deletions apps/agentstack-ui/src/modules/connectors/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export async function createConnector(body: CreateConnectorRequest) {
return ensureData(response);
}

export async function deleteConnector(path: DeleteConnectorPath) {
const response = await api.DELETE('/api/v1/connectors/{connector_id}', { params: { path } });

return ensureData(response);
}

export async function connectConnector(path: ConnectConnectorPath) {
const response = await api.POST('/api/v1/connectors/{connector_id}/connect', {
params: { path },
Expand All @@ -35,8 +41,8 @@ export async function disconnectConnector(path: DisconnectConnectorPath) {
return ensureData(response);
}

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

return ensureData(response);
}
1 change: 1 addition & 0 deletions apps/agentstack-ui/src/modules/connectors/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
export const connectorKeys = {
all: () => ['connectors'] as const,
list: () => [...connectorKeys.all(), 'list'] as const,
presetsList: () => [...connectorKeys.all(), 'presets'] as const,
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { useMutation } from '@tanstack/react-query';

import { createConnector } from '..';
import { connectorKeys } from '../keys';
import type { Connector } from '../types';

interface Props {
onSuccess?: () => void;
onSuccess?: (connector: Connector | undefined) => void;
}

export function useCreateConnector({ onSuccess }: Props = {}) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { useQuery } from '@tanstack/react-query';

import { listConnectorPresets } from '..';
import { connectorKeys } from '../keys';

export function useListConnectorPresets() {
const query = useQuery({
queryKey: connectorKeys.presetsList(),
queryFn: listConnectorPresets,
});

return query;
}
35 changes: 33 additions & 2 deletions apps/agentstack-ui/src/modules/connectors/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,31 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { ApiPath, ApiRequest } from '#@types/utils.ts';
import {
connectorSchema as sdkConnectorSchema,
listConnectorsResponseSchema as sdkListConnectorsResponseSchema,
} from 'agentstack-sdk';
import z from 'zod';

import type { ApiPath, ApiRequest, ApiResponse } from '#@types/utils.ts';

const connectorMetadataSchema = z
.object({
name: z.string().optional(),
})
.nullable();

export const connectorSchema = sdkConnectorSchema.extend({
metadata: connectorMetadataSchema,
});

export const listConnectorsResponseSchema = sdkListConnectorsResponseSchema.extend({
items: z.array(connectorSchema),
});

export type Connector = z.infer<typeof connectorSchema>;

export type ListConnectorsResponse = z.infer<typeof listConnectorsResponseSchema>;

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

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

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

export type { Connector, ListConnectorsResponse } from 'agentstack-sdk';
export type ListConnectorPresetsResponse = ApiResponse<'/api/v1/connectors/presets'>;

export type ConnectorPreset = Omit<ListConnectorPresetsResponse['items'][number], 'metadata'> & {
metadata: {
name?: string;
description?: string;
} | null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

.root {
display: flex;
flex-direction: column;
row-gap: $spacing-05;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { TextInput } from '@carbon/react';
import { useId } from 'react';
import { useFormContext } from 'react-hook-form';

import type { AddConnectorForm } from '../types';
import classes from './AddConnectorFormFields.module.scss';

export function AddConnectorFormFields() {
const id = useId();

const {
register,
formState: { errors },
} = useFormContext<AddConnectorForm>();

return (
<div className={classes.root}>
<TextInput
id={`${id}:name`}
labelText="Name"
invalid={Boolean(errors.name)}
invalidText={errors.name?.message}
{...register('name')}
/>

<TextInput
id={`${id}:url`}
labelText="URL"
invalid={Boolean(errors.url)}
invalidText={errors.url?.message}
{...register('url')}
/>

<TextInput
id={`${id}:client-id`}
labelText="Client ID"
invalid={Boolean(errors.clientId)}
invalidText={errors.clientId?.message}
{...register('clientId', { deps: ['clientSecret'] })}
/>

<TextInput
id={`${id}:client-secret`}
labelText="Client secret"
invalid={Boolean(errors.clientSecret)}
invalidText={errors.clientSecret?.message}
{...register('clientSecret')}
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
* SPDX-License-Identifier: Apache-2.0
*/

.stack {
.body {
min-block-size: rem(300px);
display: flex;
flex-direction: column;
row-gap: $spacing-05;
}

.toggleBtn {
padding-inline-end: rem(15px);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,27 @@
* SPDX-License-Identifier: Apache-2.0
*/

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

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

import { useCreateConnector } from '../api/mutations/useCreateConnector';
import { type AddConnectorForm, addConnectorFormSchema } from '../types';
import { AddConnectorFormFields } from './AddConnectorFormFields';
import classes from './AddConnectorModal.module.scss';
import { ConnectorPresetsList } from './ConnectorPresetsList';

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

const { mutate: createConnector, isPending } = useCreateConnector({ onSuccess: onRequestClose });
const { mutate: createConnector, isPending } = useCreateConnector({ onSuccess: () => onRequestClose() });

const {
register,
handleSubmit,
formState: { isValid, errors },
} = useForm({
const form = useForm({
mode: 'onChange',
resolver: zodResolver(addConnectorFormSchema),
});
Expand All @@ -43,59 +41,55 @@ export function AddConnectorModal({ onRequestClose, ...modalProps }: ModalProps)
[createConnector],
);

const toggleView = useCallback(() => setView((view) => (view === View.Add ? View.Browse : View.Add)), []);

const {
handleSubmit,
formState: { isValid },
} = form;

const isAddView = view === View.Add;

return (
<Modal {...modalProps}>
<ModalHeader buttonOnClick={() => onRequestClose()}>
<h2>Add connector</h2>
</ModalHeader>

<ModalBody>
<form onSubmit={handleSubmit(onSubmit)}>
<div className={classes.stack}>
<TextInput
id={`${id}:name`}
labelText="Name"
invalid={Boolean(errors.name)}
invalidText={errors.name?.message}
{...register('name')}
/>

<TextInput
id={`${id}:url`}
labelText="URL"
invalid={Boolean(errors.url)}
invalidText={errors.url?.message}
{...register('url')}
/>

<TextInput
id={`${id}:client-id`}
labelText="Client ID"
invalid={Boolean(errors.clientId)}
invalidText={errors.clientId?.message}
{...register('clientId', { deps: ['clientSecret'] })}
/>

<TextInput
id={`${id}:client-secret`}
labelText="Client secret"
invalid={Boolean(errors.clientSecret)}
invalidText={errors.clientSecret?.message}
{...register('clientSecret')}
/>
</div>
</form>
<div className={classes.body}>
<Button kind="tertiary" size="sm" onClick={toggleView} className={classes.toggleBtn}>
{isAddView ? 'Browse connectors' : 'Add custom connector'}
</Button>

{isAddView ? (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<AddConnectorFormFields />
</form>
</FormProvider>
) : (
<ConnectorPresetsList />
)}
</div>
</ModalBody>

<ModalFooter>
<Button kind="ghost" onClick={() => onRequestClose()}>
Cancel
</Button>

<Button onClick={handleSubmit(onSubmit)} disabled={isPending || !isValid}>
{isPending ? <InlineLoading description="Adding&hellip;" /> : 'Add connector'}
</Button>
{isAddView && (
<Button onClick={handleSubmit(onSubmit)} disabled={isPending || !isValid}>
{isPending ? <InlineLoading description="Adding&hellip;" /> : 'Add connector'}
</Button>
)}
</ModalFooter>
</Modal>
);
}

enum View {
Add = 'add',
Browse = 'browse',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

.root {
padding-inline: rem(7px);
}
Loading