mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
console: show template summary in a modal after sample template has been installed in Neon onboarding
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6117 Co-authored-by: Abhijeet Khangarot <26903230+abhi40308@users.noreply.github.com> GitOrigin-RevId: 68ca37fadbe9ee5e67a066b5c32b23992665773f
This commit is contained in:
parent
a21c66fcf1
commit
75aed6f933
@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useAppDispatch } from '@/store';
|
||||
import globals from '@/Globals';
|
||||
import { isCloudConsole } from '@/utils/cloudConsole';
|
||||
import { TopHeaderBar, ConnectDBScreen } from './components';
|
||||
import { hasLuxFeatureAccess, isCloudConsole } from '@/utils/cloudConsole';
|
||||
import { TopHeaderBar, ConnectDBScreen, TemplateSummary } from './components';
|
||||
|
||||
import { useWizardState } from './hooks';
|
||||
import { GrowthExperimentsClient } from '../GrowthExperiments';
|
||||
import { useFamiliaritySurveyData, HasuraFamiliaritySurvey } from '../Surveys';
|
||||
@ -17,10 +19,12 @@ type Props = {
|
||||
function Root(props: Props) {
|
||||
const { growthExperimentsClient } = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const hasNeonAccess = hasLuxFeatureAccess(globals, 'NeonDatabaseIntegration');
|
||||
|
||||
// dialog cannot be reopened once closed
|
||||
const { isWizardOpen, skipOnboarding, completeOnboarding } = useWizardState(
|
||||
growthExperimentsClient
|
||||
);
|
||||
const { state, setState } = useWizardState(growthExperimentsClient);
|
||||
|
||||
const {
|
||||
showFamiliaritySurvey,
|
||||
@ -29,34 +33,58 @@ function Root(props: Props) {
|
||||
onOptionClick: familiaritySurveyOnOptionClick,
|
||||
} = useFamiliaritySurveyData();
|
||||
|
||||
if (!isWizardOpen) return null;
|
||||
const templateBaseUrl =
|
||||
'https://raw.githubusercontent.com/hasura/template-gallery/main/postgres/getting-started';
|
||||
|
||||
// Radix dialog is being used for creating a layover component over the whole app.
|
||||
// It does not make sense to extend common dialog component to fit this one-off use case.
|
||||
//
|
||||
// modal={false} is set to prevent focus issues when multiple modals are visible,
|
||||
// for example survey modal and onboarding modal
|
||||
return (
|
||||
<Dialog.Root modal={false} open>
|
||||
<Dialog.Content className="fixed top-0 w-full h-full focus:outline-none bg-gray-50 overflow-hidden z-[100]">
|
||||
<TopHeaderBar />
|
||||
<div className="max-w-5xl p-md ml-auto mr-auto mt-xl">
|
||||
{showFamiliaritySurvey ? (
|
||||
<HasuraFamiliaritySurvey
|
||||
data={familiaritySurveyData}
|
||||
onSkip={familiaritySurveyOnSkip}
|
||||
onOptionClick={familiaritySurveyOnOptionClick}
|
||||
/>
|
||||
) : (
|
||||
<ConnectDBScreen
|
||||
skipOnboarding={skipOnboarding}
|
||||
completeOnboarding={completeOnboarding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
const transitionToTemplateSummary = () => {
|
||||
setState('template-summary');
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
setState('hidden');
|
||||
};
|
||||
|
||||
switch (state) {
|
||||
case 'landing-page': {
|
||||
return (
|
||||
// Radix dialog is being used for creating a layover component over the whole app.
|
||||
// It does not make sense to extend common dialog component to fit this one-off use case.
|
||||
//
|
||||
// modal={false} is set to prevent focus issues when multiple modals are visible,
|
||||
// for example survey modal and onboarding modal
|
||||
<Dialog.Root modal={false} open>
|
||||
<Dialog.Content className="fixed top-0 w-full h-full focus:outline-none bg-gray-50 overflow-hidden z-[100]">
|
||||
<TopHeaderBar />
|
||||
<div className="max-w-5xl p-md ml-auto mr-auto mt-xl">
|
||||
{showFamiliaritySurvey ? (
|
||||
<HasuraFamiliaritySurvey
|
||||
data={familiaritySurveyData}
|
||||
onSkip={familiaritySurveyOnSkip}
|
||||
onOptionClick={familiaritySurveyOnOptionClick}
|
||||
/>
|
||||
) : (
|
||||
<ConnectDBScreen
|
||||
dismissOnboarding={dismiss}
|
||||
proceed={transitionToTemplateSummary}
|
||||
hasNeonAccess={hasNeonAccess}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
case 'template-summary': {
|
||||
return (
|
||||
<TemplateSummary templateUrl={templateBaseUrl} dismiss={dismiss} />
|
||||
);
|
||||
}
|
||||
case 'hidden':
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function RootWithCloudCheck(props: Props) {
|
||||
@ -64,7 +92,7 @@ export function RootWithCloudCheck(props: Props) {
|
||||
* Don't render Root component if current context is not cloud-console
|
||||
* and current user is not project owner
|
||||
*/
|
||||
if (!isCloudConsole(globals)) {
|
||||
if (!isCloudConsole(globals) && globals.userRole !== 'owner') {
|
||||
return null;
|
||||
}
|
||||
return <Root {...props} />;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ComponentMeta, Story } from '@storybook/react';
|
||||
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
|
||||
import { within } from '@storybook/testing-library';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { ConnectDBScreen } from './ConnectDBScreen';
|
||||
@ -7,13 +8,19 @@ import { ConnectDBScreen } from './ConnectDBScreen';
|
||||
export default {
|
||||
title: 'features/Onboarding Wizard/Connect DB screen',
|
||||
component: ConnectDBScreen,
|
||||
decorators: [ReactQueryDecorator()],
|
||||
} as ComponentMeta<typeof ConnectDBScreen>;
|
||||
|
||||
export const Base: Story = () => (
|
||||
<ConnectDBScreen skipOnboarding={() => {}} completeOnboarding={() => {}} />
|
||||
export const WithoutNeon: Story = () => (
|
||||
<ConnectDBScreen
|
||||
proceed={() => {}}
|
||||
dismissOnboarding={() => {}}
|
||||
dispatch={() => {}}
|
||||
hasNeonAccess={!true}
|
||||
/>
|
||||
);
|
||||
|
||||
Base.play = async ({ canvasElement }) => {
|
||||
WithoutNeon.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Expect element renders successfully
|
||||
@ -26,3 +33,35 @@ Base.play = async ({ canvasElement }) => {
|
||||
)
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const WithNeon: Story = () => (
|
||||
<ConnectDBScreen
|
||||
proceed={() => {}}
|
||||
dismissOnboarding={() => {}}
|
||||
hasNeonAccess
|
||||
dispatch={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
WithNeon.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Expect element renders successfully
|
||||
expect(
|
||||
await canvas.findByText('Welcome to your new Hasura project!')
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
"Let's get started by connecting your first database"
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(await canvas.findByText('Connect Neon Database')).toBeVisible();
|
||||
|
||||
expect(await canvas.findByText('Need a new database?')).toBeVisible();
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'Hasura has partnered with Neon to help you seamlessly create your database with their serverless Postgres platform.'
|
||||
)
|
||||
).toBeVisible();
|
||||
};
|
||||
|
@ -1,34 +1,47 @@
|
||||
import React from 'react';
|
||||
import { useAppDispatch } from '@/store';
|
||||
import { Dispatch } from '@/types';
|
||||
import { Button } from '@/new-components/Button';
|
||||
import Globals from '@/Globals';
|
||||
import { hasLuxFeatureAccess } from '@/utils/cloudConsole';
|
||||
import { OnboardingAnimation, OnboardingAnimationNavbar } from './components';
|
||||
import { NeonOnboarding } from './NeonOnboarding';
|
||||
import _push from '../../../../components/Services/Data/push';
|
||||
import {
|
||||
persistSkippedOnboarding,
|
||||
persistOnboardingCompletion,
|
||||
} from '../../utils';
|
||||
|
||||
type ConnectDBScreenProps = {
|
||||
skipOnboarding: () => void;
|
||||
completeOnboarding: () => void;
|
||||
proceed: VoidFunction;
|
||||
dismissOnboarding: VoidFunction;
|
||||
hasNeonAccess: boolean;
|
||||
dispatch: Dispatch;
|
||||
};
|
||||
|
||||
export function ConnectDBScreen(props: ConnectDBScreenProps) {
|
||||
const { skipOnboarding, completeOnboarding } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
const { proceed, dismissOnboarding, hasNeonAccess, dispatch } = props;
|
||||
|
||||
const onError = (error?: string) => {
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
const pushToConnectDBPage = () => {
|
||||
// TODO: Due to routing being slow on prod, but wizard closing instantaneously, this causes
|
||||
// a flicker of `<Api />` tab before routing to `/data`.
|
||||
dispatch(_push(`/data/manage/connect`));
|
||||
completeOnboarding();
|
||||
dismissOnboarding();
|
||||
};
|
||||
|
||||
const onClickConnectDB = () => {
|
||||
persistOnboardingCompletion();
|
||||
pushToConnectDBPage();
|
||||
};
|
||||
|
||||
const onUnexpectedNeonOnboardingError = (error?: string) => {
|
||||
// TODO raise sentry alert
|
||||
console.error(error);
|
||||
pushToConnectDBPage();
|
||||
};
|
||||
|
||||
const skipLandingPage = React.useCallback(() => {
|
||||
persistSkippedOnboarding();
|
||||
dismissOnboarding();
|
||||
}, [dismissOnboarding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-xl font-semibold text-cloud-darkest">
|
||||
@ -42,19 +55,19 @@ export function ConnectDBScreen(props: ConnectDBScreenProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{hasLuxFeatureAccess(Globals, 'NeonDatabaseIntegration') ? (
|
||||
{hasNeonAccess ? (
|
||||
<NeonOnboarding
|
||||
dispatch={dispatch}
|
||||
onSkip={skipOnboarding}
|
||||
onCompletion={completeOnboarding}
|
||||
onError={onError}
|
||||
onSkip={dismissOnboarding}
|
||||
onCompletion={proceed}
|
||||
onError={onUnexpectedNeonOnboardingError}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="cursor-pointer text-secondary text-sm hover:text-secondary-dark">
|
||||
<div
|
||||
data-trackid="onboarding-skip-button"
|
||||
onClick={skipOnboarding}
|
||||
onClick={skipLandingPage}
|
||||
>
|
||||
Skip setup, continue to dashboard
|
||||
</div>
|
||||
@ -62,7 +75,7 @@ export function ConnectDBScreen(props: ConnectDBScreenProps) {
|
||||
<Button
|
||||
data-trackid="onboarding-connect-db-button"
|
||||
mode="primary"
|
||||
onClick={onClick}
|
||||
onClick={onClickConnectDB}
|
||||
>
|
||||
Connect Your Database
|
||||
</Button>
|
||||
|
@ -40,7 +40,7 @@ export function NeonOnboarding(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div className="w-full mb-sm">
|
||||
<NeonBanner {...neonBannerProps} />
|
||||
</div>
|
||||
@ -50,7 +50,7 @@ export function NeonOnboarding(props: {
|
||||
data-trackid="onboarding-skip-button"
|
||||
onClick={onSkip}
|
||||
>
|
||||
Skip setup, continue to console
|
||||
Skip getting started tutorial
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -56,7 +56,7 @@ Base.args = {
|
||||
Base.play = async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const runButton = canvas.getByText('Run Query');
|
||||
const skipButton = canvas.getByText('Cancel, continue to my dashboard');
|
||||
const skipButton = canvas.getByText('Skip getting started tutorial');
|
||||
|
||||
// Expect element renders successfully
|
||||
expect(canvas.getByText('👋 Welcome to Hasura!')).toBeVisible();
|
||||
|
@ -55,7 +55,7 @@ export function QueryDialog(props: Props) {
|
||||
className="pb-2 pt-4 cursor-pointer text-secondary text-sm hover:text-secondary-darkr"
|
||||
onClick={onSkipHandler}
|
||||
>
|
||||
Cancel, continue to my dashboard
|
||||
Skip getting started tutorial
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
|
@ -0,0 +1,109 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { tracingTools } from '@/features/TracingTools';
|
||||
import { NEON_ONBOARDING_QUERY_KEY, staleTime } from '../../constants';
|
||||
import { QueryDialog } from './QueryDialog';
|
||||
import {
|
||||
fetchTemplateDataQueryFn,
|
||||
getQueryFromSampleQueries,
|
||||
} from '../../utils';
|
||||
|
||||
const defaultQuery = `
|
||||
# Lookup artist info, albums, tracks based on relations
|
||||
# Filter for only 'ArtistId' with the ID of '22'
|
||||
|
||||
query lookupArtist {
|
||||
sample_Artist(where: {ArtistId: {_eq: 22}}) {
|
||||
ArtistId
|
||||
Name
|
||||
Albums {
|
||||
AlbumId
|
||||
Title
|
||||
Tracks {
|
||||
TrackId
|
||||
Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// TODO use an actual function
|
||||
const runSampleQueryInGraphiQL = (query: string) => {
|
||||
return Promise.resolve(query);
|
||||
};
|
||||
|
||||
// TODO use an actual function
|
||||
const emitSkipRunQueryEvent = () => {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
type Props = {
|
||||
templateUrl: string;
|
||||
dismiss: VoidFunction;
|
||||
};
|
||||
|
||||
export function TemplateSummary(props: Props) {
|
||||
const { templateUrl, dismiss } = props;
|
||||
const schemaImagePath = `${templateUrl}/diagram.png`;
|
||||
const sampleQueriesPath = `${templateUrl}/sample.graphql`;
|
||||
|
||||
const [sampleQuery, setSampleQuery] = React.useState(defaultQuery);
|
||||
|
||||
useQuery({
|
||||
queryKey: [NEON_ONBOARDING_QUERY_KEY, sampleQueriesPath],
|
||||
queryFn: () => fetchTemplateDataQueryFn(sampleQueriesPath, {}),
|
||||
staleTime,
|
||||
onSuccess: (allQueries: string) => {
|
||||
try {
|
||||
const gqlQuery = getQueryFromSampleQueries(allQueries, 'lookupArtist');
|
||||
setSampleQuery(gqlQuery || defaultQuery);
|
||||
} catch (e: any) {
|
||||
// this is unexpected; so get alerted
|
||||
tracingTools.sentry.captureException(
|
||||
new Error('failed to get a sample query in template summary'),
|
||||
{
|
||||
debug: {
|
||||
error: 'message' in e ? e.message : e,
|
||||
trace: 'OnboardingWizard/TemplateSummary',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (e: any) => {
|
||||
// this is unexpected; so get alerted
|
||||
tracingTools.sentry.captureException(
|
||||
new Error('failed to fetch sample queries in template summary'),
|
||||
{
|
||||
debug: {
|
||||
error: 'message' in e ? e.message : e,
|
||||
trace: 'OnboardingWizard/TemplateSummary',
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onRunHandler = () => {
|
||||
runSampleQueryInGraphiQL(sampleQuery).then(() => {
|
||||
dismiss();
|
||||
});
|
||||
};
|
||||
|
||||
const onSkipHandler = () => {
|
||||
emitSkipRunQueryEvent();
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryDialog
|
||||
title="Welcome to Hasura"
|
||||
description="Get started learning Hasura with an example."
|
||||
query={sampleQuery}
|
||||
schemaImage={schemaImagePath}
|
||||
onRunHandler={onRunHandler}
|
||||
onSkipHandler={onSkipHandler}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export { TopHeaderBar } from './TopHeaderBar/TopHeaderBar';
|
||||
export { ConnectDBScreen } from './ConnectDBScreen/ConnectDBScreen';
|
||||
export { TemplateSummary } from './QueryDialog/TemplateSummary';
|
||||
|
@ -43,5 +43,45 @@ export const skippedOnboardingVariables = {
|
||||
kind: 'skipped_onboarding',
|
||||
};
|
||||
|
||||
export const neonOAuthStartVariables = {
|
||||
...mutationVariables,
|
||||
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 hasuraSourceCreationStart = {
|
||||
...mutationVariables,
|
||||
kind: 'hasura_source_creation_start',
|
||||
};
|
||||
|
||||
export const hasuraSourceCreationComplete = {
|
||||
...mutationVariables,
|
||||
kind: 'hasura_source_creation_complete',
|
||||
};
|
||||
|
||||
export const templateSummaryRunQueryClick = {
|
||||
...mutationVariables,
|
||||
kind: 'run_query_click',
|
||||
};
|
||||
|
||||
export const templateSummaryRunQuerySkip = {
|
||||
...mutationVariables,
|
||||
kind: 'run_query_skip',
|
||||
};
|
||||
|
||||
// A stale time of 5 minutes for use in useQuery hook
|
||||
export const staleTime = 5 * 60 * 1000;
|
@ -6,8 +6,8 @@ import { HasuraMetadataV3 } from '@/metadata/types';
|
||||
import { MetadataResponse } from '@/features/MetadataAPI';
|
||||
import { useAppSelector } from '@/store';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { staleTime } from './constants';
|
||||
import { fetchTemplateDataQueryFn, transformOldMetadata } from './utils';
|
||||
import { staleTime } from '../constants';
|
||||
import { fetchTemplateDataQueryFn, transformOldMetadata } from '../utils';
|
||||
|
||||
type MutationFnArgs = {
|
||||
newMetadata: HasuraMetadataV3;
|
||||
|
@ -5,8 +5,8 @@ import { RunSQLResponse } from '@/features/DataSource';
|
||||
import { Api } from '@/hooks/apiUtils';
|
||||
import { useAppSelector } from '@/store';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { fetchTemplateDataQueryFn } from './utils';
|
||||
import { staleTime } from './constants';
|
||||
import { fetchTemplateDataQueryFn } from '../utils';
|
||||
import { staleTime } from '../constants';
|
||||
|
||||
type MutationFnArgs = {
|
||||
sql: string;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useInstallMigration } from './useInstallMigration';
|
||||
import { NEON_METADATA_PATH, NEON_MIGRATIONS_PATH } from './constants';
|
||||
import { NEON_METADATA_PATH, NEON_MIGRATIONS_PATH } from '../constants';
|
||||
import { useInstallMetadata } from './useInstallMetadata';
|
||||
|
||||
/**
|
||||
|
@ -5,8 +5,8 @@ import {
|
||||
NEON_MIGRATIONS_PATH,
|
||||
NEON_IMAGE_PATH,
|
||||
NEON_QUERY_PATH,
|
||||
} from './constants';
|
||||
import { fetchTemplateDataQueryFn } from './utils';
|
||||
} from '../constants';
|
||||
import { fetchTemplateDataQueryFn } from '../utils';
|
||||
|
||||
/**
|
||||
* Prefetch migrations and metadata file contents for NEON onboarding. Use it to get data early
|
||||
|
@ -1,81 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GrowthExperimentsClient } from '@/features/GrowthExperiments';
|
||||
import { cloudDataServiceApiClient } from '@/hooks/cloudDataServiceApiClient';
|
||||
import {
|
||||
experimentId,
|
||||
graphQlMutation,
|
||||
onboardingCompleteVariables,
|
||||
skippedOnboardingVariables,
|
||||
} from './constants';
|
||||
import { isExperimentActive, shouldShowOnboarding } from './utils';
|
||||
import { experimentId } from '../constants';
|
||||
import { isExperimentActive, shouldShowOnboarding } from '../utils';
|
||||
|
||||
type ResponseDataOnMutation = {
|
||||
data: {
|
||||
trackExperimentsCohortActivity: {
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
type WizardState = 'landing-page' | 'template-summary' | 'hidden';
|
||||
|
||||
export function useWizardState(
|
||||
growthExperimentsClient: GrowthExperimentsClient
|
||||
growthExperimentsClient: GrowthExperimentsClient,
|
||||
hasNeonAccess = false
|
||||
) {
|
||||
// lux works with cookie auth and doesn't require admin-secret header
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
const { getAllExperimentConfig, setAllExperimentConfig } =
|
||||
growthExperimentsClient;
|
||||
const { getAllExperimentConfig } = growthExperimentsClient;
|
||||
const experimentData = getAllExperimentConfig();
|
||||
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(
|
||||
shouldShowOnboarding(experimentData, experimentId) &&
|
||||
const [state, setState] = useState<WizardState>(
|
||||
shouldShowOnboarding(experimentData, experimentId, hasNeonAccess) &&
|
||||
isExperimentActive(experimentData, experimentId)
|
||||
? 'landing-page'
|
||||
: 'hidden'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsWizardOpen(
|
||||
shouldShowOnboarding(experimentData, experimentId) &&
|
||||
isExperimentActive(experimentData, experimentId)
|
||||
);
|
||||
}, [experimentData]);
|
||||
// this effect is only used to update the wizard state for initial async fetching of experiments config
|
||||
// it only takes care of "showing" the wizard, but not hiding it, hence the check for `hidden`
|
||||
// hiding wizard is taken care of by setting the wizard state directly to "hidden"
|
||||
const wizardState =
|
||||
shouldShowOnboarding(experimentData, experimentId, hasNeonAccess) &&
|
||||
isExperimentActive(experimentData, experimentId)
|
||||
? 'landing-page'
|
||||
: 'hidden';
|
||||
if (wizardState !== 'hidden') {
|
||||
setState(wizardState);
|
||||
}
|
||||
}, [growthExperimentsClient.getAllExperimentConfig()]);
|
||||
|
||||
const skipOnboarding = () => {
|
||||
setIsWizardOpen(false);
|
||||
|
||||
// mutate server data
|
||||
cloudDataServiceApiClient<ResponseDataOnMutation, ResponseDataOnMutation>(
|
||||
graphQlMutation,
|
||||
skippedOnboardingVariables,
|
||||
headers
|
||||
)
|
||||
.then(() => {
|
||||
// refetch the fresh data and update the `growthExperimentConfigClient`
|
||||
setAllExperimentConfig();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const completeOnboarding = () => {
|
||||
setIsWizardOpen(false);
|
||||
|
||||
// mutate server data
|
||||
cloudDataServiceApiClient<ResponseDataOnMutation, ResponseDataOnMutation>(
|
||||
graphQlMutation,
|
||||
onboardingCompleteVariables,
|
||||
headers
|
||||
)
|
||||
.then(() => {
|
||||
// refetch the fresh data and update the `growthExperimentConfigClient`
|
||||
setAllExperimentConfig();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
return { isWizardOpen, skipOnboarding, completeOnboarding };
|
||||
return { state, setState };
|
||||
}
|
||||
|
@ -1,83 +0,0 @@
|
||||
import { ExperimentConfig } from '@/features/GrowthExperiments';
|
||||
import { Api } from '@/hooks/apiUtils';
|
||||
import { HasuraMetadataV3 } from '@/metadata/types';
|
||||
|
||||
export function isExperimentActive(
|
||||
experimentsData: ExperimentConfig[],
|
||||
experimentId: string
|
||||
) {
|
||||
const experimentData = experimentsData?.find(
|
||||
experimentConfig => experimentConfig.experiment === experimentId
|
||||
);
|
||||
return experimentData && experimentData?.status === 'enabled';
|
||||
}
|
||||
|
||||
export function shouldShowOnboarding(
|
||||
experimentsData: ExperimentConfig[],
|
||||
experimentId: string
|
||||
) {
|
||||
const experimentData = experimentsData?.find(
|
||||
experimentConfig => experimentConfig.experiment === experimentId
|
||||
);
|
||||
if (
|
||||
experimentData?.userActivity?.onboarding_complete ||
|
||||
experimentData?.userActivity?.skipped_onboarding
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
175
console/src/features/OnboardingWizard/utils.ts
Normal file
175
console/src/features/OnboardingWizard/utils.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { parse, print } from 'graphql';
|
||||
import { ExperimentConfig } from '@/features/GrowthExperiments';
|
||||
import { cloudDataServiceApiClient } from '@/hooks/cloudDataServiceApiClient';
|
||||
import { Api } from '@/hooks/apiUtils';
|
||||
import { HasuraMetadataV3 } from '@/metadata/types';
|
||||
import {
|
||||
skippedOnboardingVariables,
|
||||
onboardingCompleteVariables,
|
||||
graphQlMutation,
|
||||
} from './constants';
|
||||
|
||||
export function isExperimentActive(
|
||||
experimentsData: ExperimentConfig[],
|
||||
experimentId: string
|
||||
) {
|
||||
const experimentData = experimentsData?.find(
|
||||
experimentConfig => experimentConfig.experiment === experimentId
|
||||
);
|
||||
return experimentData && experimentData?.status === 'enabled';
|
||||
}
|
||||
|
||||
export function shouldShowOnboarding(
|
||||
experimentsData: ExperimentConfig[],
|
||||
experimentId: string,
|
||||
hasNeonAccess: boolean
|
||||
) {
|
||||
const experimentData = experimentsData?.find(
|
||||
experimentConfig => experimentConfig.experiment === experimentId
|
||||
);
|
||||
|
||||
const userActivity = experimentData?.userActivity;
|
||||
|
||||
// onboarding skipped/completion is different with the new Neon flow
|
||||
if (hasNeonAccess) {
|
||||
if (
|
||||
userActivity?.skipped_onboarding ||
|
||||
userActivity?.onboarding_complete ||
|
||||
userActivity?.hasura_source_creation_complete
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userActivity?.onboarding_complete || userActivity?.skipped_onboarding) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
type ResponseDataOnMutation = {
|
||||
data: {
|
||||
trackExperimentsCohortActivity: {
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const cloudHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
// persist skipped onboarding in the database
|
||||
export const persistSkippedOnboarding = () => {
|
||||
// mutate server data
|
||||
cloudDataServiceApiClient<ResponseDataOnMutation, ResponseDataOnMutation>(
|
||||
graphQlMutation,
|
||||
skippedOnboardingVariables,
|
||||
cloudHeaders
|
||||
).catch(error => {
|
||||
// TODO throw Sentry alert
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// persist onboarding completion in the database
|
||||
export const persistOnboardingCompletion = () => {
|
||||
// mutate server data
|
||||
cloudDataServiceApiClient<ResponseDataOnMutation, ResponseDataOnMutation>(
|
||||
graphQlMutation,
|
||||
onboardingCompleteVariables,
|
||||
cloudHeaders
|
||||
).catch(error => {
|
||||
// TODO throw Sentry alert
|
||||
console.error(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],
|
||||
});
|
||||
}
|
@ -14,5 +14,5 @@ type Contexts = Record<ContextKind, Context>;
|
||||
that we want the engineering team to be alerted about
|
||||
*/
|
||||
export function captureException(err: Error, contexts?: Contexts) {
|
||||
Sentry.captureException(err, contexts);
|
||||
Sentry.captureException(err, { contexts });
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user