Skip to content

Commit 99195c0

Browse files
🪟 🧪 [Experiment] Show source selector on signup form (#18468)
* 🪟 🧪 [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
1 parent 5846c65 commit 99195c0

File tree

12 files changed

+1916
-3
lines changed

12 files changed

+1916
-3
lines changed

airbyte-webapp/src/hooks/services/Experiment/experiments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ export interface Experiments {
1919
"authPage.oauth.google.signUpPage": boolean;
2020
"authPage.oauth.github.signUpPage": boolean;
2121
"onboarding.speedyConnection": boolean;
22+
"authPage.signup.sourceSelector": boolean;
2223
"authPage.oauth.position": "top" | "bottom";
2324
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useLocation } from "react-router-dom";
2+
3+
interface ILocationState<T> extends Omit<Location, "state"> {
4+
state: T;
5+
}
6+
7+
export const useLocationState = <T>(): T => {
8+
const location = useLocation() as unknown as ILocationState<T>;
9+
return location.state;
10+
};
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { faPlus } from "@fortawesome/free-solid-svg-icons";
2+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import React, { useCallback, useMemo, useState } from "react";
4+
import { FormattedMessage, useIntl } from "react-intl";
5+
import { components } from "react-select";
6+
import { MenuListProps } from "react-select";
7+
8+
import { GAIcon } from "components/icons/GAIcon";
9+
import { ControlLabels } from "components/LabeledControl";
10+
import {
11+
DropDown,
12+
DropDownOptionDataItem,
13+
DropDownOptionProps,
14+
OptionView,
15+
SingleValueIcon,
16+
SingleValueProps,
17+
SingleValueView,
18+
} from "components/ui/DropDown";
19+
import { Text } from "components/ui/Text";
20+
21+
import { ReleaseStage } from "core/request/AirbyteClient";
22+
import { useModalService } from "hooks/services/Modal";
23+
import RequestConnectorModal from "views/Connector/RequestConnectorModal";
24+
import styles from "views/Connector/ServiceForm/components/Controls/ConnectorServiceTypeControl/ConnectorServiceTypeControl.module.scss";
25+
import { useAnalyticsTrackFunctions } from "views/Connector/ServiceForm/components/Controls/ConnectorServiceTypeControl/useAnalyticsTrackFunctions";
26+
import { WarningMessage } from "views/Connector/ServiceForm/components/WarningMessage";
27+
28+
import { useGetSourceDefinitions } from "./useGetSourceDefinitions";
29+
import { getSortedDropdownData } from "./utils";
30+
31+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32+
type MenuWithRequestButtonProps = MenuListProps<DropDownOptionDataItem, false> & { selectProps: any };
33+
34+
const ConnectorList: React.FC<React.PropsWithChildren<MenuWithRequestButtonProps>> = ({ children, ...props }) => (
35+
<>
36+
<components.MenuList {...props}>{children}</components.MenuList>
37+
<div className={styles.connectorListFooter}>
38+
<button
39+
className={styles.requestNewConnectorBtn}
40+
onClick={() => props.selectProps.selectProps.onOpenRequestConnectorModal(props.selectProps.inputValue)}
41+
>
42+
<FontAwesomeIcon icon={faPlus} />
43+
<FormattedMessage id="connector.requestConnectorBlock" />
44+
</button>
45+
</div>
46+
</>
47+
);
48+
49+
const StageLabel: React.FC<{ releaseStage?: ReleaseStage }> = ({ releaseStage }) => {
50+
if (!releaseStage) {
51+
return null;
52+
}
53+
54+
if (releaseStage === ReleaseStage.generally_available) {
55+
return <GAIcon />;
56+
}
57+
58+
return (
59+
<div className={styles.stageLabel}>
60+
<FormattedMessage id={`connector.releaseStage.${releaseStage}`} defaultMessage={releaseStage} />
61+
</div>
62+
);
63+
};
64+
65+
const Option: React.FC<DropDownOptionProps> = (props) => {
66+
return (
67+
<components.Option {...props}>
68+
<OptionView
69+
data-testid={props.data.label}
70+
isSelected={props.isSelected}
71+
isDisabled={props.isDisabled}
72+
isFocused={props.isFocused}
73+
>
74+
<div className={styles.connectorName}>
75+
{props.data.img || null}
76+
<Text size="lg">{props.label}</Text>
77+
</div>
78+
<StageLabel releaseStage={props.data.releaseStage} />
79+
</OptionView>
80+
</components.Option>
81+
);
82+
};
83+
84+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
85+
const SingleValue: React.FC<SingleValueProps<any>> = (props) => {
86+
return (
87+
<SingleValueView>
88+
{props.data.img && <SingleValueIcon>{props.data.img}</SingleValueIcon>}
89+
<div>
90+
<components.SingleValue className={styles.singleValueContent} {...props}>
91+
{props.data.label}
92+
<StageLabel releaseStage={props.data.releaseStage} />
93+
</components.SingleValue>
94+
</div>
95+
</SingleValueView>
96+
);
97+
};
98+
99+
interface SignupSourceDropdownProps {
100+
disabled?: boolean;
101+
email: string;
102+
}
103+
104+
export const SignupSourceDropdown: React.FC<SignupSourceDropdownProps> = ({ disabled, email }) => {
105+
const { formatMessage } = useIntl();
106+
const { openModal, closeModal } = useModalService();
107+
const { trackMenuOpen, trackNoOptionMessage, trackConnectorSelection } = useAnalyticsTrackFunctions("source");
108+
109+
const { data: availableSources } = useGetSourceDefinitions();
110+
111+
const [sourceDefinitionId, setSourceDefinitionId] = useState<string>("");
112+
113+
const onChangeServiceType = useCallback((sourceDefinitionId: string) => {
114+
setSourceDefinitionId(sourceDefinitionId);
115+
localStorage.setItem("exp-signup-selected-source-definition-id", sourceDefinitionId);
116+
}, []);
117+
118+
const sortedDropDownData = useMemo(() => getSortedDropdownData(availableSources ?? []), [availableSources]);
119+
120+
const getNoOptionsMessage = useCallback(
121+
({ inputValue }: { inputValue: string }) => {
122+
trackNoOptionMessage(inputValue);
123+
return formatMessage({ id: "form.noConnectorFound" });
124+
},
125+
[formatMessage, trackNoOptionMessage]
126+
);
127+
128+
const selectedService = React.useMemo(
129+
() => sortedDropDownData.find((s) => s.value === sourceDefinitionId),
130+
[sourceDefinitionId, sortedDropDownData]
131+
);
132+
133+
const handleSelect = useCallback(
134+
(item: DropDownOptionDataItem | null) => {
135+
if (item && onChangeServiceType) {
136+
onChangeServiceType(item.value);
137+
trackConnectorSelection(item.value, item.label || "");
138+
}
139+
},
140+
[onChangeServiceType, trackConnectorSelection]
141+
);
142+
143+
const selectProps = useMemo(
144+
() => ({
145+
onOpenRequestConnectorModal: (input: string) =>
146+
openModal({
147+
title: formatMessage({ id: "connector.requestConnector" }),
148+
content: () => (
149+
<RequestConnectorModal
150+
connectorType="source"
151+
workspaceEmail={email}
152+
searchedConnectorName={input}
153+
onClose={closeModal}
154+
/>
155+
),
156+
}),
157+
}),
158+
[closeModal, formatMessage, openModal, email]
159+
);
160+
161+
if (!Boolean(sortedDropDownData.length)) {
162+
return null;
163+
}
164+
return (
165+
<>
166+
<ControlLabels
167+
label={formatMessage({
168+
id: "login.sourceSelector",
169+
})}
170+
>
171+
<DropDown
172+
value={sourceDefinitionId}
173+
components={{
174+
MenuList: ConnectorList,
175+
Option,
176+
SingleValue,
177+
}}
178+
selectProps={selectProps}
179+
isDisabled={disabled}
180+
isSearchable
181+
placeholder={formatMessage({
182+
id: "form.selectConnector",
183+
})}
184+
options={sortedDropDownData}
185+
onChange={handleSelect}
186+
onMenuOpen={trackMenuOpen}
187+
noOptionsMessage={getNoOptionsMessage}
188+
data-testid="serviceType"
189+
/>
190+
</ControlLabels>
191+
{selectedService &&
192+
(selectedService.releaseStage === ReleaseStage.alpha || selectedService.releaseStage === ReleaseStage.beta) && (
193+
<WarningMessage stage={selectedService.releaseStage} />
194+
)}
195+
</>
196+
);
197+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { SignupSourceDropdown } from "./SignupSourceDropdown";

airbyte-webapp/src/packages/cloud/components/experiments/SignupSourceDropdown/sourceDefinitions.json

Lines changed: 1596 additions & 0 deletions
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useQuery } from "react-query";
2+
3+
import { getExcludedConnectorIds } from "core/domain/connector/constants";
4+
import { DestinationDefinitionRead, SourceDefinitionRead } from "core/request/AirbyteClient";
5+
6+
import availableSourceDefinitions from "./sourceDefinitions.json";
7+
8+
interface Catalog {
9+
destinations: DestinationDefinitionRead[];
10+
sources: SourceDefinitionRead[];
11+
}
12+
const fetchCatalog = async (): Promise<Catalog> => {
13+
const path = "https://storage.googleapis.com/prod-airbyte-cloud-connector-metadata-service/cloud_catalog.json";
14+
const response = await fetch(path);
15+
return response.json();
16+
};
17+
18+
export const useGetSourceDefinitions = () => {
19+
return useQuery<Catalog, Error, Catalog["sources"]>("cloud_catalog", fetchCatalog, {
20+
select: (data) => {
21+
return data.sources
22+
.filter(() => getExcludedConnectorIds(""))
23+
.map((source) => {
24+
const icon = availableSourceDefinitions.sourceDefinitions.find(
25+
(src) => src.sourceDefinitionId === source.sourceDefinitionId
26+
)?.icon;
27+
return {
28+
...source,
29+
icon,
30+
};
31+
});
32+
},
33+
});
34+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { ConnectorIcon } from "components/common/ConnectorIcon";
2+
3+
import { Connector } from "core/domain/connector";
4+
import { ReleaseStage, SourceDefinitionRead } from "core/request/AirbyteClient";
5+
import { naturalComparator } from "utils/objects";
6+
7+
/**
8+
* Returns the order for a specific release stage label. This will define
9+
* in what order the different release stages are shown inside the select.
10+
* They will be shown in an increasing order (i.e. 0 on top)
11+
*/
12+
const getOrderForReleaseStage = (stage?: ReleaseStage): number => {
13+
switch (stage) {
14+
case ReleaseStage.beta:
15+
return 1;
16+
case ReleaseStage.alpha:
17+
return 2;
18+
default:
19+
return 0;
20+
}
21+
};
22+
interface ServiceDropdownOption {
23+
label: string;
24+
value: string;
25+
img: JSX.Element;
26+
releaseStage: ReleaseStage | undefined;
27+
}
28+
const transformConnectorDefinitionToDropdownOption = (item: SourceDefinitionRead): ServiceDropdownOption => ({
29+
label: item.name,
30+
value: Connector.id(item),
31+
img: <ConnectorIcon icon={item.icon} />,
32+
releaseStage: item.releaseStage,
33+
});
34+
35+
const sortByReleaseStage = (a: ServiceDropdownOption, b: ServiceDropdownOption) => {
36+
if (a.releaseStage !== b.releaseStage) {
37+
return getOrderForReleaseStage(a.releaseStage) - getOrderForReleaseStage(b.releaseStage);
38+
}
39+
return naturalComparator(a.label, b.label);
40+
};
41+
42+
export const getSortedDropdownData = (availableConnectorDefinitions: SourceDefinitionRead[]): ServiceDropdownOption[] =>
43+
availableConnectorDefinitions.map(transformConnectorDefinitionToDropdownOption).sort(sortByReleaseStage);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const EXP_SOURCE_SIGNUP_SELECTOR = "exp-signup-selected-source-definition-id";

airbyte-webapp/src/packages/cloud/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"login.oauth.github": "Continue with GitHub",
4242
"login.oauth.differentCredentialsError": "Use your email and password to sign in.",
4343
"login.oauth.unknownError": "An unknown error happened during sign in: {error}",
44+
"login.sourceSelector": "Select a source to get started",
4445

4546
"confirmResetPassword.newPassword": "Enter a new password",
4647
"confirmResetPassword.success": "Your password has been reset. Please log in with the new password.",

airbyte-webapp/src/packages/cloud/views/DefaultView.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1+
import { useEffect } from "react";
12
import { Navigate } from "react-router-dom";
23

4+
import { useExperiment } from "hooks/services/Experiment";
5+
36
import { RoutePaths } from "../../../pages/routePaths";
47
import { CloudRoutes } from "../cloudRoutes";
8+
import { EXP_SOURCE_SIGNUP_SELECTOR } from "../components/experiments/constants";
59
import { useListCloudWorkspaces } from "../services/workspaces/CloudWorkspacesService";
610

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

17+
useEffect(() => {
18+
localStorage.removeItem(EXP_SOURCE_SIGNUP_SELECTOR);
19+
}, []);
1020
// Only show the workspace creation list if there is more than one workspace
1121
// otherwise redirect to the single workspace
1222
return (
@@ -17,6 +27,11 @@ export const DefaultView: React.FC = () => {
1727
: `/${RoutePaths.Workspaces}/${workspaces[0].workspaceId}`
1828
}
1929
replace
30+
// exp-signup-selected-source-definition
31+
{...(isSignupSourceSelectorExperiment && {
32+
state: { sourceDefinitionId },
33+
to: `/${RoutePaths.Workspaces}/${workspaces[0].workspaceId}/${RoutePaths.Connections}/${RoutePaths.ConnectionNew}`,
34+
})}
2035
/>
2136
);
2237
};

airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { LabeledInput, Link } from "components";
99
import { Button } from "components/ui/Button";
1010

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

181182
const showName = !useExperiment("authPage.signup.hideName", false);
182183
const showCompanyName = !useExperiment("authPage.signup.hideCompanyName", false);
184+
const showSourceSelector = useExperiment("authPage.signup.sourceSelector", false);
183185

184186
const validationSchema = useMemo(() => {
185187
const shape = {
@@ -223,7 +225,7 @@ export const SignupForm: React.FC = () => {
223225
validateOnBlur
224226
validateOnChange
225227
>
226-
{({ isValid, isSubmitting, status }) => (
228+
{({ isValid, isSubmitting, status, values }) => (
227229
<Form>
228230
{(showName || showCompanyName) && (
229231
<RowFieldItem>
@@ -232,6 +234,12 @@ export const SignupForm: React.FC = () => {
232234
</RowFieldItem>
233235
)}
234236

237+
{/* exp-select-source-signup */}
238+
{showSourceSelector && (
239+
<FieldItem>
240+
<SignupSourceDropdown disabled={isSubmitting} email={values.email} />
241+
</FieldItem>
242+
)}
235243
<FieldItem>
236244
<EmailField />
237245
</FieldItem>

0 commit comments

Comments
 (0)