fix styling and remove additional components

This commit is contained in:
msukkari 2025-09-17 15:17:47 -07:00
parent 101f29b967
commit 0ef5b49d0d
14 changed files with 29 additions and 1212 deletions

View file

@ -1,49 +0,0 @@
'use client';
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { bitbucketCloudQuickActions } from "../../connections/quickActions";
interface BitbucketCloudConnectionCreationFormProps {
onCreated?: (id: number) => void;
}
const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
const hasWorkspaces = config.workspaces && config.workspaces.length > 0 && config.workspaces.some(w => w.trim().length > 0);
if (!hasProjects && !hasRepos && !hasWorkspaces) {
return {
message: "At least one project, repository, or workspace must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
};
export const BitbucketCloudConnectionCreationForm = ({ onCreated }: BitbucketCloudConnectionCreationFormProps) => {
const defaultConfig: BitbucketConnectionConfig = {
type: 'bitbucket',
deploymentType: 'cloud',
}
return (
<SharedConnectionCreationForm<BitbucketConnectionConfig>
type="bitbucket-cloud"
title="Create a Bitbucket Cloud connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={bitbucketSchema}
additionalConfigValidation={additionalConfigValidation}
quickActions={bitbucketCloudQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -1,48 +0,0 @@
'use client';
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { bitbucketDataCenterQuickActions } from "../../connections/quickActions";
interface BitbucketDataCenterConnectionCreationFormProps {
onCreated?: (id: number) => void;
}
const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
if (!hasProjects && !hasRepos) {
return {
message: "At least one project or repository must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
};
export const BitbucketDataCenterConnectionCreationForm = ({ onCreated }: BitbucketDataCenterConnectionCreationFormProps) => {
const defaultConfig: BitbucketConnectionConfig = {
type: 'bitbucket',
deploymentType: 'server',
}
return (
<SharedConnectionCreationForm<BitbucketConnectionConfig>
type="bitbucket-server"
title="Create a Bitbucket Data Center connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={bitbucketSchema}
additionalConfigValidation={additionalConfigValidation}
quickActions={bitbucketDataCenterQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -1,47 +0,0 @@
'use client';
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { gerritQuickActions } from "../../connections/quickActions";
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
interface GerritConnectionCreationFormProps {
onCreated?: (id: number) => void;
}
const additionalConfigValidation = (config: GerritConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0;
if (!hasProjects) {
return {
message: "At least one project must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
}
export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => {
const defaultConfig: GerritConnectionConfig = {
type: 'gerrit',
url: "https://gerrit.example.com"
}
return (
<SharedConnectionCreationForm<GerritConnectionConfig>
type="gerrit"
title="Create a Gerrit connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={gerritSchema}
quickActions={gerritQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated}
/>
)
}

View file

@ -1,48 +0,0 @@
'use client';
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { giteaQuickActions } from "../../connections/quickActions";
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
interface GiteaConnectionCreationFormProps {
onCreated?: (id: number) => void;
}
const additionalConfigValidation = (config: GiteaConnectionConfig): { message: string, isValid: boolean } => {
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
if (!hasOrgs && !hasUsers && !hasRepos) {
return {
message: "At least one organization, user, or repository must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
}
export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => {
const defaultConfig: GiteaConnectionConfig = {
type: 'gitea',
}
return (
<SharedConnectionCreationForm<GiteaConnectionConfig>
type="gitea"
title="Create a Gitea connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={giteaSchema}
quickActions={giteaQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated}
/>
)
}

View file

@ -1,48 +0,0 @@
'use client';
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { githubQuickActions } from "../../connections/quickActions";
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
interface GitHubConnectionCreationFormProps {
onCreated?: (id: number) => void;
}
const additionalConfigValidation = (config: GithubConnectionConfig): { message: string, isValid: boolean } => {
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
if (!hasRepos && !hasOrgs && !hasUsers) {
return {
message: "At least one repository, organization, or user must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
};
export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => {
const defaultConfig: GithubConnectionConfig = {
type: 'github',
}
return (
<SharedConnectionCreationForm<GithubConnectionConfig>
type="github"
title="Create a GitHub connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={githubSchema}
additionalConfigValidation={additionalConfigValidation}
quickActions={githubQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -1,49 +0,0 @@
'use client';
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { gitlabQuickActions } from "../../connections/quickActions";
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
interface GitLabConnectionCreationFormProps {
onCreated?: (id: number) => void;
}
const additionalConfigValidation = (config: GitlabConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
const hasGroups = config.groups && config.groups.length > 0 && config.groups.some(g => g.trim().length > 0);
const hasAll = config.all;
if (!hasProjects && !hasUsers && !hasGroups && !hasAll) {
return {
message: "At least one project, user, or group must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
}
export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => {
const defaultConfig: GitlabConnectionConfig = {
type: 'gitlab',
}
return (
<SharedConnectionCreationForm<GitlabConnectionConfig>
type="gitlab"
title="Create a GitLab connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={gitlabSchema}
quickActions={gitlabQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated}
/>
)
}

View file

@ -1,6 +0,0 @@
export { GitHubConnectionCreationForm } from "./githubConnectionCreationForm";
export { GitLabConnectionCreationForm } from "./gitlabConnectionCreationForm";
export { GiteaConnectionCreationForm } from "./giteaConnectionCreationForm";
export { GerritConnectionCreationForm } from "./gerritConnectionCreationForm";
export { BitbucketCloudConnectionCreationForm } from "./bitbucketCloudConnectionCreationForm";
export { BitbucketDataCenterConnectionCreationForm } from "./bitbucketDataCenterConnectionCreationForm";

View file

@ -1,163 +0,0 @@
'use client';
import { getSecrets } from "@/actions";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useDomain } from "@/hooks/useDomain";
import { cn, CodeHostType, isDefined, isServiceError, unwrapServiceError } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronsUpDown, Loader2, PlusCircleIcon, TriangleAlert } from "lucide-react";
import { useCallback, useState } from "react";
import { ImportSecretDialog } from "../importSecretDialog";
interface SecretComboBoxProps {
isDisabled: boolean;
codeHostType: CodeHostType;
secretKey?: string;
onSecretChange: (secretKey: string) => void;
}
export const SecretCombobox = ({
isDisabled,
codeHostType,
secretKey,
onSecretChange,
}: SecretComboBoxProps) => {
const [searchFilter, setSearchFilter] = useState("");
const domain = useDomain();
const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
const captureEvent = useCaptureEvent();
const { data: secrets, isPending, isError, refetch } = useQuery({
queryKey: ["secrets", domain],
queryFn: () => unwrapServiceError(getSecrets(domain)),
});
const onSecretCreated = useCallback((key: string) => {
onSecretChange(key);
refetch();
}, [onSecretChange, refetch]);
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[300px] overflow-hidden",
!secretKey && "text-muted-foreground"
)}
disabled={isDisabled}
>
{!(isPending || isError) && isDefined(secretKey) && !secrets.some(({ key }) => key === secretKey) && (
<TooltipProvider>
<Tooltip
delayDuration={100}
>
<TooltipTrigger
onClick={(e) => e.preventDefault()}
>
<TriangleAlert className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
</TooltipTrigger>
<TooltipContent>
<p>The secret you selected does not exist.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<span className="truncate">{isDefined(secretKey) ? secretKey : "Select secret"}</span>
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0.5">
{isPending ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : isError ? (
<p className="p-2 text-sm text-destructive">Failed to load secrets</p>
) : secrets.length > 0 && (
<>
<Command className="mb-2">
<CommandInput
placeholder="Search secrets..."
value={searchFilter}
onValueChange={(value) => setSearchFilter(value)}
/>
<CommandList>
<CommandEmpty>
<p className="text-sm">No secrets found</p>
<p className="text-sm text-muted-foreground">{`Your search term "${searchFilter}" did not match any secrets.`}</p>
</CommandEmpty>
<CommandGroup
heading="Secrets"
>
{secrets.map(({ key }) => (
<CommandItem
className="cursor-pointer"
value={key}
key={key}
onSelect={() => {
onSecretChange(key);
}}
>
{key}
<Check
className={cn(
"ml-auto",
key === secretKey
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<Separator className="mt-2" />
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsCreateSecretDialogOpen(true);
captureEvent('wa_secret_combobox_import_secret_pressed', {
type: codeHostType,
});
}}
className={cn(
"w-full justify-start gap-1.5 p-2",
secrets && !isServiceError(secrets) && secrets.length > 0 && "my-2"
)}
>
<PlusCircleIcon className="h-5 w-5 text-muted-foreground mr-1" />
Import a secret
</Button>
</PopoverContent>
</Popover>
<ImportSecretDialog
open={isCreateSecretDialogOpen}
onOpenChange={setIsCreateSecretDialogOpen}
onSecretCreated={onSecretCreated}
codeHostType={codeHostType}
/>
</>
)
}

View file

@ -1,239 +0,0 @@
'use client';
import { checkIfSecretExists, createConnection } from "@/actions";
import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { CodeHostType, isServiceError, isAuthSupportedForCodeHost } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Schema } from "ajv";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import ConfigEditor, { isConfigValidJson, onQuickAction, QuickActionFn } from "../configEditor";
import { useDomain } from "@/hooks/useDomain";
import { InfoIcon, Loader2 } from "lucide-react";
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { SecretCombobox } from "./secretCombobox";
import strings from "@/lib/strings";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface SharedConnectionCreationFormProps<T> {
type: CodeHostType;
defaultValues: {
name?: string;
config: string;
};
title: string;
schema: Schema;
quickActions?: {
name: string;
fn: QuickActionFn<T>;
}[],
className?: string;
onCreated?: (id: number) => void;
additionalConfigValidation?: (config: T) => { message: string, isValid: boolean };
}
export default function SharedConnectionCreationForm<T>({
type,
defaultValues,
title,
schema,
quickActions,
className,
onCreated,
additionalConfigValidation
}: SharedConnectionCreationFormProps<T>) {
const { toast } = useToast();
const domain = useDomain();
const editorRef = useRef<ReactCodeMirrorRef>(null);
const captureEvent = useCaptureEvent();
const formSchema = useMemo(() => {
return z.object({
name: z.string().min(1),
config: createZodConnectionConfigValidator(schema, additionalConfigValidation),
secretKey: z.string().optional().refine(async (secretKey) => {
if (!secretKey) {
return true;
}
return checkIfSecretExists(secretKey, domain);
}, { message: "Secret not found" }),
});
}, [schema, domain, additionalConfigValidation]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues,
});
const { isSubmitting } = form.formState;
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
const response = await createConnection(data.name, type, data.config, domain);
if (isServiceError(response)) {
toast({
description: `❌ Failed to create connection. Reason: ${response.message}`
});
captureEvent('wa_create_connection_fail', {
type: type,
error: response.message,
});
} else {
toast({
description: `✅ Connection created successfully.`
});
captureEvent('wa_create_connection_success', {
type: type,
});
onCreated?.(response.id);
}
}, [domain, toast, type, onCreated, captureEvent]);
const onConfigChange = useCallback((value: string) => {
form.setValue("config", value);
const isValid = isConfigValidJson(value);
setIsSecretsDisabled(!isValid);
if (isValid) {
const configJson = JSON.parse(value);
if (configJson.token?.secret !== undefined) {
form.setValue("secretKey", configJson.token.secret);
} else {
form.setValue("secretKey", undefined);
}
}
}, [form]);
// Run onConfigChange on mount to set the initial secret key
useEffect(() => {
onConfigChange(defaultValues.config);
}, [defaultValues, onConfigChange]);
const [isSecretsDisabled, setIsSecretsDisabled] = useState(false);
return (
<div className={cn("flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6", className)}>
<div className="flex flex-col gap-4 mb-8">
<div className="flex flex-row items-center gap-3">
<ConnectionIcon
type={type}
className="w-7 h-7"
/>
<h1 className="text-3xl">{title}</h1>
</div>
<span className="flex flex-row items-center">
<InfoIcon className="w-4 h-4 mr-2" />Connections are used to specify what repositories you want Sourcebot to sync.
</span>
</div>
<Form
{...form}
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormDescription>This is the {`connection's`} display name within Sourcebot.</FormDescription>
<FormControl>
<Input
{...field}
spellCheck={false}
autoFocus={true}
placeholder="my-connection"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isAuthSupportedForCodeHost(type) && (
<FormField
control={form.control}
name="secretKey"
render={({ field: { value } }) => (
<FormItem>
<FormLabel>Secret (optional)</FormLabel>
<FormDescription>{strings.createSecretDescription}</FormDescription>
<FormControl>
<SecretCombobox
isDisabled={isSecretsDisabled}
secretKey={value}
codeHostType={type}
onSecretChange={(secretKey) => {
const view = editorRef.current?.view;
if (!view) {
return;
}
onQuickAction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(previous: any) => {
return {
...previous,
token: {
secret: secretKey,
}
}
},
form.getValues("config"),
view,
{
focusEditor: false
}
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="config"
render={({ field: { value } }) => {
return (
<FormItem>
<FormLabel>Configuration</FormLabel>
<FormDescription>{strings.connectionConfigDescription}</FormDescription>
<FormControl>
<ConfigEditor<T>
ref={editorRef}
type={type}
value={value}
onChange={onConfigChange}
actions={quickActions ?? []}
schema={schema}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
</div>
<div className="flex flex-row justify-end">
<Button
className="mt-5"
type="submit"
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="animate-spin w-4 h-4 mr-2" />}
Submit
</Button>
</div>
</form>
</Form>
</div>
)
}

View file

@ -1,273 +0,0 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import ConfigEditor, { isConfigValidJson, onQuickAction, QuickAction } from "../../../components/configEditor";
import { createZodConnectionConfigValidator } from "../../utils";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickActions, bitbucketCloudQuickActions, bitbucketDataCenterQuickActions } from "../../quickActions";
import { Schema } from "ajv";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { checkIfSecretExists, updateConnectionConfigAndScheduleSync } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError, CodeHostType, isAuthSupportedForCodeHost } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { useDomain } from "@/hooks/useDomain";
import { SecretCombobox } from "@/app/[domain]/components/connectionCreationForms/secretCombobox";
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import strings from "@/lib/strings";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
interface ConfigSettingProps {
connectionId: number;
config: string;
type: CodeHostType;
disabled?: boolean;
}
export const ConfigSetting = (props: ConfigSettingProps) => {
const { type } = props;
if (type === 'github') {
return <ConfigSettingInternal<GithubConnectionConfig>
{...props}
type="github"
quickActions={githubQuickActions}
schema={githubSchema}
/>;
}
if (type === 'gitlab') {
return <ConfigSettingInternal<GitlabConnectionConfig>
{...props}
type="gitlab"
quickActions={gitlabQuickActions}
schema={gitlabSchema}
/>;
}
if (type === 'bitbucket-cloud') {
return <ConfigSettingInternal<BitbucketConnectionConfig>
{...props}
type="bitbucket-cloud"
quickActions={bitbucketCloudQuickActions}
schema={bitbucketSchema}
/>;
}
if (type === 'bitbucket-server') {
return <ConfigSettingInternal<BitbucketConnectionConfig>
{...props}
type="bitbucket-server"
quickActions={bitbucketDataCenterQuickActions}
schema={bitbucketSchema}
/>;
}
if (type === 'gitea') {
return <ConfigSettingInternal<GiteaConnectionConfig>
{...props}
type="gitea"
quickActions={giteaQuickActions}
schema={giteaSchema}
/>;
}
if (type === 'gerrit') {
return <ConfigSettingInternal<GerritConnectionConfig>
{...props}
type="gerrit"
quickActions={gerritQuickActions}
schema={gerritSchema}
/>;
}
return null;
}
function ConfigSettingInternal<T>({
connectionId,
config,
quickActions,
schema,
type,
disabled,
}: ConfigSettingProps & {
quickActions?: QuickAction<T>[],
schema: Schema,
type: CodeHostType,
disabled?: boolean,
}) {
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const editorRef = useRef<ReactCodeMirrorRef>(null);
const [isSecretsDisabled, setIsSecretsDisabled] = useState(false);
const formSchema = useMemo(() => {
return z.object({
config: createZodConnectionConfigValidator(schema),
secretKey: z.string().optional().refine(async (secretKey) => {
if (!secretKey) {
return true;
}
return checkIfSecretExists(secretKey, domain);
}, { message: "Secret not found" })
});
}, [schema, domain]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
config,
},
});
const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true);
updateConnectionConfigAndScheduleSync(connectionId, data.config, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to update connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection config updated successfully.`
});
router.push(`?tab=overview`);
router.refresh();
}
})
.finally(() => {
setIsLoading(false);
})
}, [connectionId, domain, router, toast]);
const onConfigChange = useCallback((value: string) => {
form.setValue("config", value);
const isValid = isConfigValidJson(value);
setIsSecretsDisabled(!isValid);
if (isValid) {
const configJson = JSON.parse(value);
if (configJson.token?.secret !== undefined) {
form.setValue("secretKey", configJson.token.secret);
} else {
form.setValue("secretKey", undefined);
}
}
}, [form]);
useEffect(() => {
onConfigChange(config);
}, [config, onConfigChange]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-2">Configuration</h3>
<Form
{...form}
>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
{isAuthSupportedForCodeHost(type) && (
<FormField
control={form.control}
name="secretKey"
render={({ field: { value } }) => (
<FormItem>
<FormLabel>Secret (optional)</FormLabel>
<FormDescription>{strings.createSecretDescription}</FormDescription>
<FormControl>
<SecretCombobox
isDisabled={isSecretsDisabled}
secretKey={value}
codeHostType={type}
onSecretChange={(secretKey) => {
const view = editorRef.current?.view;
if (!view) {
return;
}
onQuickAction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(previous: any) => {
return {
...previous,
token: {
secret: secretKey,
}
}
},
form.getValues("config"),
view,
{
focusEditor: false
}
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="config"
render={({ field: { value } }) => (
<FormItem>
<FormItem>
{isAuthSupportedForCodeHost(type) && (
<FormLabel>Configuration</FormLabel>
)}
<FormDescription>{strings.connectionConfigDescription}</FormDescription>
<FormControl>
<ConfigEditor<T>
ref={editorRef}
type={type}
value={value}
onChange={onConfigChange}
schema={schema}
actions={quickActions ?? []}
/>
</FormControl>
<FormMessage />
</FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 flex justify-end">
<Button
size="sm"
type="submit"
disabled={isLoading || disabled}
>
{isLoading && <Loader2 className="animate-spin mr-2" />}
{isLoading ? 'Syncing...' : 'Save'}
</Button>
</div>
</form>
</Form>
</div>
);
}

View file

@ -28,10 +28,9 @@ export default async function ConnectionManagementPage(props: ConnectionManageme
}
return (
<div className="min-h-screen bg-background">
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="mb-8">
<Breadcrumb className="mb-6">
<div>
<Header>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href={`/${params.domain}/connections`}>Connections</BreadcrumbLink>
@ -42,26 +41,23 @@ export default async function ConnectionManagementPage(props: ConnectionManageme
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center gap-3">
<div className="mt-6 flex items-center gap-3">
<ConnectionIcon type={connection.connectionType} />
<h1 className="text-2xl font-semibold text-foreground">{connection.name}</h1>
</div>
<h1 className="text-3xl font-semibold">{connection.name}</h1>
</div>
</Header>
<div className="border-t border-border/40 pt-8">
<div className="space-y-12">
<div className="space-y-8">
<div>
<h2 className="text-lg font-medium text-foreground mb-6">Overview</h2>
<h2 className="text-lg font-medium mb-4">Overview</h2>
<Overview connectionId={connection.id} />
</div>
<div>
<h2 className="text-lg font-medium text-foreground mb-6">Linked Repositories</h2>
<h2 className="text-lg font-medium mb-4">Linked Repositories</h2>
<RepoList connectionId={connection.id} />
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -1,148 +0,0 @@
"use client"
import { cn, type CodeHostType, getCodeHostIcon } from "@/lib/utils"
import placeholderLogo from "@/public/placeholder_avatar.png"
import { BlocksIcon, LockIcon } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useMemo } from "react"
import { OrgRole } from "@sourcebot/db"
interface NewConnectionCardProps {
className?: string
role: OrgRole
configPathProvided: boolean
}
export const NewConnectionCard = ({ className, role, configPathProvided }: NewConnectionCardProps) => {
const isOwner = role === OrgRole.OWNER
const isDisabled = !isOwner || configPathProvided
return (
<div
className={cn(
"flex flex-col border rounded-lg p-4 h-fit relative",
isDisabled && "bg-muted/10 border-muted cursor-not-allowed",
className,
)}
>
{isDisabled && (
<div className="absolute right-3 top-3">
<LockIcon className="w-4 h-4 text-muted-foreground" />
</div>
)}
<BlocksIcon className={cn("mx-auto w-7 h-7", isDisabled && "text-muted-foreground")} />
<h2 className={cn("mx-auto mt-4 font-medium text-lg", isDisabled && "text-muted-foreground")}>
Connect to a Code Host
</h2>
<p className="mx-auto text-center text-sm text-muted-foreground font-light">
Create a connection to import repos from a code host.
</p>
<div className="flex flex-col gap-2 mt-4">
<Card
type="github"
title="GitHub"
subtitle="Cloud or Enterprise supported."
disabled={isDisabled}
/>
<Card
type="gitlab"
title="GitLab"
subtitle="Cloud and Self-Hosted supported."
disabled={isDisabled}
/>
<Card
type="bitbucket-cloud"
title="Bitbucket Cloud"
subtitle="Fetch repos from Bitbucket Cloud."
disabled={isDisabled}
/>
<Card
type="bitbucket-server"
title="Bitbucket Data Center"
subtitle="Fetch repos from a Bitbucket DC instance."
disabled={isDisabled}
/>
<Card
type="gitea"
title="Gitea"
subtitle="Cloud and Self-Hosted supported."
disabled={isDisabled}
/>
<Card
type="gerrit"
title="Gerrit"
subtitle="Cloud and Self-Hosted supported."
disabled={isDisabled}
/>
</div>
{isDisabled && (
<p className="mt-4 text-xs text-center text-muted-foreground">
{configPathProvided
? "Connections are managed through the configuration file."
: "Only organization owners can manage connections."}
</p>
)}
</div>
)
}
interface CardProps {
type: string
title: string
subtitle: string
disabled?: boolean
}
const Card = ({ type, title, subtitle, disabled = false }: CardProps) => {
const Icon = useMemo(() => {
const iconInfo = getCodeHostIcon(type as CodeHostType)
if (iconInfo) {
const { src, className } = iconInfo
return (
<Image
src={src || "/placeholder.svg"}
className={cn("rounded-full w-7 h-7 mb-1", className, disabled && "opacity-50")}
alt={`${type} logo`}
/>
)
}
return (
<Image
src={placeholderLogo || "/placeholder.svg"}
alt={`${type} logo`}
className={cn("rounded-full w-7 h-7 mb-1", disabled && "opacity-50")}
/>
)
}, [type, disabled])
const CardContent = (
<div
className={cn(
"flex flex-row justify-between items-center p-2",
disabled ? "cursor-not-allowed" : "cursor-pointer",
disabled && "opacity-70",
)}
>
<div className="flex flex-row items-center gap-2">
{Icon}
<div>
<p className={cn("font-medium", disabled && "text-muted-foreground")}>{title}</p>
<p className="text-sm text-muted-foreground font-light">{subtitle}</p>
</div>
</div>
</div>
)
if (disabled) {
return CardContent
}
return (
<Link className="flex flex-row justify-between items-center cursor-pointer" href={`connections/new/${type}`}>
{CardContent}
</Link>
)
}

View file

@ -1,51 +0,0 @@
'use client';
import { useRouter } from "next/navigation";
import {
GitHubConnectionCreationForm,
GitLabConnectionCreationForm,
GiteaConnectionCreationForm,
GerritConnectionCreationForm,
BitbucketCloudConnectionCreationForm,
BitbucketDataCenterConnectionCreationForm
} from "@/app/[domain]/components/connectionCreationForms";
import { useCallback, use } from "react";
import { useDomain } from "@/hooks/useDomain";
export default function NewConnectionPage(props: { params: Promise<{ type: string }> }) {
const params = use(props.params);
const { type } = params;
const router = useRouter();
const domain = useDomain();
const onCreated = useCallback(() => {
router.push(`/${domain}/connections`);
}, [domain, router]);
if (type === 'github') {
return <GitHubConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'gitlab') {
return <GitLabConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'gitea') {
return <GiteaConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'gerrit') {
return <GerritConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'bitbucket-cloud') {
return <BitbucketCloudConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'bitbucket-server') {
return <BitbucketDataCenterConnectionCreationForm onCreated={onCreated} />;
}
router.push(`/${domain}/connections`);
}

View file

@ -1,11 +1,9 @@
import { ConnectionList } from "./components/connectionList";
import { Header } from "../components/header";
import { NewConnectionCard } from "./components/newConnectionCard";
import { getConnections, getOrgMembership } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { notFound, ServiceErrorException } from "@/lib/serviceError";
import { OrgRole } from "@sourcebot/db";
import { env } from "@/env.mjs";
export default async function ConnectionsPage(props: { params: Promise<{ domain: string }> }) {
const params = await props.params;
@ -29,17 +27,9 @@ export default async function ConnectionsPage(props: { params: Promise<{ domain:
<Header>
<h1 className="text-3xl">Connections</h1>
</Header>
<div className="flex flex-col md:flex-row gap-4">
<ConnectionList
className="md:w-3/4"
isDisabled={membership.role !== OrgRole.OWNER}
/>
<NewConnectionCard
className="md:w-1/4"
role={membership.role}
configPathProvided={env.CONFIG_PATH !== undefined}
/>
</div>
</div>
);
}