console: introduce a generalised useNeonIntegration hook

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6042
GitOrigin-RevId: cc29154fbe7add1c9707483fd4a22b01d5e1fa13
This commit is contained in:
Rishichandra Wawhal 2022-09-26 19:25:08 +05:30 committed by hasura-bot
parent f42bc4b816
commit 7fb6a82043
8 changed files with 468 additions and 233 deletions

View File

@ -1,5 +1,4 @@
import React from 'react';
import { MdRefresh } from 'react-icons/md';
import { ComponentMeta, Story } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
@ -13,7 +12,10 @@ export default {
export const Base: Story = () => (
<NeonBanner
onClickConnect={() => window.alert('clicked connect button')}
status={{ status: 'default', buttonText: 'Create Neon Database for free' }}
status={{
status: 'default',
}}
buttonText="Create Neon Database for free"
/>
);
Base.play = async ({ canvasElement }) => {
@ -29,8 +31,11 @@ Base.play = async ({ canvasElement }) => {
export const Loading: Story = () => (
<NeonBanner
onClickConnect={() => window.alert('clicked connect button')}
status={{ status: 'loading', buttonText: 'Authenticating' }}
status={{
status: 'loading',
}}
buttonText="Authenticating"
onClickConnect={() => null}
/>
);
Loading.play = async ({ canvasElement }) => {
@ -45,8 +50,11 @@ Loading.play = async ({ canvasElement }) => {
export const Creating: Story = () => (
<NeonBanner
onClickConnect={() => window.alert('clicked connect button')}
status={{ status: 'loading', buttonText: 'Creating Database' }}
status={{
status: 'loading',
}}
buttonText="Creating Database"
onClickConnect={() => null}
/>
);
Creating.play = async ({ canvasElement }) => {
@ -64,11 +72,11 @@ export const Error: Story = () => (
onClickConnect={() => window.alert('clicked connect button')}
status={{
status: 'error',
buttonText: 'Try Again',
buttonIcon: <MdRefresh />,
errorTitle: 'Error creating database',
errorDescription: 'You have exceeded the free project limit on Neon.',
}}
buttonText="Try Again"
icon="refresh"
/>
);
Error.play = async ({ canvasElement }) => {

View File

@ -1,29 +1,34 @@
import React, { ReactElement } from 'react';
import React from 'react';
import { MdRefresh } from 'react-icons/md';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
export type Props = {
onClickConnect: VoidFunction;
status:
const iconMap = {
refresh: <MdRefresh />,
};
type Status =
| {
status: 'loading';
buttonText: string;
}
| {
status: 'error';
buttonText: string;
buttonIcon: ReactElement;
errorTitle: string;
errorDescription: string;
}
| {
status: 'default';
buttonText: string;
};
export type Props = {
status: Status;
onClickConnect: VoidFunction;
buttonText: string;
icon?: keyof typeof iconMap;
};
export function NeonBanner(props: Props) {
const { status, onClickConnect } = props;
const { status, onClickConnect, buttonText, icon } = props;
const isButtonDisabled = status.status === 'loading';
return (
@ -57,9 +62,9 @@ export function NeonBanner(props: Props) {
data-testid="neon-connect-db-button"
mode={status.status === 'loading' ? 'default' : 'primary'}
isLoading={status.status === 'loading'}
loadingText={status.buttonText}
loadingText={buttonText}
size="md"
icon={status.status === 'error' ? status.buttonIcon : undefined}
icon={icon ? iconMap[icon] : undefined}
onClick={() => {
if (!isButtonDisabled) {
onClickConnect();
@ -67,7 +72,7 @@ export function NeonBanner(props: Props) {
}}
disabled={isButtonDisabled}
>
{status.buttonText}
{props.buttonText}
</Button>
</div>
</div>

View File

@ -1,194 +1,110 @@
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { MdRefresh } from 'react-icons/md';
import { useNeonOAuth } from './useNeonOAuth';
import { useNeonDatabase } from './useNeonDatabase';
import { useCreateHasuraDatasource } from './useCreateHasuraDatasource';
import { setDBConnectionDetails } from '../../../DataActions';
import { Dispatch } from '@/types';
import {
NeonBanner,
Props as NeonBannerProps,
} from './components/Neon/NeonBanner';
import { getNeonDBName } from './utils';
import { useNeonIntegration } from './useNeonIntegration';
import _push from '../../../push';
// This component creates Neon DB and calls the success/error callback
export function Neon(props: {
oauthString?: string;
dbCreationCallback: (dbName: string) => void;
errorCallback: VoidFunction;
allDatabases: string[];
}) {
const dispatch = useDispatch();
const {
state: neonDBCreationStatus,
create: createNeonDatabase,
reset: resetNeonDBCreationState,
} = useNeonDatabase();
// This component deals with Neon DB creation on connect DB page
export function Neon(props: { allDatabases: string[]; dispatch: Dispatch }) {
const { dispatch, allDatabases } = props;
const { startNeonOAuth, neonOauthStatus } = useNeonOAuth(props.oauthString);
const { state: hasuraDatasourceCreationState, addHasuraDatasource } =
useCreateHasuraDatasource(
neonDBCreationStatus.status === 'success'
? neonDBCreationStatus.payload.databaseUrl || ''
: '',
getNeonDBName(props.allDatabases)
);
React.useEffect(() => {
// automatically login if creating database fails with 401 unauthorized
if (
neonDBCreationStatus.status === 'error' &&
neonDBCreationStatus.error === 'unauthorized' &&
neonOauthStatus.status === 'idle'
) {
startNeonOAuth();
resetNeonDBCreationState();
}
// automatically create database after authentication completion
if (
neonOauthStatus.status === 'authenticated' &&
(neonDBCreationStatus.status === 'idle' ||
(neonDBCreationStatus.status === 'error' &&
neonDBCreationStatus.error === 'unauthorized'))
) {
createNeonDatabase();
}
}, [neonDBCreationStatus, neonOauthStatus]);
React.useEffect(() => {
if (neonDBCreationStatus.status === 'success') {
switch (hasuraDatasourceCreationState.status) {
case 'idle':
addHasuraDatasource();
break;
case 'adding-env-var-failed':
dispatch(
setDBConnectionDetails({
dbURL: hasuraDatasourceCreationState.payload.dbUrl,
dbName: 'default',
})
);
props.errorCallback();
break;
case 'adding-data-source-failed':
dispatch(
setDBConnectionDetails({
envVar: hasuraDatasourceCreationState.payload.envVar,
dbName: 'default',
})
);
props.errorCallback();
break;
case 'success':
props.dbCreationCallback(
hasuraDatasourceCreationState.payload.dataSourceName
);
break;
default:
break;
}
}
}, [neonDBCreationStatus, hasuraDatasourceCreationState]);
// gets the NeonBanner render props associated with Neon DB creation
let neonDBCreationStatusOpts: NeonBannerProps;
switch (neonDBCreationStatus.status) {
case 'idle':
neonDBCreationStatusOpts = {
status: { status: 'default', buttonText: 'Connect Neon Database' },
onClickConnect: createNeonDatabase,
const pushToDatasource = (dataSourceName: string) => {
dispatch(_push(`/data/${dataSourceName}`));
};
break;
case 'error': {
switch (neonDBCreationStatus.error) {
case 'unauthorized':
neonDBCreationStatusOpts = {
const pushToConnectDBPage = () => {
dispatch(_push(`/data/manage/connect`));
};
const neonIntegrationStatus = useNeonIntegration(
getNeonDBName(allDatabases),
pushToDatasource,
pushToConnectDBPage,
dispatch
);
let neonBannerProps: NeonBannerProps;
switch (neonIntegrationStatus.status) {
case 'idle':
neonBannerProps = {
status: {
status: 'default',
},
buttonText: 'Connect Neon Database',
},
onClickConnect: startNeonOAuth,
onClickConnect: neonIntegrationStatus.action,
};
break;
default:
neonDBCreationStatusOpts = {
case 'authentication-loading':
neonBannerProps = {
status: {
status: 'loading',
},
buttonText: 'Authenticating with Neon',
onClickConnect: () => null,
};
break;
case 'authentication-error':
neonBannerProps = {
status: {
status: 'error',
buttonText: 'Try again',
buttonIcon: <MdRefresh />,
errorTitle: 'Creating Neon Database failed',
errorDescription: `Error creating database: ${neonDBCreationStatus.error}`,
errorTitle: neonIntegrationStatus.title,
errorDescription: neonIntegrationStatus.description,
},
onClickConnect: createNeonDatabase,
buttonText: 'Try again',
onClickConnect: neonIntegrationStatus.action,
icon: 'refresh',
};
break;
}
break;
}
case 'loading':
neonDBCreationStatusOpts = {
status: { status: 'loading', buttonText: 'Creating Database' },
case 'authentication-success':
case 'neon-database-creation-loading':
neonBannerProps = {
status: {
status: 'loading',
},
buttonText: 'Creating Database',
onClickConnect: () => null,
};
break;
case 'success':
neonDBCreationStatusOpts = {
status: { status: 'loading', buttonText: 'Connecting to Hasura' },
case 'neon-database-creation-error':
neonBannerProps = {
status: {
status: 'error',
errorTitle: neonIntegrationStatus.title,
errorDescription: neonIntegrationStatus.description,
},
buttonText: 'Try again',
onClickConnect: neonIntegrationStatus.action,
icon: 'refresh',
};
break;
case 'neon-database-creation-success':
case 'env-var-creation-loading':
case 'env-var-creation-success':
case 'env-var-creation-error':
case 'hasura-source-creation-loading':
case 'hasura-source-creation-error':
case 'hasura-source-creation-success':
neonBannerProps = {
status: {
status: 'loading',
},
buttonText: 'Connecting to Hasura',
onClickConnect: () => null,
};
break;
default:
// never happens; handling for placating TypeScript
neonDBCreationStatusOpts = {
neonBannerProps = {
status: {
status: 'default',
buttonText: 'Create Neon Database',
},
onClickConnect: createNeonDatabase,
buttonText: 'Connect Neon Database',
onClickConnect: () => null,
};
break;
}
// get the NeonBanner render props associated with Neon OAuth
let neonOAuthStatusOpts: NeonBannerProps;
switch (neonOauthStatus.status) {
case 'idle':
neonOAuthStatusOpts = { ...neonDBCreationStatusOpts };
break;
case 'authenticating':
neonOAuthStatusOpts = {
status: { status: 'loading', buttonText: 'Authenticating with Neon' },
onClickConnect: () => null,
};
break;
case 'authenticated':
neonOAuthStatusOpts = { ...neonDBCreationStatusOpts };
break;
case 'error':
neonOAuthStatusOpts = {
status: {
status: 'error',
buttonText: 'Try again',
buttonIcon: <MdRefresh />,
errorTitle: 'Error authenticating with Neon',
errorDescription: neonOauthStatus.error.message,
},
onClickConnect: startNeonOAuth,
};
break;
default:
// never happens; handling for placating TypeScript
neonOAuthStatusOpts = {
status: {
status: 'default',
buttonText: 'Create Neon Database',
},
onClickConnect: createNeonDatabase,
};
break;
}
return <NeonBanner {...neonOAuthStatusOpts} />;
return <NeonBanner {...neonBannerProps} />;
}

View File

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { tracingTools } from '@/features/TracingTools';
import { Dispatch } from '@/types';
import {
setDBURLInEnvVars,
verifyProjectHealthAndConnectDataSource,
@ -44,12 +44,11 @@ type HasuraDatasourceStatus =
payload: HasuraDBCreationPayload;
};
export function useCreateHasuraDatasource(
export function useCreateHasuraCloudDatasource(
dbUrl: string,
dataSourceName = 'default'
dataSourceName = 'default',
dispatch: Dispatch
) {
const dispatch = useDispatch();
const [state, setState] = useState<HasuraDatasourceStatus>({
status: 'idle',
});

View File

@ -0,0 +1,307 @@
import { useEffect } from 'react';
import { Dispatch } from '@/types';
import { useNeonOAuth } from './useNeonOAuth';
import { useNeonDatabase } from './useNeonDatabase';
import { useCreateHasuraCloudDatasource } from './useCreateHasuraCloudDatasource';
import { setDBConnectionDetails } from '../../../DataActions';
type EmptyPayload = Record<string, never>;
type NeonDBCreationSuccessPayload = {
databaseUrl: string;
email: string;
};
type EnvVarCreationPayload = {
databaseUrl: string;
dataSourceName: string;
};
type DatasourceCreationPayload = {
envVar: string;
databaseUrl: string;
dataSourceName: string;
};
type Idle<Status, Payload> = {
status: Status;
payload?: Payload;
action: VoidFunction;
};
type Loading<Status, Payload> = {
status: Status;
payload: Payload;
};
type Error<Status, Payload> = {
status: Status;
payload: Payload;
title: string;
description: string;
action: VoidFunction;
};
type Success<Status, Payload> = {
status: Status;
payload: Payload;
};
type NeonIntegrationStatus =
| Idle<'idle', EmptyPayload>
| Loading<'authentication-loading', EmptyPayload>
| Success<'authentication-success', EmptyPayload>
| Error<'authentication-error', EmptyPayload>
| Loading<'neon-database-creation-loading', EmptyPayload>
| Success<'neon-database-creation-success', NeonDBCreationSuccessPayload>
| Error<'neon-database-creation-error', EmptyPayload>
| Loading<'env-var-creation-loading', EnvVarCreationPayload>
| Success<'env-var-creation-success', DatasourceCreationPayload>
| Error<'env-var-creation-error', EnvVarCreationPayload>
| Loading<'hasura-source-creation-loading', DatasourceCreationPayload>
| Success<'hasura-source-creation-success', DatasourceCreationPayload>
| Error<'hasura-source-creation-error', DatasourceCreationPayload>;
export function useNeonIntegration(
dataSourceName: string,
dbCreationCallback: (dataSourceName: string) => void, // TODO use NeonIntegrationStatus as a parameter
failureCallback: VoidFunction, // TODO use NeonIntegrationStatus as a parameter
dispatch: Dispatch
): NeonIntegrationStatus {
const { startNeonOAuth, neonOauthStatus } = useNeonOAuth();
const {
create: createNeonDatabase,
state: neonDBCreationStatus,
reset: resetNeonDBCreationState,
} = useNeonDatabase();
const { state: hasuraCloudDataSourceConnectionStatus, addHasuraDatasource } =
useCreateHasuraCloudDatasource(
neonDBCreationStatus.status === 'success'
? neonDBCreationStatus.payload.databaseUrl || ''
: '',
dataSourceName,
dispatch
);
useEffect(() => {
// automatically login if creating database fails with 401 unauthorized
if (
neonDBCreationStatus.status === 'error' &&
neonDBCreationStatus.error === 'unauthorized' &&
neonOauthStatus.status === 'idle'
) {
startNeonOAuth();
resetNeonDBCreationState();
}
// automatically create database after authentication completion
if (
neonOauthStatus.status === 'authenticated' &&
(neonDBCreationStatus.status === 'idle' ||
(neonDBCreationStatus.status === 'error' &&
neonDBCreationStatus.error === 'unauthorized'))
) {
createNeonDatabase();
}
}, [neonDBCreationStatus, neonOauthStatus]);
useEffect(() => {
if (neonDBCreationStatus.status === 'success') {
switch (hasuraCloudDataSourceConnectionStatus.status) {
case 'idle':
addHasuraDatasource();
break;
case 'adding-env-var-failed':
dispatch(
setDBConnectionDetails({
dbURL: hasuraCloudDataSourceConnectionStatus.payload.dbUrl,
dbName: 'default',
})
);
failureCallback();
break;
case 'adding-data-source-failed':
dispatch(
setDBConnectionDetails({
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
dbName: 'default',
})
);
failureCallback();
break;
case 'success':
dbCreationCallback(
hasuraCloudDataSourceConnectionStatus.payload.dataSourceName
);
break;
default:
break;
}
}
}, [neonDBCreationStatus, hasuraCloudDataSourceConnectionStatus]);
const getNeonDBCreationStatus = (): NeonIntegrationStatus => {
switch (neonDBCreationStatus.status) {
case 'idle':
return {
status: 'idle',
action: createNeonDatabase,
};
case 'error': {
switch (neonOauthStatus.status) {
case 'idle':
if (neonDBCreationStatus.error === 'unauthorized') {
return {
status: 'idle',
action: startNeonOAuth,
};
}
return {
status: 'neon-database-creation-error',
action: createNeonDatabase,
payload: {},
title: 'Error creating Neon database',
description: neonDBCreationStatus.error,
};
case 'error':
return {
status: 'authentication-error',
payload: {},
action: startNeonOAuth,
title: 'Error authenticating with Neon',
description: neonOauthStatus.error.message,
};
case 'authenticating':
return {
status: 'authentication-loading',
payload: {},
};
case 'authenticated':
return {
status: 'neon-database-creation-error',
action: createNeonDatabase,
payload: {},
title: 'Error creating Neon database',
description: neonDBCreationStatus.error,
};
default:
return {
status: 'idle',
action: startNeonOAuth,
};
}
break;
}
case 'loading':
return {
status: 'neon-database-creation-loading',
payload: {},
};
case 'success':
{
const { databaseUrl: dbUrl } = neonDBCreationStatus.payload;
switch (hasuraCloudDataSourceConnectionStatus.status) {
case 'idle':
return {
status: 'neon-database-creation-success',
payload: {
databaseUrl: neonDBCreationStatus.payload.databaseUrl || '',
email: neonDBCreationStatus.payload.email || '',
},
};
case 'adding-env-var':
return {
status: 'env-var-creation-loading',
payload: {
databaseUrl: neonDBCreationStatus.payload.databaseUrl || '',
dataSourceName,
},
};
case 'adding-env-var-failed':
return {
status: 'env-var-creation-error',
payload: {
databaseUrl: dbUrl || '',
dataSourceName,
},
action: () => null,
title: 'Error creating env var',
description:
'Unexpected error adding env vars to the Hasura Cloud project',
};
case 'adding-data-source':
return {
status: 'hasura-source-creation-loading',
payload: {
dataSourceName,
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
databaseUrl: dbUrl || '',
},
};
case 'success':
return {
status: 'hasura-source-creation-success',
payload: {
databaseUrl: dbUrl || '',
dataSourceName,
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
},
};
case 'adding-data-source-failed':
default:
return {
status: 'hasura-source-creation-error',
payload: {
databaseUrl: dbUrl || '',
dataSourceName,
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
},
action: createNeonDatabase,
title: 'Error creating Hasura datasource',
description: 'Unexpected error creating Hasura datasource',
};
}
}
break;
default: {
return {
status: 'idle',
payload: {},
action: createNeonDatabase,
};
}
}
};
const getNeonIntegrationStatus = (): NeonIntegrationStatus => {
switch (neonOauthStatus.status) {
case 'idle':
return getNeonDBCreationStatus();
case 'authenticating':
return {
status: 'authentication-loading',
payload: {},
};
case 'error':
return {
status: 'authentication-error',
payload: {},
title: 'Error authenticating with Neon',
description: neonOauthStatus.error.message,
action: startNeonOAuth,
};
case 'authenticated':
return getNeonDBCreationStatus();
default:
return {
status: 'idle',
payload: {},
action: startNeonOAuth,
};
}
};
return getNeonIntegrationStatus();
}

View File

@ -12,7 +12,6 @@ import { NotFoundError } from '../../../../Error/PageNotFound';
import { getDataSources } from '../../../../../metadata/selector';
import { HerokuBanner } from './Neon/components/HerokuBanner/Banner';
import { Neon } from './Neon';
import _push from '../../push';
interface Props extends InjectedProps {}
@ -38,13 +37,8 @@ const CreateDataSource: React.FC<Props> = ({
<div className={`${styles.container} mb-md`}>
<div className="w-full mb-md">
<Neon
dbCreationCallback={dataSourceName => {
dispatch(_push(`/data/${dataSourceName}`));
}}
errorCallback={() => {
dispatch(_push('/data/manage/connect'));
}}
allDatabases={allDataSources.map(d => d.name)}
dispatch={dispatch}
/>
</div>
<HerokuBanner />

View File

@ -1,5 +1,4 @@
import React from 'react';
import { MdRefresh } from 'react-icons/md';
import { ComponentMeta, Story } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
@ -12,8 +11,9 @@ export default {
export const Base: Story = () => (
<NeonBanner
onButtonClick={() => window.alert('clicked connect button')}
status={{ status: 'default', buttonText: 'Create a Neon Database' }}
onClickConnect={() => window.alert('clicked connect button')}
status={{ status: 'default' }}
buttonText="Create a Neon Database"
/>
);
Base.play = async ({ canvasElement }) => {
@ -31,8 +31,9 @@ Base.play = async ({ canvasElement }) => {
export const Creating: Story = () => (
<NeonBanner
onButtonClick={() => window.alert('clicked connect button')}
status={{ status: 'loading', buttonText: 'Creating Neon Database' }}
onClickConnect={() => window.alert('clicked connect button')}
status={{ status: 'loading' }}
buttonText="Creating Neon Database"
/>
);
Creating.play = async ({ canvasElement }) => {
@ -51,14 +52,14 @@ Creating.play = async ({ canvasElement }) => {
export const Error: Story = () => (
<NeonBanner
onButtonClick={() => window.alert('clicked connect button')}
onClickConnect={() => window.alert('clicked connect button')}
status={{
status: 'error',
buttonText: 'Try Again',
buttonIcon: <MdRefresh />,
errorTitle: 'Your Neon Database connection failed',
errorDescription: 'You have exceeded the free project limit on Neon.',
}}
buttonText="Try Again"
icon="refresh"
/>
);
Error.play = async ({ canvasElement }) => {

View File

@ -1,30 +1,35 @@
import React, { ReactElement } from 'react';
import React from 'react';
import { MdRefresh } from 'react-icons/md';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { NeonIcon } from './NeonIcon';
export type Props = {
onButtonClick: VoidFunction;
status:
const iconMap = {
refresh: <MdRefresh />,
};
type Status =
| {
status: 'loading';
buttonText: string;
}
| {
status: 'error';
buttonText: string;
buttonIcon: ReactElement;
errorTitle: string;
errorDescription: string;
}
| {
status: 'default';
buttonText: string;
};
export type Props = {
status: Status;
onClickConnect: VoidFunction;
buttonText: string;
icon?: keyof typeof iconMap;
};
export function NeonBanner(props: Props) {
const { status, onButtonClick } = props;
const { status, onClickConnect, buttonText, icon } = props;
const isButtonDisabled = status.status === 'loading';
return (
@ -46,17 +51,17 @@ export function NeonBanner(props: Props) {
data-testid="onboarding-wizard-neon-connect-db-button"
mode={status.status === 'loading' ? 'default' : 'primary'}
isLoading={status.status === 'loading'}
loadingText={status.buttonText}
loadingText={buttonText}
size="md"
icon={status.status === 'error' ? status.buttonIcon : undefined}
icon={icon ? iconMap[icon] : undefined}
onClick={() => {
if (!isButtonDisabled) {
onButtonClick();
onClickConnect();
}
}}
disabled={isButtonDisabled}
>
{status.buttonText}
{buttonText}
</Button>
</div>
</div>