Skip to content

Commit

Permalink
🪟 🧪 [Experiment] Show source selector on signup form (#18468)
Browse files Browse the repository at this point in the history
* 🪟 🧪 [Experiment] Show source selector on signup form

Demo: https://www.loom.com/share/f676522a48184a48adfb461232937f5e

* fetch directly from cloud catalog

* remove cloud catalog.json

* wrap onChangeServiceType on useCallback

* cleanup

* filter out hidden cloud connectors

* Connections Flow

* fix import
  • Loading branch information
letiescanciano authored Nov 7, 2022
1 parent 5846c65 commit 99195c0
Show file tree
Hide file tree
Showing 12 changed files with 1,916 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export interface Experiments {
"authPage.oauth.google.signUpPage": boolean;
"authPage.oauth.github.signUpPage": boolean;
"onboarding.speedyConnection": boolean;
"authPage.signup.sourceSelector": boolean;
"authPage.oauth.position": "top" | "bottom";
}
10 changes: 10 additions & 0 deletions airbyte-webapp/src/hooks/useLocationState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useLocation } from "react-router-dom";

interface ILocationState<T> extends Omit<Location, "state"> {
state: T;
}

export const useLocationState = <T>(): T => {
const location = useLocation() as unknown as ILocationState<T>;
return location.state;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { useCallback, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { components } from "react-select";
import { MenuListProps } from "react-select";

import { GAIcon } from "components/icons/GAIcon";
import { ControlLabels } from "components/LabeledControl";
import {
DropDown,
DropDownOptionDataItem,
DropDownOptionProps,
OptionView,
SingleValueIcon,
SingleValueProps,
SingleValueView,
} from "components/ui/DropDown";
import { Text } from "components/ui/Text";

import { ReleaseStage } from "core/request/AirbyteClient";
import { useModalService } from "hooks/services/Modal";
import RequestConnectorModal from "views/Connector/RequestConnectorModal";
import styles from "views/Connector/ServiceForm/components/Controls/ConnectorServiceTypeControl/ConnectorServiceTypeControl.module.scss";
import { useAnalyticsTrackFunctions } from "views/Connector/ServiceForm/components/Controls/ConnectorServiceTypeControl/useAnalyticsTrackFunctions";
import { WarningMessage } from "views/Connector/ServiceForm/components/WarningMessage";

import { useGetSourceDefinitions } from "./useGetSourceDefinitions";
import { getSortedDropdownData } from "./utils";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type MenuWithRequestButtonProps = MenuListProps<DropDownOptionDataItem, false> & { selectProps: any };

const ConnectorList: React.FC<React.PropsWithChildren<MenuWithRequestButtonProps>> = ({ children, ...props }) => (
<>
<components.MenuList {...props}>{children}</components.MenuList>
<div className={styles.connectorListFooter}>
<button
className={styles.requestNewConnectorBtn}
onClick={() => props.selectProps.selectProps.onOpenRequestConnectorModal(props.selectProps.inputValue)}
>
<FontAwesomeIcon icon={faPlus} />
<FormattedMessage id="connector.requestConnectorBlock" />
</button>
</div>
</>
);

const StageLabel: React.FC<{ releaseStage?: ReleaseStage }> = ({ releaseStage }) => {
if (!releaseStage) {
return null;
}

if (releaseStage === ReleaseStage.generally_available) {
return <GAIcon />;
}

return (
<div className={styles.stageLabel}>
<FormattedMessage id={`connector.releaseStage.${releaseStage}`} defaultMessage={releaseStage} />
</div>
);
};

const Option: React.FC<DropDownOptionProps> = (props) => {
return (
<components.Option {...props}>
<OptionView
data-testid={props.data.label}
isSelected={props.isSelected}
isDisabled={props.isDisabled}
isFocused={props.isFocused}
>
<div className={styles.connectorName}>
{props.data.img || null}
<Text size="lg">{props.label}</Text>
</div>
<StageLabel releaseStage={props.data.releaseStage} />
</OptionView>
</components.Option>
);
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SingleValue: React.FC<SingleValueProps<any>> = (props) => {
return (
<SingleValueView>
{props.data.img && <SingleValueIcon>{props.data.img}</SingleValueIcon>}
<div>
<components.SingleValue className={styles.singleValueContent} {...props}>
{props.data.label}
<StageLabel releaseStage={props.data.releaseStage} />
</components.SingleValue>
</div>
</SingleValueView>
);
};

interface SignupSourceDropdownProps {
disabled?: boolean;
email: string;
}

export const SignupSourceDropdown: React.FC<SignupSourceDropdownProps> = ({ disabled, email }) => {
const { formatMessage } = useIntl();
const { openModal, closeModal } = useModalService();
const { trackMenuOpen, trackNoOptionMessage, trackConnectorSelection } = useAnalyticsTrackFunctions("source");

const { data: availableSources } = useGetSourceDefinitions();

const [sourceDefinitionId, setSourceDefinitionId] = useState<string>("");

const onChangeServiceType = useCallback((sourceDefinitionId: string) => {
setSourceDefinitionId(sourceDefinitionId);
localStorage.setItem("exp-signup-selected-source-definition-id", sourceDefinitionId);
}, []);

const sortedDropDownData = useMemo(() => getSortedDropdownData(availableSources ?? []), [availableSources]);

const getNoOptionsMessage = useCallback(
({ inputValue }: { inputValue: string }) => {
trackNoOptionMessage(inputValue);
return formatMessage({ id: "form.noConnectorFound" });
},
[formatMessage, trackNoOptionMessage]
);

const selectedService = React.useMemo(
() => sortedDropDownData.find((s) => s.value === sourceDefinitionId),
[sourceDefinitionId, sortedDropDownData]
);

const handleSelect = useCallback(
(item: DropDownOptionDataItem | null) => {
if (item && onChangeServiceType) {
onChangeServiceType(item.value);
trackConnectorSelection(item.value, item.label || "");
}
},
[onChangeServiceType, trackConnectorSelection]
);

const selectProps = useMemo(
() => ({
onOpenRequestConnectorModal: (input: string) =>
openModal({
title: formatMessage({ id: "connector.requestConnector" }),
content: () => (
<RequestConnectorModal
connectorType="source"
workspaceEmail={email}
searchedConnectorName={input}
onClose={closeModal}
/>
),
}),
}),
[closeModal, formatMessage, openModal, email]
);

if (!Boolean(sortedDropDownData.length)) {
return null;
}
return (
<>
<ControlLabels
label={formatMessage({
id: "login.sourceSelector",
})}
>
<DropDown
value={sourceDefinitionId}
components={{
MenuList: ConnectorList,
Option,
SingleValue,
}}
selectProps={selectProps}
isDisabled={disabled}
isSearchable
placeholder={formatMessage({
id: "form.selectConnector",
})}
options={sortedDropDownData}
onChange={handleSelect}
onMenuOpen={trackMenuOpen}
noOptionsMessage={getNoOptionsMessage}
data-testid="serviceType"
/>
</ControlLabels>
{selectedService &&
(selectedService.releaseStage === ReleaseStage.alpha || selectedService.releaseStage === ReleaseStage.beta) && (
<WarningMessage stage={selectedService.releaseStage} />
)}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SignupSourceDropdown } from "./SignupSourceDropdown";

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useQuery } from "react-query";

import { getExcludedConnectorIds } from "core/domain/connector/constants";
import { DestinationDefinitionRead, SourceDefinitionRead } from "core/request/AirbyteClient";

import availableSourceDefinitions from "./sourceDefinitions.json";

interface Catalog {
destinations: DestinationDefinitionRead[];
sources: SourceDefinitionRead[];
}
const fetchCatalog = async (): Promise<Catalog> => {
const path = "https://storage.googleapis.com/prod-airbyte-cloud-connector-metadata-service/cloud_catalog.json";
const response = await fetch(path);
return response.json();
};

export const useGetSourceDefinitions = () => {
return useQuery<Catalog, Error, Catalog["sources"]>("cloud_catalog", fetchCatalog, {
select: (data) => {
return data.sources
.filter(() => getExcludedConnectorIds(""))
.map((source) => {
const icon = availableSourceDefinitions.sourceDefinitions.find(
(src) => src.sourceDefinitionId === source.sourceDefinitionId
)?.icon;
return {
...source,
icon,
};
});
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ConnectorIcon } from "components/common/ConnectorIcon";

import { Connector } from "core/domain/connector";
import { ReleaseStage, SourceDefinitionRead } from "core/request/AirbyteClient";
import { naturalComparator } from "utils/objects";

/**
* Returns the order for a specific release stage label. This will define
* in what order the different release stages are shown inside the select.
* They will be shown in an increasing order (i.e. 0 on top)
*/
const getOrderForReleaseStage = (stage?: ReleaseStage): number => {
switch (stage) {
case ReleaseStage.beta:
return 1;
case ReleaseStage.alpha:
return 2;
default:
return 0;
}
};
interface ServiceDropdownOption {
label: string;
value: string;
img: JSX.Element;
releaseStage: ReleaseStage | undefined;
}
const transformConnectorDefinitionToDropdownOption = (item: SourceDefinitionRead): ServiceDropdownOption => ({
label: item.name,
value: Connector.id(item),
img: <ConnectorIcon icon={item.icon} />,
releaseStage: item.releaseStage,
});

const sortByReleaseStage = (a: ServiceDropdownOption, b: ServiceDropdownOption) => {
if (a.releaseStage !== b.releaseStage) {
return getOrderForReleaseStage(a.releaseStage) - getOrderForReleaseStage(b.releaseStage);
}
return naturalComparator(a.label, b.label);
};

export const getSortedDropdownData = (availableConnectorDefinitions: SourceDefinitionRead[]): ServiceDropdownOption[] =>
availableConnectorDefinitions.map(transformConnectorDefinitionToDropdownOption).sort(sortByReleaseStage);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const EXP_SOURCE_SIGNUP_SELECTOR = "exp-signup-selected-source-definition-id";
1 change: 1 addition & 0 deletions airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"login.oauth.github": "Continue with GitHub",
"login.oauth.differentCredentialsError": "Use your email and password to sign in.",
"login.oauth.unknownError": "An unknown error happened during sign in: {error}",
"login.sourceSelector": "Select a source to get started",

"confirmResetPassword.newPassword": "Enter a new password",
"confirmResetPassword.success": "Your password has been reset. Please log in with the new password.",
Expand Down
15 changes: 15 additions & 0 deletions airbyte-webapp/src/packages/cloud/views/DefaultView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { useEffect } from "react";
import { Navigate } from "react-router-dom";

import { useExperiment } from "hooks/services/Experiment";

import { RoutePaths } from "../../../pages/routePaths";
import { CloudRoutes } from "../cloudRoutes";
import { EXP_SOURCE_SIGNUP_SELECTOR } from "../components/experiments/constants";
import { useListCloudWorkspaces } from "../services/workspaces/CloudWorkspacesService";

export const DefaultView: React.FC = () => {
const workspaces = useListCloudWorkspaces();
// exp-signup-selected-source-definition
const isSignupSourceSelectorExperiment = useExperiment("authPage.signup.sourceSelector", false);
const sourceDefinitionId = localStorage.getItem(EXP_SOURCE_SIGNUP_SELECTOR);

useEffect(() => {
localStorage.removeItem(EXP_SOURCE_SIGNUP_SELECTOR);
}, []);
// Only show the workspace creation list if there is more than one workspace
// otherwise redirect to the single workspace
return (
Expand All @@ -17,6 +27,11 @@ export const DefaultView: React.FC = () => {
: `/${RoutePaths.Workspaces}/${workspaces[0].workspaceId}`
}
replace
// exp-signup-selected-source-definition
{...(isSignupSourceSelectorExperiment && {
state: { sourceDefinitionId },
to: `/${RoutePaths.Workspaces}/${workspaces[0].workspaceId}/${RoutePaths.Connections}/${RoutePaths.ConnectionNew}`,
})}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LabeledInput, Link } from "components";
import { Button } from "components/ui/Button";

import { useExperiment } from "hooks/services/Experiment";
import { SignupSourceDropdown } from "packages/cloud/components/experiments/SignupSourceDropdown";
import { FieldError } from "packages/cloud/lib/errors/FieldError";
import { useAuthService } from "packages/cloud/services/auth/AuthService";
import { isGdprCountry } from "utils/dataPrivacy";
Expand Down Expand Up @@ -180,6 +181,7 @@ export const SignupForm: React.FC = () => {

const showName = !useExperiment("authPage.signup.hideName", false);
const showCompanyName = !useExperiment("authPage.signup.hideCompanyName", false);
const showSourceSelector = useExperiment("authPage.signup.sourceSelector", false);

const validationSchema = useMemo(() => {
const shape = {
Expand Down Expand Up @@ -223,7 +225,7 @@ export const SignupForm: React.FC = () => {
validateOnBlur
validateOnChange
>
{({ isValid, isSubmitting, status }) => (
{({ isValid, isSubmitting, status, values }) => (
<Form>
{(showName || showCompanyName) && (
<RowFieldItem>
Expand All @@ -232,6 +234,12 @@ export const SignupForm: React.FC = () => {
</RowFieldItem>
)}

{/* exp-select-source-signup */}
{showSourceSelector && (
<FieldItem>
<SignupSourceDropdown disabled={isSubmitting} email={values.email} />
</FieldItem>
)}
<FieldItem>
<EmailField />
</FieldItem>
Expand Down
Loading

0 comments on commit 99195c0

Please sign in to comment.