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" ? ( + + ) : ( + + )} + + )}