mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 09:22:43 +03:00
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:
parent
d8a4d254f6
commit
84ae7a0652
@ -73,7 +73,7 @@ export {
|
||||
} from './lib/features/Analytics';
|
||||
export { CloudOnboarding } from './lib/features/CloudOnboarding';
|
||||
export { prefetchSurveysData } from './lib/features/Surveys';
|
||||
export { prefetchOnboardingData } from './lib/features/CloudOnboarding/OnboardingWizard';
|
||||
export { prefetchOnboardingData } from './lib/features/CloudOnboarding/NeonOnboardingWizard';
|
||||
export {
|
||||
prefetchEELicenseInfo,
|
||||
NavbarButton as EntepriseNavbarButton,
|
||||
|
@ -1,35 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useAppDispatch } from '../../../storeHooks';
|
||||
import { AllowedSurveyThemes, Survey } from '../../Surveys';
|
||||
import {
|
||||
ConnectDBScreen,
|
||||
TemplateSummary,
|
||||
DialogContainer,
|
||||
UseCaseScreen,
|
||||
} from './components';
|
||||
import { ConnectDBScreen, TemplateSummary } from './components';
|
||||
import { DialogContainer } from '../components';
|
||||
|
||||
import { useWizardState } from './hooks';
|
||||
import {
|
||||
NEON_TEMPLATE_BASE_PATH,
|
||||
dialogHeader,
|
||||
familiaritySurveySubHeader,
|
||||
NEON_TEMPLATE_BASE_PATH,
|
||||
stepperNavSteps,
|
||||
} from './constants';
|
||||
} from '../constants';
|
||||
import { OnboardingResponseData } from '../types';
|
||||
|
||||
/**
|
||||
* 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 [stepperIndex, setStepperIndex] = React.useState<number>(1);
|
||||
|
||||
const {
|
||||
state,
|
||||
setState,
|
||||
familiaritySurveyData,
|
||||
familiaritySurveyOnOptionClick,
|
||||
} = useWizardState();
|
||||
const { state, setState } = useWizardState(props.onboardingData);
|
||||
|
||||
const transitionToTemplateSummary = () => {
|
||||
setState('template-summary');
|
||||
@ -40,20 +32,6 @@ export function Root() {
|
||||
};
|
||||
|
||||
switch (state) {
|
||||
case 'familiarity-survey': {
|
||||
return (
|
||||
<DialogContainer
|
||||
header={dialogHeader}
|
||||
subHeader={familiaritySurveySubHeader}
|
||||
>
|
||||
<Survey
|
||||
theme={AllowedSurveyThemes.familiaritySurveyTheme}
|
||||
onSubmit={familiaritySurveyOnOptionClick}
|
||||
data={familiaritySurveyData}
|
||||
/>
|
||||
</DialogContainer>
|
||||
);
|
||||
}
|
||||
case 'landing-page': {
|
||||
return (
|
||||
<DialogContainer
|
||||
@ -87,12 +65,6 @@ export function Root() {
|
||||
</DialogContainer>
|
||||
);
|
||||
}
|
||||
case 'use-case-onboarding':
|
||||
return (
|
||||
<DialogContainer header="">
|
||||
<UseCaseScreen dismiss={dismiss} dispatch={dispatch} />
|
||||
</DialogContainer>
|
||||
);
|
||||
case 'hidden':
|
||||
default: {
|
||||
return null;
|
@ -15,8 +15,8 @@ import {
|
||||
import {
|
||||
NEON_TEMPLATE_BASE_PATH,
|
||||
skippedNeonOnboardingVariables,
|
||||
} from '../../../constants';
|
||||
import { emitOnboardingEvent } from '../../../utils';
|
||||
} from '../../../../constants';
|
||||
import { emitOnboardingEvent } from '../../../../utils';
|
||||
|
||||
export function NeonOnboarding(props: {
|
||||
dispatch: Dispatch;
|
@ -6,9 +6,9 @@ import { HasuraLogoFull } from '../../../../../new-components/HasuraLogo';
|
||||
import { Analytics } from '../../../../Analytics';
|
||||
import { NeonIcon } from './NeonIcon';
|
||||
import _push from '../../../../../components/Services/Data/push';
|
||||
import { emitOnboardingEvent } from '../../utils';
|
||||
import { emitOnboardingEvent } from '../../../utils';
|
||||
import { Dispatch } from '../../../../../types';
|
||||
import { skippedNeonOnboardingToConnectOtherDB } from '../../constants';
|
||||
import { skippedNeonOnboardingToConnectOtherDB } from '../../../constants';
|
||||
|
||||
const iconMap = {
|
||||
refresh: <MdRefresh />,
|
@ -6,10 +6,14 @@ import {
|
||||
staleTime,
|
||||
templateSummaryRunQueryClickVariables,
|
||||
templateSummaryRunQuerySkipVariables,
|
||||
} from '../../constants';
|
||||
import { QueryScreen } from './QueryScreen';
|
||||
import { fetchTemplateDataQueryFn, emitOnboardingEvent } from '../../utils';
|
||||
import { runQueryInGraphiQL, fillSampleQueryInGraphiQL } from '../../../utils';
|
||||
} from '../../../constants';
|
||||
import { QueryScreen } from '../../../components/QueryScreen/QueryScreen';
|
||||
import { fetchTemplateDataQueryFn } from '../../utils';
|
||||
import {
|
||||
runQueryInGraphiQL,
|
||||
fillSampleQueryInGraphiQL,
|
||||
emitOnboardingEvent,
|
||||
} from '../../../utils';
|
||||
|
||||
type Props = {
|
||||
templateUrl: string;
|
@ -0,0 +1,2 @@
|
||||
export { TemplateSummary } from './TemplateSummary/TemplateSummary';
|
||||
export { ConnectDBScreen } from './ConnectDBScreen/ConnectDBScreen';
|
@ -2,16 +2,12 @@ import { useEffect } from 'react';
|
||||
import { NeonIntegrationStatus } from '../../../../components/Services/Data/DataSources/CreateDataSource/Neon/useNeonIntegration';
|
||||
import {
|
||||
neonOAuthStartVariables,
|
||||
// neonOAuthCompleteVariables,
|
||||
neonDbCreationStartVariables,
|
||||
// neonDbCreationCompleteVariables,
|
||||
hasuraSourceCreationStartVariables,
|
||||
// hasuraSourceCreationCompleteVariables,
|
||||
installTemplateStartVariables,
|
||||
// installTemplateCompleteVariables,
|
||||
getNeonOnboardingErrorVariables,
|
||||
} from '../constants';
|
||||
import { emitOnboardingEvent } from '../utils';
|
||||
} from '../../constants';
|
||||
import { emitOnboardingEvent } from '../../utils';
|
||||
|
||||
export function useEmitOnboardingEvents(
|
||||
neonIntegrationStatus: NeonIntegrationStatus,
|
@ -6,7 +6,7 @@ import { HasuraMetadataV3 } from '../../../../metadata/types';
|
||||
import { MetadataResponse } from '../../../MetadataAPI';
|
||||
import { useAppSelector } from '../../../../storeHooks';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { staleTime } from '../constants';
|
||||
import { staleTime } from '../../constants';
|
||||
import { fetchTemplateDataQueryFn, transformOldMetadata } from '../utils';
|
||||
|
||||
type MutationFnArgs = {
|
@ -6,7 +6,7 @@ import { Api } from '../../../../hooks/apiUtils';
|
||||
import { useAppSelector } from '../../../../storeHooks';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { fetchTemplateDataQueryFn } from '../utils';
|
||||
import { staleTime } from '../constants';
|
||||
import { staleTime } from '../../constants';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
type MutationFnArgs = {
|
@ -20,7 +20,7 @@ import {
|
||||
serverDownErrorMessage,
|
||||
} from '../mocks/constants';
|
||||
import { useInstallTemplate } from './useInstallTemplate';
|
||||
import { NEON_TEMPLATE_BASE_PATH } from '../constants';
|
||||
import { NEON_TEMPLATE_BASE_PATH } from '../../constants';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
const server = setupServer();
|
@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useInstallMigration } from './useInstallMigration';
|
||||
import { getMetadataUrl, getMigrationUrl } from '../constants';
|
||||
import { getMetadataUrl, getMigrationUrl } from '../../constants';
|
||||
import { useInstallMetadata } from './useInstallMetadata';
|
||||
|
||||
/**
|
@ -1,8 +1,8 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import { APIError } from '../../../../hooks/error';
|
||||
import { fetchAllOnboardingDataQueryFn } from '../utils';
|
||||
import { onboardingQueryKey } from '../constants';
|
||||
import { OnboardingResponseData } from '../types';
|
||||
import { onboardingQueryKey } from '../../constants';
|
||||
import { OnboardingResponseData } from '../../types';
|
||||
|
||||
export function useOnboardingData() {
|
||||
return useQuery<OnboardingResponseData, APIError>(
|
@ -5,7 +5,7 @@ import {
|
||||
getMigrationUrl,
|
||||
getSampleQueriesUrl,
|
||||
getSchemaImageUrl,
|
||||
} from '../constants';
|
||||
} from '../../constants';
|
||||
import { fetchTemplateDataQueryFn } from '../utils';
|
||||
|
||||
/**
|
@ -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,
|
||||
};
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { Root } from './Root';
|
||||
|
||||
export { prefetchOnboardingData } from './utils';
|
||||
export { useOnboardingData } from './hooks';
|
||||
export const NeonOnboarding = Root;
|
@ -8,8 +8,8 @@ import {
|
||||
getSampleQueriesUrl,
|
||||
getSchemaImageUrl,
|
||||
NEON_TEMPLATE_BASE_PATH,
|
||||
} from '../constants';
|
||||
import { OnboardingResponseData } from '../types';
|
||||
} from '../../constants';
|
||||
import { OnboardingResponseData } from '../../types';
|
||||
|
||||
const userMock = {
|
||||
id: '59300b64-fb3a-4f17-8a0f-6f698569eade',
|
@ -9,7 +9,7 @@ import {
|
||||
MOCK_MIGRATION_FILE_CONTENTS,
|
||||
serverDownErrorMessage,
|
||||
} from './constants';
|
||||
import { OnboardingResponseData } from '../types';
|
||||
import { OnboardingResponseData } from '../../types';
|
||||
import { FetchAllSurveysDataQuery } from '../../../ControlPlane';
|
||||
|
||||
type ResponseBodyOnSuccess = {
|
@ -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
|
||||
);
|
||||
};
|
@ -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'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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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[];
|
||||
};
|
||||
};
|
@ -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
|
||||
);
|
||||
};
|
@ -3,7 +3,8 @@ import globals from '../../../Globals';
|
||||
import { FaGithub } from 'react-icons/fa';
|
||||
import { GraphiqlPopup, WorkflowProgress } from './components';
|
||||
import { stepperNavSteps } from './constants';
|
||||
import { DialogContainer } from '../OnboardingWizard';
|
||||
import { oneClickDeploymentOnboardingShown } from '../constants';
|
||||
import { DialogContainer } from '../components';
|
||||
import { useTriggerDeployment } from './hooks';
|
||||
import { GitRepoDetails, FallbackApp } from './types';
|
||||
import {
|
||||
@ -11,6 +12,7 @@ import {
|
||||
getGitRepoFullLinkFromDetails,
|
||||
getSampleQueriesUrl,
|
||||
} from './util';
|
||||
import { emitOnboardingEvent } from '../utils';
|
||||
|
||||
/**
|
||||
* 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 = () => {
|
||||
setGraphiQlPopupStatus('success');
|
||||
setState('graphiql-popup');
|
||||
emitOnboardingEvent(oneClickDeploymentOnboardingShown);
|
||||
};
|
||||
const transitionToQueryPopupWithErrorState = () => {
|
||||
setGraphiQlPopupStatus('error');
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
ProgressStateStatus,
|
||||
GitRepoDetails,
|
||||
} from './types';
|
||||
import { OnboardingKind, OnboardingResponseData } from '../types';
|
||||
|
||||
// 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
|
||||
@ -295,3 +296,19 @@ export const getSampleQueriesUrl = (gitRepoDetails: GitRepoDetails) => {
|
||||
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 || [],
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
import { Root } from './Root';
|
||||
|
||||
export const UseCaseOnboarding = Root;
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import { Button } from '../../../../new-components/Button';
|
||||
import { FaPlayCircle } from 'react-icons/fa';
|
||||
import { Analytics } from '../../../../Analytics';
|
||||
import { Analytics } from '../../../Analytics';
|
||||
|
||||
export interface Props {
|
||||
schemaImage: string;
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { HasuraLogoFull } from '../../../../../new-components/HasuraLogo';
|
||||
import { HasuraLogoFull } from '../../../../new-components/HasuraLogo';
|
||||
|
||||
export function TopHeaderBar() {
|
||||
return (
|
@ -1,6 +1,3 @@
|
||||
export { ConnectDBScreen } from './ConnectDBScreen/ConnectDBScreen';
|
||||
export { TemplateSummary } from './QueryScreen/TemplateSummary';
|
||||
export { StepperNavbar } from './StepperNavbar/StepperNavbar';
|
||||
export type { StepperNavbarStep } from './StepperNavbar/StepperNavbar';
|
||||
export { DialogContainer } from './DialogContainer/DialogContainer';
|
||||
export { UseCaseScreen } from './UseCaseScreen/UseCaseScreen';
|
@ -1,6 +1,6 @@
|
||||
import globals from '../../../Globals';
|
||||
import { BASE_URL_TEMPLATE } from '../../../components/Services/Data/Schema/TemplateGallery/templateGalleryConfig';
|
||||
import { UseCases } from './components/UseCaseScreen/UseCaseScreen';
|
||||
import globals from '../../Globals';
|
||||
import { BASE_URL_TEMPLATE } from '../../components/Services/Data/Schema/TemplateGallery/templateGalleryConfig';
|
||||
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
|
||||
// stores the directory structure.
|
||||
@ -16,9 +16,10 @@ export const onboardingQueryKey = 'onboardingData';
|
||||
*/
|
||||
export const fetchAllOnboardingDataQuery = `
|
||||
query fetchAllOnboardingData ($projectId: uuid!) {
|
||||
user_onboarding {
|
||||
user_onboarding(where: {target: {_eq: "cloud_console"}}) {
|
||||
activity
|
||||
target
|
||||
is_onboarded
|
||||
}
|
||||
users {
|
||||
id
|
||||
@ -63,8 +64,6 @@ export const getSchemaImageUrl = (baseUrl: string) => {
|
||||
return `${baseUrl}/diagram.png`;
|
||||
};
|
||||
|
||||
export const NEON_ONBOARDING_QUERY_KEY = 'neonOnboarding';
|
||||
|
||||
export const trackOnboardingActivityMutation = `
|
||||
mutation trackOnboardingActivity($projectId: uuid!, $kind: String!, $error_code: String, $subkind: String,) {
|
||||
trackOnboardingActivity(payload: {kind: $kind, project_id: $projectId, error_code: $error_code, subkind: $subkind}) {
|
||||
@ -79,11 +78,6 @@ const mutationVariables = {
|
||||
...(projectId && { projectId }),
|
||||
};
|
||||
|
||||
export const onboardingCompleteVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'onboarding_complete',
|
||||
};
|
||||
|
||||
export const skippedOnboardingThroughURLParamVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'skipped_neon_onboarding_through_url_param',
|
||||
@ -103,41 +97,21 @@ export const neonOAuthStartVariables = {
|
||||
kind: 'neon_login_start',
|
||||
};
|
||||
|
||||
export const neonOAuthCompleteVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'neon_login_complete',
|
||||
};
|
||||
|
||||
export const neonDbCreationStartVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'neon_db_creation_start',
|
||||
};
|
||||
|
||||
export const neonDbCreationCompleteVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'neon_db_creation_complete',
|
||||
};
|
||||
|
||||
export const hasuraSourceCreationStartVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'hasura_source_creation_start',
|
||||
};
|
||||
|
||||
export const hasuraSourceCreationCompleteVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'hasura_source_creation_complete',
|
||||
};
|
||||
|
||||
export const installTemplateStartVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'install_template_start',
|
||||
};
|
||||
|
||||
export const installTemplateCompleteVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'install_template_complete',
|
||||
};
|
||||
|
||||
export const templateSummaryRunQueryClickVariables = {
|
||||
...mutationVariables,
|
||||
kind: 'run_query_click',
|
@ -1,60 +1,53 @@
|
||||
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 {
|
||||
useOnboardingData,
|
||||
emitOnboardingEvent,
|
||||
oneClickDeploymentOnboardingShown,
|
||||
} from '../OnboardingWizard';
|
||||
import { OnboardingKind } from '../types';
|
||||
|
||||
export const useOnboardingKind = () => {
|
||||
const { data, error, isLoading } = useOnboardingData();
|
||||
export const useOnboardingKind = (
|
||||
onboardingData: OnboardingResponseData | undefined,
|
||||
error: APIError | null,
|
||||
isLoading: boolean
|
||||
) => {
|
||||
const [kind, setKind] = React.useState<OnboardingKind>({ kind: 'none' });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error || isLoading) {
|
||||
setKind({ kind: 'none' });
|
||||
if (error || isLoading || !onboardingData?.data) {
|
||||
// TODO: emit onboarding error
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.data) {
|
||||
setKind({ kind: 'none' });
|
||||
// TODO: emit onboarding error
|
||||
return;
|
||||
const is_onboarded =
|
||||
onboardingData?.data?.user_onboarding[0]?.is_onboarded || false;
|
||||
const isSkipOnboardingFlagSet =
|
||||
getLSItem(LS_KEYS.skipOnboarding) === 'true';
|
||||
|
||||
if (isSkipOnboardingFlagSet) {
|
||||
emitOnboardingEvent(skippedOnboardingThroughURLParamVariables);
|
||||
removeLSItem(LS_KEYS.skipOnboarding);
|
||||
}
|
||||
|
||||
// if there's a pending one-click-deployment for this project,
|
||||
// show the deployment status
|
||||
// if the deployment is complete, show nothing
|
||||
const deployment = data.data.one_click_deployment[0];
|
||||
if (deployment?.state === OneClickDeploymentState.Completed) {
|
||||
setKind({ kind: 'none' });
|
||||
return;
|
||||
}
|
||||
if (deployment) {
|
||||
if (
|
||||
onboardingData?.data?.one_click_deployment[0] &&
|
||||
onboardingData?.data?.one_click_deployment[0]?.state !==
|
||||
OneClickDeploymentState.Completed
|
||||
) {
|
||||
// TODO: Skip this block also if state == 'ERROR' && retryCount >= something
|
||||
setKind(oneClickDeploymentOnboardingKind(onboardingData));
|
||||
return;
|
||||
}
|
||||
|
||||
setKind({
|
||||
kind: 'one-click-deployment',
|
||||
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);
|
||||
if (is_onboarded || isSkipOnboardingFlagSet) {
|
||||
setKind({ kind: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// pass control to onboarding wizard
|
||||
setKind({ kind: 'wizard' });
|
||||
}, [data, error, isLoading]);
|
||||
setKind({ kind: 'use-case-onboarding' });
|
||||
}, [onboardingData, error, isLoading]);
|
||||
|
||||
// if there's error fetching onboarding data
|
||||
// do not show anything and proceed to the console
|
||||
|
@ -1,17 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import globals from '../../Globals';
|
||||
import { isCloudConsole } from '../../utils/cloudConsole';
|
||||
import { isCloudConsole } from '../../utils';
|
||||
import { OneClickDeployment } from './OneClickDeployment';
|
||||
import { OnboardingWizard } from './OnboardingWizard';
|
||||
import { NeonOnboarding, useOnboardingData } from './NeonOnboardingWizard';
|
||||
import { UseCaseOnboarding } from './UseCaseOnboarding';
|
||||
import { useOnboardingKind } from './hooks/useOnboardingKind';
|
||||
import { useFallbackApps } from './hooks/useFallbackApps';
|
||||
|
||||
export const CloudOnboardingWithoutCloudCheck = () => {
|
||||
const cloudOnboarding = useOnboardingKind();
|
||||
const { data, error, isLoading } = useOnboardingData();
|
||||
const cloudOnboarding = useOnboardingKind(data, error, isLoading);
|
||||
const fallbackApps = useFallbackApps(cloudOnboarding);
|
||||
switch (cloudOnboarding.kind) {
|
||||
case 'wizard':
|
||||
return <OnboardingWizard />;
|
||||
case 'neon-onboarding':
|
||||
return <NeonOnboarding onboardingData={data} />;
|
||||
case 'one-click-deployment':
|
||||
return (
|
||||
<OneClickDeployment
|
||||
@ -20,6 +22,8 @@ export const CloudOnboardingWithoutCloudCheck = () => {
|
||||
fallbackApps={fallbackApps}
|
||||
/>
|
||||
);
|
||||
case 'use-case-onboarding':
|
||||
return <UseCaseOnboarding dismiss={cloudOnboarding.dismissOnboarding} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -1,12 +1,47 @@
|
||||
import { OnboardingResponseData } from './OnboardingWizard';
|
||||
import { One_Click_Deployment_States_Enum } from '../ControlPlane';
|
||||
import { GitRepoDetails, FallbackApp } from './OneClickDeployment';
|
||||
|
||||
export type OneClickDeploymentSampleApp =
|
||||
OnboardingResponseData['data']['one_click_deployment_sample_apps'];
|
||||
export type UserOnboarding = {
|
||||
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 =
|
||||
| {
|
||||
kind: 'wizard';
|
||||
kind: 'neon-onboarding';
|
||||
}
|
||||
| {
|
||||
kind: 'one-click-deployment';
|
||||
@ -16,6 +51,9 @@ export type OnboardingKind =
|
||||
};
|
||||
fallbackApps: OnboardingResponseData['data']['one_click_deployment_sample_apps'];
|
||||
}
|
||||
| {
|
||||
kind: 'use-case-onboarding';
|
||||
}
|
||||
| {
|
||||
kind: 'none';
|
||||
};
|
||||
|
@ -1,9 +1,12 @@
|
||||
import {
|
||||
clickRunQueryButton,
|
||||
forceGraphiQLIntrospection,
|
||||
forceChangeGraphiqlQuery,
|
||||
forceGraphiQLIntrospection,
|
||||
} from '../../components/Services/ApiExplorer/OneGraphExplorer/utils';
|
||||
import { Dispatch } from '../../types';
|
||||
import { cloudDataServiceApiClient } from '../../hooks/cloudDataServiceApiClient';
|
||||
import { trackOnboardingActivityMutation } from './constants';
|
||||
import { programmaticallyTraceError } from '../Analytics/core/programmaticallyTraceError';
|
||||
|
||||
export const runQueryInGraphiQL = () => {
|
||||
clickRunQueryButton();
|
||||
@ -21,3 +24,26 @@ export const fillSampleQueryInGraphiQL = (
|
||||
forceChangeGraphiqlQuery(query, dispatch);
|
||||
}, 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);
|
||||
});
|
||||
};
|
||||
|
@ -125,7 +125,6 @@ export const LS_KEYS = {
|
||||
notificationsLastSeen: 'notifications:lastSeen',
|
||||
authState: 'AUTH_STATE',
|
||||
skipOnboarding: 'SKIP_CLOUD_ONBOARDING',
|
||||
showUseCaseOverviewPopup: 'onboarding:showUseCaseOverviewPopup',
|
||||
};
|
||||
|
||||
export const clearGraphiqlLS = () => {
|
||||
|
@ -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;
|
@ -93,7 +93,6 @@ import logoutIcon from './images/log-out.svg';
|
||||
import EELogo from './images/hasura-ee-mono-light.svg';
|
||||
import { isHasuraCollaboratorUser } from '../Login/utils';
|
||||
import { ConsoleDevTools } from '@hasura/console-legacy-ce';
|
||||
import ExploreUseCasePopup from './ExploreUseCasePopup';
|
||||
|
||||
const { Plan, Project_Entitlement_Types_Enum } = ControlPlane;
|
||||
class Main extends React.Component {
|
||||
@ -727,7 +726,6 @@ class Main extends React.Component {
|
||||
metadata={metadata?.metadataObject}
|
||||
/>
|
||||
) : null}
|
||||
<ExploreUseCasePopup />
|
||||
</div>
|
||||
<CloudOnboarding />
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user