console: Refactor cloud console onboarding

[GT-639]: https://hasurahq.atlassian.net/browse/GT-639?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9344
GitOrigin-RevId: 9f135937075baf12d1370508fa6717b7bcdbe4ce
This commit is contained in:
Varun Dey 2023-06-05 14:16:54 +05:30 committed by hasura-bot
parent d8a4d254f6
commit 84ae7a0652
58 changed files with 501 additions and 783 deletions

View File

@ -73,7 +73,7 @@ export {
} from './lib/features/Analytics'; } from './lib/features/Analytics';
export { CloudOnboarding } from './lib/features/CloudOnboarding'; export { CloudOnboarding } from './lib/features/CloudOnboarding';
export { prefetchSurveysData } from './lib/features/Surveys'; export { prefetchSurveysData } from './lib/features/Surveys';
export { prefetchOnboardingData } from './lib/features/CloudOnboarding/OnboardingWizard'; export { prefetchOnboardingData } from './lib/features/CloudOnboarding/NeonOnboardingWizard';
export { export {
prefetchEELicenseInfo, prefetchEELicenseInfo,
NavbarButton as EntepriseNavbarButton, NavbarButton as EntepriseNavbarButton,

View File

@ -1,35 +1,27 @@
import React from 'react'; import React from 'react';
import { useAppDispatch } from '../../../storeHooks'; import { useAppDispatch } from '../../../storeHooks';
import { AllowedSurveyThemes, Survey } from '../../Surveys'; import { ConnectDBScreen, TemplateSummary } from './components';
import { import { DialogContainer } from '../components';
ConnectDBScreen,
TemplateSummary,
DialogContainer,
UseCaseScreen,
} from './components';
import { useWizardState } from './hooks'; import { useWizardState } from './hooks';
import { import {
NEON_TEMPLATE_BASE_PATH,
dialogHeader, dialogHeader,
familiaritySurveySubHeader, NEON_TEMPLATE_BASE_PATH,
stepperNavSteps, stepperNavSteps,
} from './constants'; } from '../constants';
import { OnboardingResponseData } from '../types';
/** /**
* Parent container for the onboarding wizard. Takes care of assembling and rendering all steps. * Parent container for the onboarding wizard. Takes care of assembling and rendering all steps.
*/ */
export function Root() { export function Root(props: {
onboardingData: OnboardingResponseData | undefined;
}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [stepperIndex, setStepperIndex] = React.useState<number>(1); const [stepperIndex, setStepperIndex] = React.useState<number>(1);
const { const { state, setState } = useWizardState(props.onboardingData);
state,
setState,
familiaritySurveyData,
familiaritySurveyOnOptionClick,
} = useWizardState();
const transitionToTemplateSummary = () => { const transitionToTemplateSummary = () => {
setState('template-summary'); setState('template-summary');
@ -40,20 +32,6 @@ export function Root() {
}; };
switch (state) { switch (state) {
case 'familiarity-survey': {
return (
<DialogContainer
header={dialogHeader}
subHeader={familiaritySurveySubHeader}
>
<Survey
theme={AllowedSurveyThemes.familiaritySurveyTheme}
onSubmit={familiaritySurveyOnOptionClick}
data={familiaritySurveyData}
/>
</DialogContainer>
);
}
case 'landing-page': { case 'landing-page': {
return ( return (
<DialogContainer <DialogContainer
@ -87,12 +65,6 @@ export function Root() {
</DialogContainer> </DialogContainer>
); );
} }
case 'use-case-onboarding':
return (
<DialogContainer header="">
<UseCaseScreen dismiss={dismiss} dispatch={dispatch} />
</DialogContainer>
);
case 'hidden': case 'hidden':
default: { default: {
return null; return null;

View File

@ -15,8 +15,8 @@ import {
import { import {
NEON_TEMPLATE_BASE_PATH, NEON_TEMPLATE_BASE_PATH,
skippedNeonOnboardingVariables, skippedNeonOnboardingVariables,
} from '../../../constants'; } from '../../../../constants';
import { emitOnboardingEvent } from '../../../utils'; import { emitOnboardingEvent } from '../../../../utils';
export function NeonOnboarding(props: { export function NeonOnboarding(props: {
dispatch: Dispatch; dispatch: Dispatch;

View File

@ -6,9 +6,9 @@ import { HasuraLogoFull } from '../../../../../new-components/HasuraLogo';
import { Analytics } from '../../../../Analytics'; import { Analytics } from '../../../../Analytics';
import { NeonIcon } from './NeonIcon'; import { NeonIcon } from './NeonIcon';
import _push from '../../../../../components/Services/Data/push'; import _push from '../../../../../components/Services/Data/push';
import { emitOnboardingEvent } from '../../utils'; import { emitOnboardingEvent } from '../../../utils';
import { Dispatch } from '../../../../../types'; import { Dispatch } from '../../../../../types';
import { skippedNeonOnboardingToConnectOtherDB } from '../../constants'; import { skippedNeonOnboardingToConnectOtherDB } from '../../../constants';
const iconMap = { const iconMap = {
refresh: <MdRefresh />, refresh: <MdRefresh />,

View File

@ -6,10 +6,14 @@ import {
staleTime, staleTime,
templateSummaryRunQueryClickVariables, templateSummaryRunQueryClickVariables,
templateSummaryRunQuerySkipVariables, templateSummaryRunQuerySkipVariables,
} from '../../constants'; } from '../../../constants';
import { QueryScreen } from './QueryScreen'; import { QueryScreen } from '../../../components/QueryScreen/QueryScreen';
import { fetchTemplateDataQueryFn, emitOnboardingEvent } from '../../utils'; import { fetchTemplateDataQueryFn } from '../../utils';
import { runQueryInGraphiQL, fillSampleQueryInGraphiQL } from '../../../utils'; import {
runQueryInGraphiQL,
fillSampleQueryInGraphiQL,
emitOnboardingEvent,
} from '../../../utils';
type Props = { type Props = {
templateUrl: string; templateUrl: string;

View File

@ -0,0 +1,2 @@
export { TemplateSummary } from './TemplateSummary/TemplateSummary';
export { ConnectDBScreen } from './ConnectDBScreen/ConnectDBScreen';

View File

@ -2,16 +2,12 @@ import { useEffect } from 'react';
import { NeonIntegrationStatus } from '../../../../components/Services/Data/DataSources/CreateDataSource/Neon/useNeonIntegration'; import { NeonIntegrationStatus } from '../../../../components/Services/Data/DataSources/CreateDataSource/Neon/useNeonIntegration';
import { import {
neonOAuthStartVariables, neonOAuthStartVariables,
// neonOAuthCompleteVariables,
neonDbCreationStartVariables, neonDbCreationStartVariables,
// neonDbCreationCompleteVariables,
hasuraSourceCreationStartVariables, hasuraSourceCreationStartVariables,
// hasuraSourceCreationCompleteVariables,
installTemplateStartVariables, installTemplateStartVariables,
// installTemplateCompleteVariables,
getNeonOnboardingErrorVariables, getNeonOnboardingErrorVariables,
} from '../constants'; } from '../../constants';
import { emitOnboardingEvent } from '../utils'; import { emitOnboardingEvent } from '../../utils';
export function useEmitOnboardingEvents( export function useEmitOnboardingEvents(
neonIntegrationStatus: NeonIntegrationStatus, neonIntegrationStatus: NeonIntegrationStatus,

View File

@ -6,7 +6,7 @@ import { HasuraMetadataV3 } from '../../../../metadata/types';
import { MetadataResponse } from '../../../MetadataAPI'; import { MetadataResponse } from '../../../MetadataAPI';
import { useAppSelector } from '../../../../storeHooks'; import { useAppSelector } from '../../../../storeHooks';
import { useMutation, useQuery } from 'react-query'; import { useMutation, useQuery } from 'react-query';
import { staleTime } from '../constants'; import { staleTime } from '../../constants';
import { fetchTemplateDataQueryFn, transformOldMetadata } from '../utils'; import { fetchTemplateDataQueryFn, transformOldMetadata } from '../utils';
type MutationFnArgs = { type MutationFnArgs = {

View File

@ -6,7 +6,7 @@ import { Api } from '../../../../hooks/apiUtils';
import { useAppSelector } from '../../../../storeHooks'; import { useAppSelector } from '../../../../storeHooks';
import { useMutation, useQuery } from 'react-query'; import { useMutation, useQuery } from 'react-query';
import { fetchTemplateDataQueryFn } from '../utils'; import { fetchTemplateDataQueryFn } from '../utils';
import { staleTime } from '../constants'; import { staleTime } from '../../constants';
import 'whatwg-fetch'; import 'whatwg-fetch';
type MutationFnArgs = { type MutationFnArgs = {

View File

@ -20,7 +20,7 @@ import {
serverDownErrorMessage, serverDownErrorMessage,
} from '../mocks/constants'; } from '../mocks/constants';
import { useInstallTemplate } from './useInstallTemplate'; import { useInstallTemplate } from './useInstallTemplate';
import { NEON_TEMPLATE_BASE_PATH } from '../constants'; import { NEON_TEMPLATE_BASE_PATH } from '../../constants';
import 'whatwg-fetch'; import 'whatwg-fetch';
const server = setupServer(); const server = setupServer();

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useInstallMigration } from './useInstallMigration'; import { useInstallMigration } from './useInstallMigration';
import { getMetadataUrl, getMigrationUrl } from '../constants'; import { getMetadataUrl, getMigrationUrl } from '../../constants';
import { useInstallMetadata } from './useInstallMetadata'; import { useInstallMetadata } from './useInstallMetadata';
/** /**

View File

@ -1,8 +1,8 @@
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { APIError } from '../../../../hooks/error'; import { APIError } from '../../../../hooks/error';
import { fetchAllOnboardingDataQueryFn } from '../utils'; import { fetchAllOnboardingDataQueryFn } from '../utils';
import { onboardingQueryKey } from '../constants'; import { onboardingQueryKey } from '../../constants';
import { OnboardingResponseData } from '../types'; import { OnboardingResponseData } from '../../types';
export function useOnboardingData() { export function useOnboardingData() {
return useQuery<OnboardingResponseData, APIError>( return useQuery<OnboardingResponseData, APIError>(

View File

@ -5,7 +5,7 @@ import {
getMigrationUrl, getMigrationUrl,
getSampleQueriesUrl, getSampleQueriesUrl,
getSchemaImageUrl, getSchemaImageUrl,
} from '../constants'; } from '../../constants';
import { fetchTemplateDataQueryFn } from '../utils'; import { fetchTemplateDataQueryFn } from '../utils';
/** /**

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { getWizardState } from '../utils';
import { OnboardingResponseData } from '../../types';
export type WizardState = 'landing-page' | 'template-summary' | 'hidden';
export function useWizardState(
onboardingData: OnboardingResponseData | undefined
) {
const [state, setState] = useState<WizardState>(
getWizardState(onboardingData)
);
useEffect(() => {
const wizardState = getWizardState(onboardingData);
setState(wizardState);
}, [onboardingData]);
return {
state,
setState,
};
}

View File

@ -0,0 +1,5 @@
import { Root } from './Root';
export { prefetchOnboardingData } from './utils';
export { useOnboardingData } from './hooks';
export const NeonOnboarding = Root;

View File

@ -8,8 +8,8 @@ import {
getSampleQueriesUrl, getSampleQueriesUrl,
getSchemaImageUrl, getSchemaImageUrl,
NEON_TEMPLATE_BASE_PATH, NEON_TEMPLATE_BASE_PATH,
} from '../constants'; } from '../../constants';
import { OnboardingResponseData } from '../types'; import { OnboardingResponseData } from '../../types';
const userMock = { const userMock = {
id: '59300b64-fb3a-4f17-8a0f-6f698569eade', id: '59300b64-fb3a-4f17-8a0f-6f698569eade',

View File

@ -9,7 +9,7 @@ import {
MOCK_MIGRATION_FILE_CONTENTS, MOCK_MIGRATION_FILE_CONTENTS,
serverDownErrorMessage, serverDownErrorMessage,
} from './constants'; } from './constants';
import { OnboardingResponseData } from '../types'; import { OnboardingResponseData } from '../../types';
import { FetchAllSurveysDataQuery } from '../../../ControlPlane'; import { FetchAllSurveysDataQuery } from '../../../ControlPlane';
type ResponseBodyOnSuccess = { type ResponseBodyOnSuccess = {

View File

@ -0,0 +1,104 @@
import { cloudDataServiceApiClient } from '../../../hooks/cloudDataServiceApiClient';
import { Api } from '../../../hooks/apiUtils';
import { HasuraMetadataV3 } from '../../../metadata/types';
import { reactQueryClient } from '../../../lib/reactQuery';
import {
fetchAllOnboardingDataQuery,
fetchAllOnboardingDataQueryVariables,
onboardingQueryKey,
} from '../constants';
import { WizardState } from './hooks/useWizardState';
import { OnboardingResponseData } from '../types';
export function getWizardState(
onboardingData?: OnboardingResponseData
): WizardState {
// if onbarding data is not present due to api error, or data loading state, then hide the wizard
// this early return is required to distinguish between server errors vs data not being present for user
// if the request is successful and data is not present for the given user, then we should show the onboarding wizard
if (!onboardingData?.data) return 'hidden';
// if user created account before the launch of onboarding wizard (Oct 17, 2022),
// hide the wizard and survey
const userCreatedAt = new Date(onboardingData.data.users[0].created_at);
if (userCreatedAt.getTime() < 1666008600000) {
return 'hidden';
}
if (!onboardingData.data.user_onboarding[0]?.is_onboarded) {
return 'landing-page';
}
return 'hidden';
}
const cloudHeaders = {
'content-type': 'application/json',
};
/**
* Utility function to be used as a react query QueryFn, which does a `GET` request to
* fetch our requested object, and returns a promise.
*/
export function fetchTemplateDataQueryFn<
ResponseData,
TransformedData = ResponseData
>(
dataUrl: string,
headers: Record<string, string>,
transformFn?: (data: ResponseData) => TransformedData
) {
return Api.get<ResponseData, TransformedData>(
{
url: dataUrl,
headers,
},
transformFn
);
}
/**
* Utility function which merges the old and additional metadata objects
* to create a new metadata object and returns it.
*/
export const transformOldMetadata = (
oldMetadata: HasuraMetadataV3,
additionalMetadata: HasuraMetadataV3,
source: string
) => {
const newMetadata: HasuraMetadataV3 = {
...oldMetadata,
sources:
oldMetadata?.sources?.map(oldSource => {
if (oldSource.name !== source) {
return oldSource;
}
const metadataObject = additionalMetadata?.sources?.[0];
if (!metadataObject) {
return oldSource;
}
return {
...oldSource,
tables: [...oldSource.tables, ...(metadataObject.tables ?? [])],
functions: [
...(oldSource.functions ?? []),
...(metadataObject.functions ?? []),
],
};
}) ?? [],
};
return newMetadata;
};
export const fetchAllOnboardingDataQueryFn = () =>
cloudDataServiceApiClient<OnboardingResponseData, OnboardingResponseData>(
fetchAllOnboardingDataQuery,
fetchAllOnboardingDataQueryVariables,
cloudHeaders
);
export const prefetchOnboardingData = () => {
reactQueryClient.prefetchQuery(
onboardingQueryKey,
fetchAllOnboardingDataQueryFn
);
};

View File

@ -1,70 +0,0 @@
import React from 'react';
import { FaPlayCircle } from 'react-icons/fa';
import { Dialog } from '../../../../../new-components/Dialog';
import { Button } from '../../../../../new-components/Button';
import { Analytics } from '../../../../Analytics';
export interface Props {
title: string;
description: string;
query: string;
schemaImage: string;
onRunHandler: () => void;
onSkipHandler: () => void;
}
export function QueryDialog(props: Props) {
const {
title,
description,
query,
schemaImage,
onRunHandler,
onSkipHandler,
} = props;
return (
<Dialog hasBackdrop title={title} description={description} size="md">
<>
<div className="mx-4 my-2">
<div className="text-md text-gray-700 mb-2">
We&apos;ve created a <b>`sample`</b> schema to help you get started
using Hasura. It contains the sample structure of a music directory,
as well as a view and function to give you an idea about how Hasura
works.
</div>
<img
className="mb-2"
data-testid="query-dialog-schema-image"
src={schemaImage}
alt="getting-started"
/>
<div data-testid="query-dialog-sample-query">
<div className="text-md text-gray-700 mb-2">
Give it a try with our example query:
</div>
<pre className="border border-gray-300 bg-gray-200 text-muted font-mono text-sm px-4 py-4">
{query}
</pre>
</div>
</div>
<div className="border border-gray-300 shadow-lg bg-white flex justify-between items-center px-4 py-4">
<Analytics name="query-dialog-skip-button">
<div
className="cursor-pointer text-secondary text-sm hover:text-secondary-dark"
onClick={onSkipHandler}
>
Skip getting started tutorial
</div>
</Analytics>
<Analytics name="query-dialog-get-started-button">
<Button mode="primary" onClick={onRunHandler}>
Run Query <FaPlayCircle />
</Button>
</Analytics>
</div>
</>
</Dialog>
);
}

View File

@ -1,185 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '../../../../../new-components/Button';
import { emitOnboardingEvent } from '../../utils';
import {
getUseCaseExperimentOnboardingVariables,
skippedUseCaseExperimentOnboarding,
} from '../../constants';
import { Analytics, trackCustomEvent } from '../../../../Analytics';
import { Dispatch } from '../../../../../types';
import _push from '../../../../../components/Services/Data/push';
type UseCaseScreenProps = { dismiss: () => void; dispatch: Dispatch };
export type UseCases =
| 'data-api'
| 'gql-backend'
| 'data-federation'
| 'gateway';
interface UseCaseAssets {
id: UseCases;
title: string;
description: string;
image: string;
consoleUrl: string;
docsUrl: string;
}
const useCasesAssets: UseCaseAssets[] = [
{
id: 'data-api',
image:
'https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/hasura-usecase-data-api.svg',
title: 'Data Access Layer',
description:
'Build an instant, real-time API over your data sources for easy and performant access',
consoleUrl: '/',
docsUrl: 'https://hasura.io/docs/latest/resources/use-case/data-api/',
},
{
id: 'gql-backend',
image:
'https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/hasura-usecase-gql-backend.svg',
title: 'Graphql Backend',
description:
'Build a lightning-fast GraphQL backend and significantly accelerate your application development',
consoleUrl: '/',
docsUrl: 'https://hasura.io/docs/latest/resources/use-case/gql-backend/',
},
{
id: 'gateway',
image:
'https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/hasura-usecase-gateway.svg',
title: 'API Gateway',
description:
'Build a single entry point from client applications into an ecosystem of microservices',
consoleUrl: '/',
docsUrl: 'https://hasura.io/docs/latest/resources/use-case/api-gateway/',
},
];
export const UseCaseScreen = (props: UseCaseScreenProps) => {
const [selectedUseCase, setSelectedUseCase] = useState<UseCases | null>(null);
useEffect(() => {
trackCustomEvent({
location: 'Console',
action: 'Load',
object: 'Use Case Wizard',
});
}, []);
const randomUseCaseAssets = useMemo(() => {
return useCasesAssets.sort(() => Math.random() - 0.5);
}, [useCasesAssets]);
const useCase = useCasesAssets.find(
useCase => useCase.id === selectedUseCase
);
const onSubmit = () => {
if (useCase) {
props.dispatch(_push(useCase.consoleUrl));
emitOnboardingEvent(getUseCaseExperimentOnboardingVariables(useCase.id));
props.dismiss();
}
};
return (
<div
className="use-case-container border border-solid border-gray-400 rounded-sm flex flex-col flex-wrap p-8 h-full bg-white"
style={{
width: '902px',
}}
>
<div className="use-case-welcome-header flex justify-between w-full">
<div className="use-case-welcome-text font-sans">
<h1 className="text-xl font-bold text-cloud-darkest">
Welcome to Hasura!
</h1>
<div className="text-muted-dark font-normal">
Hasura can help you supercharge your development
</div>
</div>
<div className="use-case-welcome-illustrations h-[93px]">
<img
src="https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-dashboard/dashboard/hasura-loading-illustration.svg"
alt="welcome"
/>
</div>
</div>
<div className="use-case-intro-text text-[#64748B] font-sans mt-3 mb-3.5">
What would you like to build with Hasura?
</div>
<div className="use-cases flex flex-wrap justify-around gap-y-15 gap-y-8">
{randomUseCaseAssets.map((item, index) => (
<div className="flex relative h-[250px]" key={index}>
<label
key={index}
htmlFor={item.id}
className="use-case-card flex flex-col cursor-pointer border border-solid border-slate-300 rounded focus-within:border focus-within:border-solid focus-within:border-amber-500 transition-shadow shadow-none hover:shadow-md w-[400px]"
onChange={event => {
const selectedUseCaseNode = event.target as HTMLInputElement;
setSelectedUseCase(selectedUseCaseNode.id as UseCases);
}}
>
<img src={item.image} alt={item.id} />
<div className="absolute bottom-0 data-api-description flex mt-3 ml-6 mb-2">
<input
type="radio"
id={item.id}
name="use-case"
className="mt-1"
/>
<div className="flex flex-col font-sans ml-2">
<div className="font-semibold text-slate-900">
{item.title}
</div>
<div className="text-muted-dark font-normal">
{item.description}
</div>
</div>
</div>
</label>
</div>
))}
</div>
<div className="use-case-cta flex justify-between w-full mt-8">
<Analytics name="use-case-onboarding-skip">
<div
className="ml-xs mr-4 text-secondary flex items-center cursor-pointer"
onClick={() => {
props.dismiss();
emitOnboardingEvent(skippedUseCaseExperimentOnboarding);
}}
>
Skip
</div>
</Analytics>
{useCase ? (
<Analytics name={`use-case-onboarding-${selectedUseCase}`}>
<a
href={useCase.docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={onSubmit}
>
<Button mode="primary">Continue</Button>
</a>
</Analytics>
) : (
<Analytics
name={`use-case-onboarding-unselected`}
passHtmlAttributesToChildren
>
<Button mode="primary" disabled>
Continue
</Button>
</Analytics>
)}
</div>
</div>
);
};

View File

@ -1,46 +0,0 @@
import { useEffect, useState } from 'react';
import { useSurveysData } from '../../../Surveys';
import { useOnboardingData } from './useOnboardingData';
import { getWizardState } from '../utils';
import { AllowedSurveyNames } from '../../../Surveys/types';
import { getLSItem, LS_KEYS, removeLSItem, setLSItem } from '../../../../utils';
export type WizardState =
| 'familiarity-survey'
| 'landing-page'
| 'template-summary'
| 'use-case-onboarding'
| 'hidden';
export function useWizardState() {
const {
show: showSurvey,
data: familiaritySurveyData,
onSubmit: familiaritySurveyOnOptionClick,
} = useSurveysData({ surveyName: AllowedSurveyNames.familiaritySurvey });
const { data: onboardingData } = useOnboardingData();
const [state, setState] = useState<WizardState>(
getWizardState(showSurvey, onboardingData)
);
useEffect(() => {
const wizardState = getWizardState(showSurvey, onboardingData);
if (wizardState !== 'hidden') {
setLSItem(LS_KEYS.showUseCaseOverviewPopup, 'true');
}
setState(wizardState);
//removing skipOnboarding key from LocalStorage
if (getLSItem(LS_KEYS.skipOnboarding) === 'true') {
removeLSItem(LS_KEYS.skipOnboarding);
}
}, [onboardingData, showSurvey]);
return {
state,
setState,
familiaritySurveyData,
familiaritySurveyOnOptionClick,
};
}

View File

@ -1,8 +0,0 @@
import { Root } from './Root';
export { prefetchOnboardingData, emitOnboardingEvent } from './utils';
export { oneClickDeploymentOnboardingShown } from './constants';
export { useOnboardingData } from './hooks';
export type { UserOnboarding, OnboardingResponseData } from './types';
export { DialogContainer } from './components';
export const OnboardingWizard = Root;

View File

@ -1,38 +0,0 @@
import { One_Click_Deployment_States_Enum } from '../../ControlPlane';
export type UserOnboarding = {
activity: Record<string, any>;
target: string;
};
export type User = {
id: string;
created_at: string;
};
export type OneClickDeploymentByProject = {
id: number;
state: One_Click_Deployment_States_Enum;
git_repository_url: string;
git_repository_branch?: string;
hasura_directory?: string;
};
export type OneClickDeploymentSampleApp = {
git_repository_url: string;
git_repository_branch: string;
hasura_directory: string;
name: string;
description?: string;
react_icons_fa_component_name?: string;
rank: number;
};
export type OnboardingResponseData = {
data: {
user_onboarding: UserOnboarding[];
users: User[];
one_click_deployment: OneClickDeploymentByProject[];
one_click_deployment_sample_apps: OneClickDeploymentSampleApp[];
};
};

View File

@ -1,221 +0,0 @@
import { parse, print } from 'graphql';
import { cloudDataServiceApiClient } from '../../../hooks/cloudDataServiceApiClient';
import { Api } from '../../../hooks/apiUtils';
import { HasuraMetadataV3 } from '../../../metadata/types';
import { reactQueryClient } from '../../../lib/reactQuery';
import { programmaticallyTraceError } from '../../Analytics';
import {
skippedNeonOnboardingVariables,
onboardingCompleteVariables,
templateSummaryRunQueryClickVariables,
templateSummaryRunQuerySkipVariables,
hasuraSourceCreationStartVariables,
trackOnboardingActivityMutation,
onboardingQueryKey,
fetchAllOnboardingDataQuery,
fetchAllOnboardingDataQueryVariables,
oneClickDeploymentOnboardingShown,
useCaseExperimentOnboarding,
skippedOnboardingThroughURLParamVariables,
skippedUseCaseExperimentOnboarding,
skippedNeonOnboardingToConnectOtherDB,
} from './constants';
import { WizardState } from './hooks/useWizardState';
import { OnboardingResponseData, UserOnboarding } from './types';
import { getLSItem, LS_KEYS } from '../../../utils';
export function shouldShowOnboarding(onboardingData: UserOnboarding) {
const userActivity = onboardingData?.activity;
if (
userActivity?.[skippedNeonOnboardingVariables.kind]?.value === 'true' ||
userActivity?.[skippedUseCaseExperimentOnboarding.kind]?.value === 'true' ||
userActivity?.[skippedOnboardingThroughURLParamVariables.kind]?.value ===
'true' ||
userActivity?.[skippedNeonOnboardingToConnectOtherDB.kind]?.value ===
'true' ||
userActivity?.[onboardingCompleteVariables.kind]?.value === 'true' ||
userActivity?.[hasuraSourceCreationStartVariables.kind]?.value === 'true' ||
userActivity?.[templateSummaryRunQuerySkipVariables.kind]?.value ===
'true' ||
userActivity?.[templateSummaryRunQueryClickVariables.kind]?.value ===
'true' ||
userActivity?.[oneClickDeploymentOnboardingShown.kind]?.value === 'true' ||
userActivity?.[useCaseExperimentOnboarding.kind]?.value === 'true' ||
userActivity?.[oneClickDeploymentOnboardingShown.kind]?.value === 'true'
) {
return false;
}
if (getLSItem(LS_KEYS.skipOnboarding) === 'true') {
emitOnboardingEvent(skippedOnboardingThroughURLParamVariables);
return false;
}
return true;
}
const nullUserOnboardingData = {
activity: {},
target: 'cloud_console',
};
/**
* Transforms server returned data to the required format.
*/
function onboardingDataTransformFn(
data: OnboardingResponseData
): UserOnboarding {
return (
data?.data?.user_onboarding?.find(
onboarding => onboarding.target === 'cloud_console'
) || nullUserOnboardingData
);
}
export function getWizardState(
showFamiliaritySurvey: boolean,
onboardingData?: OnboardingResponseData
): WizardState {
// if onbarding data is not present due to api error, or data loading state, then hide the wizard
// this early return is required to distinguish between server errors vs data not being present for user
// if the request is successful and data is not present for the given user, then we should show the onboarding wizard
if (!onboardingData?.data) return 'hidden';
// if user created account before the launch of onboarding wizard (Oct 17, 2022),
// hide the wizard and survey
const userCreatedAt = new Date(onboardingData.data.users[0].created_at);
if (userCreatedAt.getTime() < 1666008600000) {
return 'hidden';
}
// transform the onboarding data if present, to a consumable format
const transformedOnboardingData = onboardingDataTransformFn(onboardingData);
if (shouldShowOnboarding(transformedOnboardingData)) {
if (showFamiliaritySurvey) return 'familiarity-survey';
return 'use-case-onboarding';
}
return 'hidden';
}
type ResponseDataOnMutation = {
data: {
trackOnboardingActivity: {
status: string;
};
};
};
const cloudHeaders = {
'content-type': 'application/json',
};
export const emitOnboardingEvent = (variables: Record<string, unknown>) => {
// mutate server data
cloudDataServiceApiClient<ResponseDataOnMutation, ResponseDataOnMutation>(
trackOnboardingActivityMutation,
variables,
cloudHeaders
).catch(error => {
programmaticallyTraceError(error);
});
};
/**
* Utility function to be used as a react query QueryFn, which does a `GET` request to
* fetch our requested object, and returns a promise.
*/
export function fetchTemplateDataQueryFn<
ResponseData,
TransformedData = ResponseData
>(
dataUrl: string,
headers: Record<string, string>,
transformFn?: (data: ResponseData) => TransformedData
) {
return Api.get<ResponseData, TransformedData>(
{
url: dataUrl,
headers,
},
transformFn
);
}
/**
* Utility function which merges the old and additional metadata objects
* to create a new metadata object and returns it.
*/
export const transformOldMetadata = (
oldMetadata: HasuraMetadataV3,
additionalMetadata: HasuraMetadataV3,
source: string
) => {
const newMetadata: HasuraMetadataV3 = {
...oldMetadata,
sources:
oldMetadata?.sources?.map(oldSource => {
if (oldSource.name !== source) {
return oldSource;
}
const metadataObject = additionalMetadata?.sources?.[0];
if (!metadataObject) {
return oldSource;
}
return {
...oldSource,
tables: [...oldSource.tables, ...(metadataObject.tables ?? [])],
functions: [
...(oldSource.functions ?? []),
...(metadataObject.functions ?? []),
],
};
}) ?? [],
};
return newMetadata;
};
/**
* Utility function to parse a string with multiple graphql queries and pick the one with given operation name
* */
export function getQueryFromSampleQueries(
allQueries: string,
operationName: string
): string {
const doc = parse(allQueries);
const { definitions: allDefinitions } = doc;
const definitions = allDefinitions.filter(
d => d.kind === 'OperationDefinition'
);
let queryDef = definitions
.filter(d => d.kind === 'OperationDefinition')
.find(d => {
if (d.kind === 'OperationDefinition') {
return d.name?.value === operationName;
}
return false;
});
if (!queryDef) {
if (definitions.length > 0) {
queryDef = definitions[0];
} else {
throw new Error('no valid operations in sample.graphql');
}
}
return print({
...doc,
definitions: [queryDef],
});
}
export const fetchAllOnboardingDataQueryFn = () =>
cloudDataServiceApiClient<OnboardingResponseData, OnboardingResponseData>(
fetchAllOnboardingDataQuery,
fetchAllOnboardingDataQueryVariables,
cloudHeaders
);
export const prefetchOnboardingData = () => {
reactQueryClient.prefetchQuery(
onboardingQueryKey,
fetchAllOnboardingDataQueryFn
);
};

View File

@ -3,7 +3,8 @@ import globals from '../../../Globals';
import { FaGithub } from 'react-icons/fa'; import { FaGithub } from 'react-icons/fa';
import { GraphiqlPopup, WorkflowProgress } from './components'; import { GraphiqlPopup, WorkflowProgress } from './components';
import { stepperNavSteps } from './constants'; import { stepperNavSteps } from './constants';
import { DialogContainer } from '../OnboardingWizard'; import { oneClickDeploymentOnboardingShown } from '../constants';
import { DialogContainer } from '../components';
import { useTriggerDeployment } from './hooks'; import { useTriggerDeployment } from './hooks';
import { GitRepoDetails, FallbackApp } from './types'; import { GitRepoDetails, FallbackApp } from './types';
import { import {
@ -11,6 +12,7 @@ import {
getGitRepoFullLinkFromDetails, getGitRepoFullLinkFromDetails,
getSampleQueriesUrl, getSampleQueriesUrl,
} from './util'; } from './util';
import { emitOnboardingEvent } from '../utils';
/** /**
* Parent container for the one click deployment wizard. Takes care of assembling and rendering all steps. * Parent container for the one click deployment wizard. Takes care of assembling and rendering all steps.
@ -39,6 +41,7 @@ export function Root(props: {
const transitionToQueryPopupSuccessState = () => { const transitionToQueryPopupSuccessState = () => {
setGraphiQlPopupStatus('success'); setGraphiQlPopupStatus('success');
setState('graphiql-popup'); setState('graphiql-popup');
emitOnboardingEvent(oneClickDeploymentOnboardingShown);
}; };
const transitionToQueryPopupWithErrorState = () => { const transitionToQueryPopupWithErrorState = () => {
setGraphiQlPopupStatus('error'); setGraphiQlPopupStatus('error');

View File

@ -9,6 +9,7 @@ import {
ProgressStateStatus, ProgressStateStatus,
GitRepoDetails, GitRepoDetails,
} from './types'; } from './types';
import { OnboardingKind, OnboardingResponseData } from '../types';
// returns the error status if the step had an error in the latest workflow // returns the error status if the step had an error in the latest workflow
// returns null if the step hasn't had an error in the latest workflow // returns null if the step hasn't had an error in the latest workflow
@ -295,3 +296,19 @@ export const getSampleQueriesUrl = (gitRepoDetails: GitRepoDetails) => {
return ''; return '';
} }
}; };
export const oneClickDeploymentOnboardingKind = (
onboardingData: OnboardingResponseData
): OnboardingKind => ({
kind: 'one-click-deployment',
deployment: {
deploymentId: onboardingData.data.one_click_deployment[0].id,
gitRepoDetails: {
url: onboardingData.data.one_click_deployment[0].git_repository_url,
branch: onboardingData.data.one_click_deployment[0].git_repository_branch,
hasuraDirectory:
onboardingData.data.one_click_deployment[0].hasura_directory,
},
},
fallbackApps: onboardingData.data.one_click_deployment_sample_apps || [],
});

View File

@ -0,0 +1,192 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '../../../new-components/Button';
import { emitOnboardingEvent } from '../utils';
import {
getUseCaseExperimentOnboardingVariables,
skippedUseCaseExperimentOnboarding,
} from '../constants';
import { Analytics, trackCustomEvent } from '../../Analytics';
import _push from '../../../components/Services/Data/push';
import { useAppDispatch } from '../../../storeHooks';
import { DialogContainer } from '../components';
type UseCaseScreenProps = {
dismiss: () => void;
};
export type UseCases =
| 'data-api'
| 'gql-backend'
| 'data-federation'
| 'gateway';
interface UseCaseAssets {
id: UseCases;
title: string;
description: string;
image: string;
consoleUrl: string;
docsUrl: string;
}
const useCasesAssets: UseCaseAssets[] = [
{
id: 'data-api',
image:
'https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/hasura-usecase-data-api.svg',
title: 'Data Access Layer',
description:
'Build an instant, real-time API over your data sources for easy and performant access',
consoleUrl: '/',
docsUrl: 'https://hasura.io/docs/latest/resources/use-case/data-api/',
},
{
id: 'gql-backend',
image:
'https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/hasura-usecase-gql-backend.svg',
title: 'Graphql Backend',
description:
'Build a lightning-fast GraphQL backend and significantly accelerate your application development',
consoleUrl: '/',
docsUrl: 'https://hasura.io/docs/latest/resources/use-case/gql-backend/',
},
{
id: 'gateway',
image:
'https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/hasura-usecase-gateway.svg',
title: 'API Gateway',
description:
'Build a single entry point from client applications into an ecosystem of microservices',
consoleUrl: '/',
docsUrl: 'https://hasura.io/docs/latest/resources/use-case/api-gateway/',
},
];
export const Root = (props: UseCaseScreenProps) => {
const [selectedUseCase, setSelectedUseCase] = useState<UseCases | null>(null);
useEffect(() => {
trackCustomEvent({
location: 'Console',
action: 'Load',
object: 'Use Case Wizard',
});
}, []);
const dispatch = useAppDispatch();
const randomUseCaseAssets = useMemo(() => {
return useCasesAssets.sort(() => Math.random() - 0.5);
}, [useCasesAssets]);
const useCase = useCasesAssets.find(
useCase => useCase.id === selectedUseCase
);
const onSubmit = () => {
if (useCase) {
dispatch(_push(useCase.consoleUrl));
emitOnboardingEvent(getUseCaseExperimentOnboardingVariables(useCase.id));
props.dismiss();
}
};
return (
<DialogContainer header={''}>
<div
className="use-case-container border border-solid border-gray-400 rounded-sm flex flex-col flex-wrap p-8 h-full bg-white"
style={{
width: '902px',
}}
>
<div className="use-case-welcome-header flex justify-between w-full">
<div className="use-case-welcome-text font-sans">
<h1 className="text-xl font-bold text-cloud-darkest">
Welcome to Hasura!
</h1>
<div className="text-muted-dark font-normal">
Hasura can help you supercharge your development
</div>
</div>
<div className="use-case-welcome-illustrations h-[93px]">
<img
src="https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-dashboard/dashboard/hasura-loading-illustration.svg"
alt="welcome"
/>
</div>
</div>
<div className="use-case-intro-text text-[#64748B] font-sans mt-3 mb-3.5">
What would you like to build with Hasura?
</div>
<div className="use-cases flex flex-wrap justify-around gap-y-15 gap-y-8">
{randomUseCaseAssets.map((item, index) => (
<div className="flex relative h-[250px]" key={index}>
<label
key={index}
htmlFor={item.id}
className="use-case-card flex flex-col cursor-pointer border border-solid border-slate-300 rounded focus-within:border focus-within:border-solid focus-within:border-amber-500 transition-shadow shadow-none hover:shadow-md w-[400px]"
onChange={event => {
const selectedUseCaseNode = event.target as HTMLInputElement;
setSelectedUseCase(selectedUseCaseNode.id as UseCases);
}}
>
<img src={item.image} alt={item.id} />
<div className="absolute bottom-0 data-api-description flex mt-3 ml-6 mb-2">
<input
type="radio"
id={item.id}
name="use-case"
className="mt-1"
/>
<div className="flex flex-col font-sans ml-2">
<div className="font-semibold text-slate-900">
{item.title}
</div>
<div className="text-muted-dark font-normal">
{item.description}
</div>
</div>
</div>
</label>
</div>
))}
</div>
<div className="use-case-cta flex justify-between w-full mt-8">
<Analytics name="use-case-onboarding-skip">
<div
className="ml-xs mr-4 text-secondary flex items-center cursor-pointer"
onClick={() => {
props.dismiss();
emitOnboardingEvent(skippedUseCaseExperimentOnboarding);
}}
>
Skip
</div>
</Analytics>
{useCase ? (
<Analytics name={`use-case-onboarding-${selectedUseCase}`}>
<a
href={useCase.docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={onSubmit}
>
<Button mode="primary">Continue</Button>
</a>
</Analytics>
) : (
<Analytics
name={`use-case-onboarding-unselected`}
passHtmlAttributesToChildren
>
<Button mode="primary" disabled>
Continue
</Button>
</Analytics>
)}
</div>
</div>
</DialogContainer>
);
};

View File

@ -0,0 +1,3 @@
import { Root } from './Root';
export const UseCaseOnboarding = Root;

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Button } from '../../../../../new-components/Button'; import { Button } from '../../../../new-components/Button';
import { FaPlayCircle } from 'react-icons/fa'; import { FaPlayCircle } from 'react-icons/fa';
import { Analytics } from '../../../../Analytics'; import { Analytics } from '../../../Analytics';
export interface Props { export interface Props {
schemaImage: string; schemaImage: string;

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { HasuraLogoFull } from '../../../../../new-components/HasuraLogo'; import { HasuraLogoFull } from '../../../../new-components/HasuraLogo';
export function TopHeaderBar() { export function TopHeaderBar() {
return ( return (

View File

@ -1,6 +1,3 @@
export { ConnectDBScreen } from './ConnectDBScreen/ConnectDBScreen';
export { TemplateSummary } from './QueryScreen/TemplateSummary';
export { StepperNavbar } from './StepperNavbar/StepperNavbar'; export { StepperNavbar } from './StepperNavbar/StepperNavbar';
export type { StepperNavbarStep } from './StepperNavbar/StepperNavbar'; export type { StepperNavbarStep } from './StepperNavbar/StepperNavbar';
export { DialogContainer } from './DialogContainer/DialogContainer'; export { DialogContainer } from './DialogContainer/DialogContainer';
export { UseCaseScreen } from './UseCaseScreen/UseCaseScreen';

View File

@ -1,6 +1,6 @@
import globals from '../../../Globals'; import globals from '../../Globals';
import { BASE_URL_TEMPLATE } from '../../../components/Services/Data/Schema/TemplateGallery/templateGalleryConfig'; import { BASE_URL_TEMPLATE } from '../../components/Services/Data/Schema/TemplateGallery/templateGalleryConfig';
import { UseCases } from './components/UseCaseScreen/UseCaseScreen'; import { UseCases } from './UseCaseOnboarding/Root';
// This config is stored in root level index.js, and there is a config file in each directory which stores which // This config is stored in root level index.js, and there is a config file in each directory which stores which
// stores the directory structure. // stores the directory structure.
@ -16,9 +16,10 @@ export const onboardingQueryKey = 'onboardingData';
*/ */
export const fetchAllOnboardingDataQuery = ` export const fetchAllOnboardingDataQuery = `
query fetchAllOnboardingData ($projectId: uuid!) { query fetchAllOnboardingData ($projectId: uuid!) {
user_onboarding { user_onboarding(where: {target: {_eq: "cloud_console"}}) {
activity activity
target target
is_onboarded
} }
users { users {
id id
@ -63,8 +64,6 @@ export const getSchemaImageUrl = (baseUrl: string) => {
return `${baseUrl}/diagram.png`; return `${baseUrl}/diagram.png`;
}; };
export const NEON_ONBOARDING_QUERY_KEY = 'neonOnboarding';
export const trackOnboardingActivityMutation = ` export const trackOnboardingActivityMutation = `
mutation trackOnboardingActivity($projectId: uuid!, $kind: String!, $error_code: String, $subkind: String,) { mutation trackOnboardingActivity($projectId: uuid!, $kind: String!, $error_code: String, $subkind: String,) {
trackOnboardingActivity(payload: {kind: $kind, project_id: $projectId, error_code: $error_code, subkind: $subkind}) { trackOnboardingActivity(payload: {kind: $kind, project_id: $projectId, error_code: $error_code, subkind: $subkind}) {
@ -79,11 +78,6 @@ const mutationVariables = {
...(projectId && { projectId }), ...(projectId && { projectId }),
}; };
export const onboardingCompleteVariables = {
...mutationVariables,
kind: 'onboarding_complete',
};
export const skippedOnboardingThroughURLParamVariables = { export const skippedOnboardingThroughURLParamVariables = {
...mutationVariables, ...mutationVariables,
kind: 'skipped_neon_onboarding_through_url_param', kind: 'skipped_neon_onboarding_through_url_param',
@ -103,41 +97,21 @@ export const neonOAuthStartVariables = {
kind: 'neon_login_start', kind: 'neon_login_start',
}; };
export const neonOAuthCompleteVariables = {
...mutationVariables,
kind: 'neon_login_complete',
};
export const neonDbCreationStartVariables = { export const neonDbCreationStartVariables = {
...mutationVariables, ...mutationVariables,
kind: 'neon_db_creation_start', kind: 'neon_db_creation_start',
}; };
export const neonDbCreationCompleteVariables = {
...mutationVariables,
kind: 'neon_db_creation_complete',
};
export const hasuraSourceCreationStartVariables = { export const hasuraSourceCreationStartVariables = {
...mutationVariables, ...mutationVariables,
kind: 'hasura_source_creation_start', kind: 'hasura_source_creation_start',
}; };
export const hasuraSourceCreationCompleteVariables = {
...mutationVariables,
kind: 'hasura_source_creation_complete',
};
export const installTemplateStartVariables = { export const installTemplateStartVariables = {
...mutationVariables, ...mutationVariables,
kind: 'install_template_start', kind: 'install_template_start',
}; };
export const installTemplateCompleteVariables = {
...mutationVariables,
kind: 'install_template_complete',
};
export const templateSummaryRunQueryClickVariables = { export const templateSummaryRunQueryClickVariables = {
...mutationVariables, ...mutationVariables,
kind: 'run_query_click', kind: 'run_query_click',

View File

@ -1,60 +1,53 @@
import * as React from 'react'; import * as React from 'react';
import { OnboardingKind, OnboardingResponseData } from '../types';
import { APIError } from '../../../hooks/error';
import { getLSItem, LS_KEYS, removeLSItem } from '../../../utils';
import { skippedOnboardingThroughURLParamVariables } from '../constants';
import { emitOnboardingEvent } from '../utils';
import { oneClickDeploymentOnboardingKind } from '../OneClickDeployment/util';
import { OneClickDeploymentState } from '../OneClickDeployment'; import { OneClickDeploymentState } from '../OneClickDeployment';
import {
useOnboardingData,
emitOnboardingEvent,
oneClickDeploymentOnboardingShown,
} from '../OnboardingWizard';
import { OnboardingKind } from '../types';
export const useOnboardingKind = () => { export const useOnboardingKind = (
const { data, error, isLoading } = useOnboardingData(); onboardingData: OnboardingResponseData | undefined,
error: APIError | null,
isLoading: boolean
) => {
const [kind, setKind] = React.useState<OnboardingKind>({ kind: 'none' }); const [kind, setKind] = React.useState<OnboardingKind>({ kind: 'none' });
React.useEffect(() => { React.useEffect(() => {
if (error || isLoading) { if (error || isLoading || !onboardingData?.data) {
setKind({ kind: 'none' });
// TODO: emit onboarding error // TODO: emit onboarding error
return; return;
} }
if (!data?.data) { const is_onboarded =
setKind({ kind: 'none' }); onboardingData?.data?.user_onboarding[0]?.is_onboarded || false;
// TODO: emit onboarding error const isSkipOnboardingFlagSet =
return; getLSItem(LS_KEYS.skipOnboarding) === 'true';
if (isSkipOnboardingFlagSet) {
emitOnboardingEvent(skippedOnboardingThroughURLParamVariables);
removeLSItem(LS_KEYS.skipOnboarding);
} }
// if there's a pending one-click-deployment for this project, if (
// show the deployment status onboardingData?.data?.one_click_deployment[0] &&
// if the deployment is complete, show nothing onboardingData?.data?.one_click_deployment[0]?.state !==
const deployment = data.data.one_click_deployment[0]; OneClickDeploymentState.Completed
if (deployment?.state === OneClickDeploymentState.Completed) { ) {
setKind({ kind: 'none' });
return;
}
if (deployment) {
// TODO: Skip this block also if state == 'ERROR' && retryCount >= something // TODO: Skip this block also if state == 'ERROR' && retryCount >= something
setKind(oneClickDeploymentOnboardingKind(onboardingData));
return;
}
setKind({ if (is_onboarded || isSkipOnboardingFlagSet) {
kind: 'one-click-deployment', setKind({ kind: 'none' });
deployment: {
deploymentId: data.data.one_click_deployment[0].id,
gitRepoDetails: {
url: data.data.one_click_deployment[0].git_repository_url,
branch: data.data.one_click_deployment[0].git_repository_branch,
hasuraDirectory: data.data.one_click_deployment[0].hasura_directory,
},
},
fallbackApps: data.data.one_click_deployment_sample_apps || [],
});
// emit onboarding event denoting onboarding through one click deployment
emitOnboardingEvent(oneClickDeploymentOnboardingShown);
return; return;
} }
// pass control to onboarding wizard // pass control to onboarding wizard
setKind({ kind: 'wizard' }); setKind({ kind: 'use-case-onboarding' });
}, [data, error, isLoading]); }, [onboardingData, error, isLoading]);
// if there's error fetching onboarding data // if there's error fetching onboarding data
// do not show anything and proceed to the console // do not show anything and proceed to the console

View File

@ -1,17 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import globals from '../../Globals'; import globals from '../../Globals';
import { isCloudConsole } from '../../utils/cloudConsole'; import { isCloudConsole } from '../../utils';
import { OneClickDeployment } from './OneClickDeployment'; import { OneClickDeployment } from './OneClickDeployment';
import { OnboardingWizard } from './OnboardingWizard'; import { NeonOnboarding, useOnboardingData } from './NeonOnboardingWizard';
import { UseCaseOnboarding } from './UseCaseOnboarding';
import { useOnboardingKind } from './hooks/useOnboardingKind'; import { useOnboardingKind } from './hooks/useOnboardingKind';
import { useFallbackApps } from './hooks/useFallbackApps'; import { useFallbackApps } from './hooks/useFallbackApps';
export const CloudOnboardingWithoutCloudCheck = () => { export const CloudOnboardingWithoutCloudCheck = () => {
const cloudOnboarding = useOnboardingKind(); const { data, error, isLoading } = useOnboardingData();
const cloudOnboarding = useOnboardingKind(data, error, isLoading);
const fallbackApps = useFallbackApps(cloudOnboarding); const fallbackApps = useFallbackApps(cloudOnboarding);
switch (cloudOnboarding.kind) { switch (cloudOnboarding.kind) {
case 'wizard': case 'neon-onboarding':
return <OnboardingWizard />; return <NeonOnboarding onboardingData={data} />;
case 'one-click-deployment': case 'one-click-deployment':
return ( return (
<OneClickDeployment <OneClickDeployment
@ -20,6 +22,8 @@ export const CloudOnboardingWithoutCloudCheck = () => {
fallbackApps={fallbackApps} fallbackApps={fallbackApps}
/> />
); );
case 'use-case-onboarding':
return <UseCaseOnboarding dismiss={cloudOnboarding.dismissOnboarding} />;
default: default:
return null; return null;
} }

View File

@ -1,12 +1,47 @@
import { OnboardingResponseData } from './OnboardingWizard'; import { One_Click_Deployment_States_Enum } from '../ControlPlane';
import { GitRepoDetails, FallbackApp } from './OneClickDeployment'; import { GitRepoDetails, FallbackApp } from './OneClickDeployment';
export type OneClickDeploymentSampleApp = export type UserOnboarding = {
OnboardingResponseData['data']['one_click_deployment_sample_apps']; activity: Record<string, any>;
target: string;
is_onboarded: boolean;
};
export type User = {
id: string;
created_at: string;
};
export type OneClickDeploymentByProject = {
id: number;
state: One_Click_Deployment_States_Enum;
git_repository_url: string;
git_repository_branch?: string;
hasura_directory?: string;
};
export type OneClickDeploymentSampleApp = {
git_repository_url: string;
git_repository_branch: string;
hasura_directory: string;
name: string;
description?: string;
react_icons_fa_component_name?: string;
rank: number;
};
export type OnboardingResponseData = {
data: {
user_onboarding: UserOnboarding[];
users: User[];
one_click_deployment: OneClickDeploymentByProject[];
one_click_deployment_sample_apps: OneClickDeploymentSampleApp[];
};
};
export type OnboardingKind = export type OnboardingKind =
| { | {
kind: 'wizard'; kind: 'neon-onboarding';
} }
| { | {
kind: 'one-click-deployment'; kind: 'one-click-deployment';
@ -16,6 +51,9 @@ export type OnboardingKind =
}; };
fallbackApps: OnboardingResponseData['data']['one_click_deployment_sample_apps']; fallbackApps: OnboardingResponseData['data']['one_click_deployment_sample_apps'];
} }
| {
kind: 'use-case-onboarding';
}
| { | {
kind: 'none'; kind: 'none';
}; };

View File

@ -1,9 +1,12 @@
import { import {
clickRunQueryButton, clickRunQueryButton,
forceGraphiQLIntrospection,
forceChangeGraphiqlQuery, forceChangeGraphiqlQuery,
forceGraphiQLIntrospection,
} from '../../components/Services/ApiExplorer/OneGraphExplorer/utils'; } from '../../components/Services/ApiExplorer/OneGraphExplorer/utils';
import { Dispatch } from '../../types'; import { Dispatch } from '../../types';
import { cloudDataServiceApiClient } from '../../hooks/cloudDataServiceApiClient';
import { trackOnboardingActivityMutation } from './constants';
import { programmaticallyTraceError } from '../Analytics/core/programmaticallyTraceError';
export const runQueryInGraphiQL = () => { export const runQueryInGraphiQL = () => {
clickRunQueryButton(); clickRunQueryButton();
@ -21,3 +24,26 @@ export const fillSampleQueryInGraphiQL = (
forceChangeGraphiqlQuery(query, dispatch); forceChangeGraphiqlQuery(query, dispatch);
}, 500); }, 500);
}; };
type ResponseDataOnMutation = {
data: {
trackOnboardingActivity: {
status: string;
};
};
};
const cloudHeaders = {
'content-type': 'application/json',
};
export const emitOnboardingEvent = (variables: Record<string, unknown>) => {
// mutate server data
cloudDataServiceApiClient<ResponseDataOnMutation, ResponseDataOnMutation>(
trackOnboardingActivityMutation,
variables,
cloudHeaders
).catch(error => {
programmaticallyTraceError(error);
});
};

View File

@ -125,7 +125,6 @@ export const LS_KEYS = {
notificationsLastSeen: 'notifications:lastSeen', notificationsLastSeen: 'notifications:lastSeen',
authState: 'AUTH_STATE', authState: 'AUTH_STATE',
skipOnboarding: 'SKIP_CLOUD_ONBOARDING', skipOnboarding: 'SKIP_CLOUD_ONBOARDING',
showUseCaseOverviewPopup: 'onboarding:showUseCaseOverviewPopup',
}; };
export const clearGraphiqlLS = () => { export const clearGraphiqlLS = () => {

View File

@ -1,63 +0,0 @@
import React from 'react';
import {
Analytics,
Button,
getLSItem,
LS_KEYS,
setLSItem,
} from '@hasura/console-legacy-ce';
import { MdClose } from 'react-icons/md';
const ExploreUseCasePopup = () => {
const showUseCaseOverviewPopup = getLSItem(LS_KEYS.showUseCaseOverviewPopup);
const [showUseCasePopup, setShowUseCasePopup] =
React.useState<boolean>(false);
React.useEffect(() => {
setShowUseCasePopup(showUseCaseOverviewPopup === 'true');
}, [showUseCaseOverviewPopup]);
const useCaseOverviewDocsUrl =
'https://hasura.io/docs/latest/resources/use-case/overview/';
return (
showUseCasePopup && (
<div className="z-[10] fixed w-96 bottom-14 right-12 border border-slate-300 abc">
<div className="p-sm flex space-x-1.5 bg-emerald-50 justify-between">
Want to learn more? Find out how you can leverage Hasura! 🚀
<Analytics name="use-case-popup-dismiss">
<MdClose
className="mt-1 cursor-pointer"
onClick={() => {
setShowUseCasePopup(false);
setLSItem(LS_KEYS.showUseCaseOverviewPopup, 'false');
}}
/>
</Analytics>
</div>
<div className="p-sm bg-slate-50 border-t border-slate-300">
<Analytics name="use-case-popup-explore" passHtmlAttributesToChildren>
<Button
mode="default"
className="w-full"
onClick={() => {
setShowUseCasePopup(false);
setLSItem(LS_KEYS.showUseCaseOverviewPopup, 'false');
window.open(
useCaseOverviewDocsUrl,
'_blank',
'noreferrer,noopener'
);
}}
>
Explore
</Button>
</Analytics>
</div>
</div>
)
);
};
export default ExploreUseCasePopup;

View File

@ -93,7 +93,6 @@ import logoutIcon from './images/log-out.svg';
import EELogo from './images/hasura-ee-mono-light.svg'; import EELogo from './images/hasura-ee-mono-light.svg';
import { isHasuraCollaboratorUser } from '../Login/utils'; import { isHasuraCollaboratorUser } from '../Login/utils';
import { ConsoleDevTools } from '@hasura/console-legacy-ce'; import { ConsoleDevTools } from '@hasura/console-legacy-ce';
import ExploreUseCasePopup from './ExploreUseCasePopup';
const { Plan, Project_Entitlement_Types_Enum } = ControlPlane; const { Plan, Project_Entitlement_Types_Enum } = ControlPlane;
class Main extends React.Component { class Main extends React.Component {
@ -727,7 +726,6 @@ class Main extends React.Component {
metadata={metadata?.metadataObject} metadata={metadata?.metadataObject}
/> />
) : null} ) : null}
<ExploreUseCasePopup />
</div> </div>
<CloudOnboarding /> <CloudOnboarding />
</div> </div>