Skip to content

Commit

Permalink
fix(connect): add display name, auto generate form from connection_co…
Browse files Browse the repository at this point in the history
…nfig (#2775)

## Describe your changes

Contributes to https://linear.app/nango/issue/NAN-1703/create-ui

- API: Add display name to `GET /integrations`
- Connect: use display name
- Connect: auto generate form based on `connection_config` inside
providers.yaml


<img width="638" alt="Screenshot 2024-09-26 at 17 47 27"
src="https://github.com/user-attachments/assets/f779016b-5698-4e3a-aecd-4a79b264426f">
  • Loading branch information
bodinsamuel authored Sep 27, 2024
1 parent de9da6c commit 82bfc9e
Show file tree
Hide file tree
Showing 17 changed files with 205 additions and 96 deletions.
1 change: 1 addition & 0 deletions docs-v2/reference/api/integration/get.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ openapi: 'GET /integrations/{uniqueKey}'
{
"data": {
"unique_key": "slack-nango-community",
"display_name": "Slack",
"provider": "slack",
"logo": "http://localhost:3003/images/template-logos/github.svg",
"created_at": "2023-10-16T08:45:26.241Z",
Expand Down
2 changes: 2 additions & 0 deletions docs-v2/reference/api/integration/list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ const response = await nango.listIntegrations();
"data": [
{
"unique_key": "slack-nango-community",
"display_name": "Slack",
"provider": "slack",
"logo": "http://localhost:3003/images/template-logos/slack.svg",
"created_at": "2023-10-16T08:45:26.241Z",
"updated_at": "2023-10-16T08:45:26.241Z",
},
{
"unique_key": "github-prod",
"display_name": "GitHub",
"provider": "github",
"logo": "http://localhost:3003/images/template-logos/github.svg",
"created_at": "2023-10-16T08:45:26.241Z",
Expand Down
4 changes: 4 additions & 0 deletions docs-v2/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1948,13 +1948,17 @@ components:
additionalProperties: false
required:
- unique_key
- display_name
- provider
- created_at
- updated_at
properties:
unique_key:
type: string
description: The integration ID that you created in Nango.
display_name:
type: string
description: The provider display name.
provider:
type: string
description: The Nango API Configuration.
Expand Down
2 changes: 1 addition & 1 deletion packages/connect-ui/src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
<input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-neutral-500 focus-visible:outline-none focus-visible:border-dark-800 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300 aria-invalid:bg-red-base-35 aria-invalid:border-red-base',
'flex h-10 w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-dark-400 placeholder:italic focus-visible:outline-none focus-visible:border-dark-800 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300 aria-invalid:bg-red-base-35 aria-invalid:border-red-base',
className
)}
type={type}
Expand Down
13 changes: 13 additions & 0 deletions packages/connect-ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { z } from 'zod';

import type { SimplifiedJSONSchema } from '@nangohq/types';

import type { ClassValue } from 'clsx';

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export function jsonSchemaToZod(schema: SimplifiedJSONSchema): z.ZodString {
let field = z.string();
if (schema.format === 'hostname') field = field.regex(/^[a-zA-Z0-9-]$/);
else if (schema.format === 'uuid') field = field.uuid();
else if (schema.format === 'uri') field = field.url();
if (schema.pattern) field = field.regex(new RegExp(schema.pattern), { message: `Incorrect ${schema.title}` });

return field;
}
113 changes: 88 additions & 25 deletions packages/connect-ui/src/views/Go.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { AuthError } from '@nangohq/frontend';
import { IconArrowLeft, IconCircleCheckFilled, IconExclamationCircle, IconX } from '@tabler/icons-react';
import { Link, Navigate } from '@tanstack/react-router';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

Expand All @@ -16,18 +16,17 @@ import { Input } from '@/components/ui/input';
import { triggerClose } from '@/lib/events';
import { nango } from '@/lib/nango';
import { useGlobal } from '@/lib/store';
import { jsonSchemaToZod } from '@/lib/utils';

import type { Resolver } from 'react-hook-form';

const formSchema: Record<AuthModeType, z.AnyZodObject> = {
API_KEY: z.object({
credentials: z.object({
apiKey: z.string().min(1)
})
apiKey: z.string().min(1)
}),
BASIC: z.object({
credentials: z.object({
username: z.string().min(1),
password: z.string().min(1)
})
username: z.string().min(1),
password: z.string().min(1)
}),
APP: z.object({}),
APP_STORE: z.object({}),
Expand Down Expand Up @@ -58,14 +57,6 @@ export const Go: React.FC = () => {

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unsafe-assignment
const authMode = provider?.auth_mode;
const fields = authMode ? formSchema[authMode] : null;
const hasField = fields ? Object.keys(fields.shape).length > 0 : true;
const form = useForm<z.infer<(typeof formSchema)['API_KEY']>>({
resolver: zodResolver(formSchema[authMode || 'NONE']),
defaultValues: {
username: ''
}
});

useEffect(() => {
// on unmount always clear popup and state
Expand All @@ -74,6 +65,43 @@ export const Go: React.FC = () => {
};
}, []);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { resolver, hasField } = useMemo<{ resolver: Resolver<any>; hasField: boolean }>(() => {
if (!provider) {
return { hasField: true, resolver: () => ({ values: {}, errors: {} }) };
}

const authMode = provider.auth_mode;
const baseForm = formSchema[authMode];

// Modify base form with credentials specific
for (const [name, schema] of Object.entries(provider.credentials || [])) {
baseForm.shape[name] = jsonSchemaToZod(schema);
}

// Append connectionConfig object
const additionalFields: z.ZodRawShape = {};
for (const [name, schema] of Object.entries(provider.connection_config || [])) {
additionalFields[name] = jsonSchemaToZod(schema);
}

// Only add objects if they have something otherwise it breaks react-form
const fields = z.object({
...(Object.keys(baseForm.shape).length > 0 ? { credentials: baseForm } : {}),
...(Object.keys(additionalFields).length > 0 ? { params: z.object(additionalFields) } : {})
});

const hasField = Object.keys(fields.shape).length > 0;
const resolver = zodResolver(fields);
return { hasField, resolver };
}, [provider]);

const form = useForm<z.infer<(typeof formSchema)['API_KEY']>>({
resolver: resolver,
mode: 'onChange',
reValidateMode: 'onChange'
});

const onSubmit = useCallback(
async (values: z.infer<(typeof formSchema)[AuthModeType]>) => {
if (!integration || loading || !provider) {
Expand All @@ -95,14 +123,15 @@ export const Go: React.FC = () => {
if (err.type === 'blocked_by_browser') {
setError('Auth pop-up blocked by your browser, please allow pop-ups to open');
return;
}
if (err.type === 'windowClosed') {
} else if (err.type === 'windowClosed') {
setError('The auth pop-up was closed before the end of the process');
return;
} else if (err.type === 'connection_test_failed') {
setError(`${provider.display_name} refused your credentials. Please check the values and try again.`);
return;
}
}

console.error(err);
setError('An error occurred, please try again');
} finally {
setLoading(false);
Expand Down Expand Up @@ -178,15 +207,16 @@ export const Go: React.FC = () => {
control={form.control}
name="credentials.apiKey"
render={({ field }) => {
const def = provider.credentials?.apiKey;
return (
<FormItem>
<div>
<FormLabel>API Key</FormLabel>
<FormDescription>Find your API Key in your own {provider.name} account</FormDescription>
<FormLabel>{def?.title || 'API Key'}</FormLabel>
<FormDescription>{def?.description}</FormDescription>
</div>
<div>
<FormControl>
<Input placeholder="Your API Key" {...field} autoComplete="off" type="password" />
<Input placeholder={def?.example || 'Your API Key'} {...field} autoComplete="off" type="password" />
</FormControl>
<FormMessage />
</div>
Expand Down Expand Up @@ -378,15 +408,48 @@ export const Go: React.FC = () => {
/>
</>
)}

{provider.connection_config &&
Object.entries(provider.connection_config).map(([key, schema]) => {
return (
<FormField
key={key}
control={form.control}
name={`params.${key}`}
render={({ field }) => {
return (
<FormItem>
<div>
<FormLabel>{schema.title}</FormLabel>
{schema.description && <FormDescription>{schema.description}</FormDescription>}
</div>
<div>
<FormControl>
<Input placeholder={schema.example || schema.title} {...field} autoComplete="off" />
</FormControl>
<FormMessage />
</div>
</FormItem>
);
}}
/>
);
})}
</div>
<div>
<div className="flex flex-col gap-4">
{error && (
<div className="border border-red-base bg-red-base-35 text-red-base flex items-center py-1 px-4 rounded gap-2">
<IconExclamationCircle size={17} stroke={1} /> {error}
<IconExclamationCircle size={20} stroke={1} /> {error}
</div>
)}
{hasField && (
<Button className="w-full" loading={loading} size={'lg'} type="submit">
<Button
className="w-full"
disabled={!form.formState.isValid || Object.keys(form.formState.errors).length > 0}
loading={loading}
size={'lg'}
type="submit"
>
Submit
</Button>
)}
Expand Down
2 changes: 1 addition & 1 deletion packages/connect-ui/src/views/IntegrationsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const Integration: React.FC<{ integration: ApiPublicIntegration }> = ({ integrat
<div className="w-[50px] h-[50px] bg-white transition-colors rounded-xl shadow-card p-2.5 group-hover:bg-dark-100">
<img src={integration.logo} />
</div>
<div className="text-zinc-900">{integration.provider}</div>
<div className="text-zinc-900">{integration.display_name}</div>
{error && (
<div className="border border-red-base bg-red-base-35 text-red-base flex items-center py-1 px-4 rounded gap-2">
<IconExclamationCircle size={17} stroke={1} /> {error}
Expand Down
3 changes: 2 additions & 1 deletion packages/frontend/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type AuthErrorType =
| 'missingCredentials'
| 'windowClosed'
| 'request_error'
| 'missing_ws_client_id';
| 'missing_ws_client_id'
| 'connection_test_failed';

export interface AuthResult {
providerConfigKey: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe(`GET ${endpoint}`, () => {
{
provider: 'github',
unique_key: 'github',
display_name: 'GitHub',
logo: 'http://localhost:3003/images/template-logos/github.svg',
created_at: expect.toBeIsoDate(),
updated_at: expect.toBeIsoDate()
Expand Down
11 changes: 9 additions & 2 deletions packages/server/lib/controllers/config/getListIntegrations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { asyncWrapper } from '../../utils/asyncWrapper.js';
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
import type { GetPublicListIntegrationsLegacy } from '@nangohq/types';
import { configService } from '@nangohq/shared';
import { configService, getProviders } from '@nangohq/shared';
import { integrationToPublicApi } from '../../formatters/integration.js';

export const getPublicListIntegrationsLegacy = asyncWrapper<GetPublicListIntegrationsLegacy>(async (req, res) => {
Expand All @@ -13,8 +13,15 @@ export const getPublicListIntegrationsLegacy = asyncWrapper<GetPublicListIntegra

const { environment } = res.locals;
const configs = await configService.listProviderConfigs(environment.id);

const providers = getProviders();
if (!providers) {
res.status(500).send({ error: { code: 'server_error', message: `failed to load providers` } });
return;
}

const results = configs.map((config) => {
return integrationToPublicApi(config);
return integrationToPublicApi({ integration: config, provider: providers[config.provider]! });
});

res.status(200).send({ configs: results });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe(`GET ${endpoint}`, () => {
{
provider: 'github',
unique_key: 'github',
display_name: 'GitHub',
logo: 'http://localhost:3003/images/template-logos/github.svg',
created_at: expect.toBeIsoDate(),
updated_at: expect.toBeIsoDate()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { asyncWrapper } from '../../utils/asyncWrapper.js';
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';
import type { GetPublicListIntegrations } from '@nangohq/types';
import { configService } from '@nangohq/shared';
import { configService, getProviders } from '@nangohq/shared';
import { integrationToPublicApi } from '../../formatters/integration.js';

export const getPublicListIntegrations = asyncWrapper<GetPublicListIntegrations>(async (req, res) => {
Expand All @@ -14,9 +14,15 @@ export const getPublicListIntegrations = asyncWrapper<GetPublicListIntegrations>
const { environment } = res.locals;
const configs = await configService.listProviderConfigs(environment.id);

const providers = getProviders();
if (!providers) {
res.status(500).send({ error: { code: 'server_error', message: `failed to load providers` } });
return;
}

res.status(200).send({
data: configs.map((config) => {
return integrationToPublicApi(config);
return integrationToPublicApi({ integration: config, provider: providers[config.provider]! });
})
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe(`GET ${endpoint}`, () => {
data: {
provider: 'github',
unique_key: 'github',
display_name: 'GitHub',
logo: 'http://localhost:3003/images/template-logos/github.svg',
created_at: expect.toBeIsoDate(),
updated_at: expect.toBeIsoDate()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,6 @@ export const getPublicIntegration = asyncWrapper<GetPublicIntegration>(async (re
}

res.status(200).send({
data: integrationToPublicApi(integration, include)
data: integrationToPublicApi({ integration, include, provider })
});
});
23 changes: 16 additions & 7 deletions packages/server/lib/formatters/integration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ApiIntegration, ApiPublicIntegration, ApiPublicIntegrationInclude, IntegrationConfig } from '@nangohq/types';
import type { ApiIntegration, ApiPublicIntegration, ApiPublicIntegrationInclude, IntegrationConfig, Provider } from '@nangohq/types';
import { basePublicUrl } from '@nangohq/utils';

export function integrationToApi(data: IntegrationConfig): ApiIntegration {
Expand All @@ -9,13 +9,22 @@ export function integrationToApi(data: IntegrationConfig): ApiIntegration {
};
}

export function integrationToPublicApi(data: IntegrationConfig, include?: ApiPublicIntegrationInclude): ApiPublicIntegration {
export function integrationToPublicApi({
integration,
include,
provider
}: {
integration: IntegrationConfig;
provider: Provider;
include?: ApiPublicIntegrationInclude;
}): ApiPublicIntegration {
return {
unique_key: data.unique_key,
provider: data.provider,
logo: `${basePublicUrl}/images/template-logos/${data.provider}.svg`,
unique_key: integration.unique_key,
provider: integration.provider,
display_name: provider.display_name,
logo: `${basePublicUrl}/images/template-logos/${integration.provider}.svg`,
...include,
created_at: data.created_at.toISOString(),
updated_at: data.updated_at.toISOString()
created_at: integration.created_at.toISOString(),
updated_at: integration.updated_at.toISOString()
};
}
Loading

0 comments on commit 82bfc9e

Please sign in to comment.