diff --git a/airbyte-webapp/src/components/ui/ComboBox/ComboBox.tsx b/airbyte-webapp/src/components/ui/ComboBox/ComboBox.tsx
index 0273fbc6ce3..2972a0a062b 100644
--- a/airbyte-webapp/src/components/ui/ComboBox/ComboBox.tsx
+++ b/airbyte-webapp/src/components/ui/ComboBox/ComboBox.tsx
@@ -22,7 +22,7 @@ export interface Option {
description?: string;
}
-interface OptionSection {
+export interface OptionSection {
sectionTitle?: string;
innerOptions: Option[];
}
@@ -53,6 +53,7 @@ export interface ComboBoxProps extends BaseProps {
disabled?: boolean;
allowCustomValue?: boolean;
optionsConfig?: OptionsConfig;
+ "data-testid"?: string;
}
export interface MultiComboBoxProps extends BaseProps {
@@ -85,23 +86,6 @@ const ComboBoxOption = ({ option }: { option: Option }) => (
);
-const getOptionsList = ({ optionSections }: { optionSections: OptionSection[] }) => {
- return optionSections.map(({ sectionTitle, innerOptions }, index) => (
-
- {sectionTitle && (
-
-
- {sectionTitle}
-
-
- )}
- {innerOptions.map((option) => (
-
- ))}
-
- ));
-};
-
const OptionsLoading = ({ message }: { message: ReactNode }) => {
return (
{
);
};
-const Options = React.forwardRef(
- ({ optionSections, loadingMessage, instructionMessage, loading = false }, ref) => {
- const { formatMessage } = useIntl();
- const defaultLoadingMessage = formatMessage({ id: "ui.loading" });
- const optionsList = getOptionsList({ optionSections });
- if (optionSections.length === 0 && !loading) {
- return null;
- }
+const Options: React.FC = ({ optionSections, loadingMessage, instructionMessage, loading = false }) => {
+ const { formatMessage } = useIntl();
+ const defaultLoadingMessage = formatMessage({ id: "ui.loading" });
+ const optionsList = optionSections.map(({ sectionTitle, innerOptions }, index) => (
+
+ {sectionTitle && (
+
+
+ {sectionTitle}
+
+
+ )}
+ {innerOptions.map((option) => (
+
+ ))}
+
+ ));
- if (loading) {
- optionsList.unshift();
- }
+ if (optionSections.length === 0 && !loading) {
+ return null;
+ }
- if (instructionMessage) {
- optionsList.unshift();
- }
+ if (loading) {
+ optionsList.unshift();
+ }
- return (
-
- {optionsList}
-
- );
+ if (instructionMessage) {
+ optionsList.unshift();
}
-);
-Options.displayName = "Options";
+
+ return <>{optionsList}>;
+};
const normalizeOptionsAsSections = (options: Option[] | OptionSection[]): OptionSection[] => {
if (options.length === 0) {
@@ -210,6 +201,7 @@ export const ComboBox = ({
optionsConfig,
filterOptions = true,
allowCustomValue,
+ "data-testid": testId,
}: ComboBoxProps) => {
// Stores the value that the user types in to filter the options
const [query, setQuery] = useState("");
@@ -230,12 +222,14 @@ export const ComboBox = ({
return value;
}
- return undefined;
+ return "";
}, [allowCustomValue, inputOptionSections, query, value]);
const displayOptionSections = useMemo(() => {
+ const nonEmptyOptionSections = inputOptionSections.filter((section) => section.innerOptions.length > 0);
+
const filteredOptionSections =
- filterOptions && query ? filterOptionSectionsByQuery(inputOptionSections, query) : inputOptionSections;
+ filterOptions && query ? filterOptionSectionsByQuery(nonEmptyOptionSections, query) : nonEmptyOptionSections;
const shouldAddCustomValue =
allowCustomValue && currentInputValue && isCustomValue(currentInputValue, filteredOptionSections);
@@ -246,23 +240,25 @@ export const ComboBox = ({
return (
onChange(newValue ?? "")}
onClose={() => {
setQuery("");
}}
immediate
as="div"
+ data-testid={testId}
>
-
+
+
)
@@ -277,13 +273,18 @@ export const ComboBox = ({
onChange(selectedOption?.value ?? newQuery);
} else if (selectedOption) {
onChange(selectedOption.value);
+ } else {
+ onChange("");
}
}}
onBlur={onBlur ? (e) => onBlur?.(e) : fieldInputProps?.onBlur}
disabled={disabled}
+ data-testid={testId ? `${testId}--input` : undefined}
/>
-
+
+
+
);
@@ -309,6 +310,8 @@ export const MultiComboBox = ({
disabled={disabled}
/>
-
+
+
+
);
diff --git a/airbyte-webapp/src/core/api/hooks/connectorBuilderProject.ts b/airbyte-webapp/src/core/api/hooks/connectorBuilderProject.ts
index f4e7143e049..9d8ca23f2aa 100644
--- a/airbyte-webapp/src/core/api/hooks/connectorBuilderProject.ts
+++ b/airbyte-webapp/src/core/api/hooks/connectorBuilderProject.ts
@@ -18,6 +18,7 @@ import {
updateConnectorBuilderProjectTestingValues,
updateDeclarativeManifestVersion,
getDeclarativeManifestBaseImage,
+ createForkedConnectorBuilderProject,
} from "../generated/AirbyteClient";
import { SCOPE_WORKSPACE } from "../scopes";
import {
@@ -34,6 +35,7 @@ import {
ConnectorBuilderProjectStreamReadSlicesItemStateItem,
DeclarativeManifestRequestBody,
DeclarativeManifestBaseImageRead,
+ SourceDefinitionId,
} from "../types/AirbyteClient";
import { DeclarativeComponentSchema, DeclarativeStream, NoPaginationType } from "../types/ConnectorManifest";
import { useRequestOptions } from "../useRequestOptions";
@@ -149,6 +151,27 @@ export const useCreateBuilderProject = () => {
);
};
+export const useCreateSourceDefForkedBuilderProject = () => {
+ const requestOptions = useRequestOptions();
+ const queryClient = useQueryClient();
+ const workspaceId = useCurrentWorkspaceId();
+
+ return useMutation(
+ async (sourceDefinitionId) => {
+ return createForkedConnectorBuilderProject(
+ { workspaceId, baseActorDefinitionId: sourceDefinitionId },
+ requestOptions
+ );
+ },
+ {
+ onSuccess: () => {
+ // invalidate cached projects list
+ queryClient.invalidateQueries(connectorBuilderProjectsKeys.list(workspaceId));
+ },
+ }
+ );
+};
+
export const useDeleteBuilderProject = () => {
const queryClient = useQueryClient();
const requestOptions = useRequestOptions();
diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json
index f8c57adeb63..ebb45b92d26 100644
--- a/airbyte-webapp/src/locales/en.json
+++ b/airbyte-webapp/src/locales/en.json
@@ -1541,7 +1541,10 @@
"connectorBuilder.generatePage.firstStreamLabel": "First Data Stream",
"connectorBuilder.generatePage.firstStreamPlaceholder": "e.g. users",
"connectorBuilder.generatePage.firstStreamTooltip": "The name of the first object from the API that you want to load into your destination.",
- "connectorBuilder.forkPage.prompt": "Load an existing connector",
+ "connectorBuilder.forkPage.prompt": "Fork an existing connector",
+ "connectorBuilder.forkPage.description": "Create a new builder connector based off of an existing connector",
+ "connectorBuilder.forkPage.connectorSelectionText.builderProject": "Copy {version} of {name} into a new custom connector.",
+ "connectorBuilder.forkPage.connectorSelectionText.catalogConnector": "Copy the latest {name} version into a new custom connector, with the option to contribute your changes back to Airbyte's catalog.",
"connectorBuilder.loadingStreamList": "Loading",
"connectorBuilder.noStreamSelected": "No stream selected",
"connectorBuilder.streamTestLimitReached": "Stream testing limit reached. During testing a maximum of {recordLimit} records, or {sliceLimit} stream partitions with {pageLimit} pages each will be returned.",
@@ -1568,9 +1571,9 @@
"connectorBuilder.deleteProjectModal.submitButton": "Delete",
"connectorBuilder.deleteProject.error": "Failed to delete custom connector: {reason}",
"connectorBuilder.deleteProject.publishedTooltip": "It's not possible to delete a builder connector after being published",
- "connectorBuilder.createPage.loadExistingConnector.title": "Load an existing connector",
- "connectorBuilder.createPage.loadExistingConnector.description": "Select an existing builder connector to use as a starting point",
- "connectorBuilder.createPage.loadExistingConnector.button": "Load a connector",
+ "connectorBuilder.createPage.forkExistingConnector.title": "Fork an existing connector",
+ "connectorBuilder.createPage.forkExistingConnector.description": "Select an existing connector to use as a starting point",
+ "connectorBuilder.createPage.forkExistingConnector.button": "Fork a connector",
"connectorBuilder.forkPage.copyName": "{oldName} - copy",
"connectorBuilder.forkPage.createLabel": "Create",
"connectorBuilder.backButtonLabel": "Back",
diff --git a/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderCreatePage/ConnectorBuilderCreatePage.tsx b/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderCreatePage/ConnectorBuilderCreatePage.tsx
index 3545f6737c3..3a344af977a 100644
--- a/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderCreatePage/ConnectorBuilderCreatePage.tsx
+++ b/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderCreatePage/ConnectorBuilderCreatePage.tsx
@@ -32,6 +32,7 @@ import StartFromScratchImage from "./start-from-scratch.svg?react";
import { AirbyteTitle } from "../components/AirbyteTitle";
import { BackButton } from "../components/BackButton";
import { useCreateAndNavigate } from "../components/useCreateAndNavigate";
+import { useManifestOnlySourceDefinitions } from "../components/useManifestOnlySourceDefinitions";
import { ConnectorBuilderRoutePaths } from "../ConnectorBuilderRoutes";
const YAML_UPLOAD_ERROR_ID = "connectorBuilder.yamlUpload.error";
@@ -39,6 +40,8 @@ const YAML_UPLOAD_ERROR_ID = "connectorBuilder.yamlUpload.error";
const ConnectorBuilderCreatePageInner: React.FC = () => {
const analyticsService = useAnalyticsService();
const existingProjects = useListBuilderProjects();
+ const { manifestOnlySourceDefinitions } = useManifestOnlySourceDefinitions();
+
const [activeTile, setActiveTile] = useState<"yaml" | "empty" | undefined>();
const navigate = useNavigate();
@@ -155,12 +158,12 @@ const ConnectorBuilderCreatePageInner: React.FC = () => {
}}
dataTestId="import-yaml"
/>
- {existingProjects.length > 0 && (
+ {(existingProjects.length > 0 || manifestOnlySourceDefinitions.length > 0) && (
}
- title="connectorBuilder.createPage.loadExistingConnector.title"
- description="connectorBuilder.createPage.loadExistingConnector.description"
- buttonText="connectorBuilder.createPage.loadExistingConnector.button"
+ title="connectorBuilder.createPage.forkExistingConnector.title"
+ description="connectorBuilder.createPage.forkExistingConnector.description"
+ buttonText="connectorBuilder.createPage.forkExistingConnector.button"
buttonProps={{ disabled: buttonsDisabledState }}
onClick={() => {
navigate(`../${ConnectorBuilderRoutePaths.Fork}`);
diff --git a/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.module.scss b/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.module.scss
index ae4061922c1..c0d05175074 100644
--- a/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.module.scss
+++ b/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.module.scss
@@ -10,3 +10,10 @@
.form {
padding: variables.$spacing-xl;
}
+
+$iconWidth: 20px;
+
+.connectorIcon {
+ width: $iconWidth;
+ height: $iconWidth;
+}
diff --git a/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.tsx b/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.tsx
index ae1ff5e4d24..d3ab301aa8d 100644
--- a/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.tsx
+++ b/airbyte-webapp/src/pages/connectorBuilder/ConnectorBuilderForkPage/ConnectorBuilderForkPage.tsx
@@ -1,49 +1,95 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { ControlLabels } from "components";
+import { ConnectorIcon } from "components/ConnectorIcon";
import { HeadTitle } from "components/HeadTitle";
import { Button } from "components/ui/Button";
import { Card } from "components/ui/Card";
+import { ComboBox, Option, OptionSection } from "components/ui/ComboBox";
import { FlexContainer } from "components/ui/Flex";
import { ListBox } from "components/ui/ListBox";
import { Text } from "components/ui/Text";
-import { BuilderProject, useListBuilderProjects, useListBuilderProjectVersions } from "core/api";
+import { useListBuilderProjects, useListBuilderProjectVersions } from "core/api";
import { ConnectorBuilderLocalStorageProvider } from "services/connectorBuilder/ConnectorBuilderLocalStorageService";
import styles from "./ConnectorBuilderForkPage.module.scss";
import { AirbyteTitle } from "../components/AirbyteTitle";
import { BackButton } from "../components/BackButton";
import { useCreateAndNavigate } from "../components/useCreateAndNavigate";
+import { useManifestOnlySourceDefinitions } from "../components/useManifestOnlySourceDefinitions";
import { ConnectorBuilderRoutePaths } from "../ConnectorBuilderRoutes";
const ConnectorBuilderForkPageInner: React.FC = () => {
const { formatMessage } = useIntl();
+
const projects = useListBuilderProjects();
- const { createAndNavigate, isLoading: isCreating } = useCreateAndNavigate();
+ const { manifestOnlySourceDefinitions, sourceDefinitionMap } = useManifestOnlySourceDefinitions();
+
+ const { createAndNavigate, forkAndNavigate, isLoading: isCreating } = useCreateAndNavigate();
const navigate = useNavigate();
useEffect(() => {
- if (projects.length === 0) {
+ if (projects.length === 0 && manifestOnlySourceDefinitions.length === 0) {
navigate(ConnectorBuilderRoutePaths.Create, { replace: true });
}
- }, [navigate, projects.length]);
+ }, [manifestOnlySourceDefinitions.length, navigate, projects.length]);
- const [selectedProject, setSelectedProject] = useState(projects[0]);
- const { data: versions, isLoading: isLoadingVersions } = useListBuilderProjectVersions(selectedProject);
- const [selectedVersion, setSelectedVersion] = useState<"draft" | number>("draft");
+ const [selectedId, setSelectedId] = useState(undefined);
+ const selection = useMemo(() => {
+ if (!selectedId) {
+ return undefined;
+ }
+
+ const sourceDefinition = sourceDefinitionMap.get(selectedId);
+ if (sourceDefinition) {
+ return { type: "sourceDefinition", sourceDefinition } as const;
+ }
+
+ const selectedProject = projects.find((project) => project.id === selectedId);
+ if (selectedProject) {
+ return { type: "builderProject", project: selectedProject } as const;
+ }
+
+ return undefined;
+ }, [projects, selectedId, sourceDefinitionMap]);
+
+ const connectorOptions: OptionSection[] = useMemo(() => {
+ const builderProjectOptions: Option[] = projects.map((project) => {
+ return { label: project.name, value: project.id };
+ });
+
+ const sourceDefinitionOptions: Option[] = manifestOnlySourceDefinitions.map((sourceDefinition) => {
+ return {
+ label: sourceDefinition.name,
+ value: sourceDefinition.sourceDefinitionId,
+ iconLeft: ,
+ };
+ });
+
+ return [
+ { sectionTitle: "AIRBYTE", innerOptions: sourceDefinitionOptions },
+ { sectionTitle: "Custom", innerOptions: builderProjectOptions },
+ ];
+ }, [manifestOnlySourceDefinitions, projects]);
+ const selectedProject = useMemo(
+ () => (selection && selection.type === "builderProject" ? selection.project : undefined),
+ [selection]
+ );
+ const { data: versions, isLoading: isLoadingVersions } = useListBuilderProjectVersions(selectedProject);
+ const [selectedBuilderProjectVersion, setSelectedBuilderProjectVersion] = useState("draft");
useEffect(() => {
if (!versions) {
return;
}
- setSelectedVersion(selectedProject.hasDraft ? "draft" : versions[0]?.version);
+ setSelectedBuilderProjectVersion(selectedProject?.hasDraft ? "draft" : versions[0]?.version);
}, [selectedProject, versions]);
- const versionOptions: Array<{ label: React.ReactNode; value: "draft" | number }> = (versions || []).map(
- ({ version, description }) => {
+ const versionOptions: VersionOption[] = useMemo(() => {
+ const options: VersionOption[] = (versions || []).map(({ version, description }) => {
return {
label: (
@@ -57,60 +103,99 @@ const ConnectorBuilderForkPageInner: React.FC = () => {
),
value: version,
};
- }
- );
- if (selectedProject.hasDraft) {
- versionOptions.unshift({
- label: (
-
-
-
- ),
- value: "draft",
});
- }
+ if (selectedProject?.hasDraft) {
+ options.unshift({
+ label: (
+
+
+
+ ),
+ value: "draft",
+ });
+ }
+ return options;
+ }, [selectedProject, versions]);
const isLoading = isCreating || isLoadingVersions;
return (
- } />
+
+ } />
+
+
+
+
-
-
- options={projects.map((project) => {
- return { label: {project.name}, value: project };
- })}
- onSelect={(selected) => selected && setSelectedProject(selected)}
- selectedValue={selectedProject}
+
+
{versionOptions.length > 1 && (
options={versionOptions}
- onSelect={(selected) => selected && setSelectedVersion(selected)}
- selectedValue={selectedVersion}
+ onSelect={(selected) => selected && setSelectedBuilderProjectVersion(selected)}
+ selectedValue={selectedBuilderProjectVersion}
/>
)}
+ {selection && (
+
+ {selection.type === "builderProject" ? (
+
+ ) : (
+
+ )}
+
+ )}