pro-console: integrate onboarding wizard

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5559
Co-authored-by: Rishichandra Wawhal <27274869+wawhal@users.noreply.github.com>
GitOrigin-RevId: 35da0aa3c22f030f78d90bdd0ef6a7526c566016
This commit is contained in:
Abhijeet Khangarot 2022-08-24 12:54:07 +05:30 committed by hasura-bot
parent 37601d7303
commit 150fbaea7c
17 changed files with 362 additions and 18 deletions

View File

@ -11,6 +11,8 @@ export {
export { fetchConsoleNotifications } from '../src/components/Main/Actions';
export { default as NotificationSection } from '../src/components/Main/NotificationSection';
export { default as Onboarding } from '../src/components/Common/Onboarding';
export { OnboardingWizard } from '../src/features/OnboardingWizard';
export { makeGrowthExperimentsClient } from '../src/features/GrowthExperiments'
export { default as PageNotFound } from '../src/components/Error/PageNotFound';
export * from '../src/new-components/Button/';
export * from '../src/new-components/Tooltip/';

View File

@ -123,6 +123,7 @@ export type EnvVars = {
consoleType?: ConsoleType;
eeMode?: string;
consoleId?: string;
userRole?: string;
} & (
| OSSServerEnv
| CloudServerEnv
@ -173,7 +174,7 @@ const globals = {
hasuraCloudProjectId: window.__env?.projectID,
cloudDataApiUrl: `${window.location?.protocol}//data.${window.__env?.cloudRootDomain}`,
luxDataHost: window.__env?.luxDataHost,
userRole: undefined, // userRole is not applicable for the OSS console
userRole: window.__env?.userRole || undefined,
consoleType: window.__env?.consoleType || '',
eeMode: window.__env?.eeMode === 'true',
};

View File

@ -0,0 +1,31 @@
import Endpoints from '@/Endpoints';
import { Api } from '@/hooks/apiUtils';
/**
* Calls hasura cloud data service with provided query and variables. Uses the common `fetch` api client.
* Returns a promise which either resolves the data or throws an error. Optionally pass a transform function
* to transform the response data. This can be transformed into a hook, and directly use headers from
* `useAppSelector` hook if required. This can also be passed to react query as the `queryFn` if required.
*/
export function cloudDataServiceApiClient<
ResponseData,
TransformedData = ResponseData
>(
query: string,
variables: Record<string, string>,
headers: Record<string, string>,
transformFn?: (data: ResponseData) => TransformedData
): Promise<TransformedData> {
return Api.post<ResponseData, TransformedData>(
{
url: Endpoints.luxDataGraphql,
headers,
body: {
query,
variables: variables || {},
},
credentials: 'include',
},
transformFn
);
}

View File

@ -0,0 +1,20 @@
/**
* GraphQl query to fetch all growth experiments data related to the current user.
*/
export const query = `
query {
experiments_config {
experiment
metadata
status
}
experiments_cohort {
experiment
activity
}
}
`;
export const growthExperimentsIds = {
onboardingWizardV1: 'console_onboarding_wizard_v1',
};

View File

@ -0,0 +1,66 @@
import globals from '@/Globals';
import { cloudDataServiceApiClient } from './cloudDataServiceApiClient';
import { ExperimentConfig, ExperimentsResponseData } from './types';
import { query } from './constants';
import { transformFn } from './utils';
export type GrowthExperimentsClient = {
setAllExperimentConfig: () => Promise<void>;
getAllExperimentConfig: () => ExperimentConfig[];
};
/**
* Fetch all growth experiments data for the given user. Not making it a hook as this could be required in legacy class components.
* Hence `setAllExperimentConfig` needs to be passed the correct headers from calling component. If caller is hook, use `useAppSelector`
* to get the headers. Class based caller needs to subscribe to the global store to get headers.
*/
export function makeGrowthExperimentsClient(): GrowthExperimentsClient {
/**
* Experiments config data, transformed in required format.
*/
let allExperimentsConfig: ExperimentConfig[] = [];
/**
* Makes variable `allExperimentsConfig` consistent with the server state. Call this in top level of your app to set
* `allExperimentsConfig`. Also, when a server state mutation is done, use this function to keep data in sync.
*/
const setAllExperimentConfig = () => {
/*
* Gracefully exit if current context is not cloud-console
* and current user is not project owner
*/
if (globals.consoleType !== 'cloud' || globals.userRole !== 'owner') {
return Promise.resolve();
}
// cloud uses cookie-based auth, so does not require an admin secret
const headers = {
'content-type': 'application/json',
};
return cloudDataServiceApiClient<
ExperimentsResponseData,
ExperimentConfig[]
>(query, {}, headers, transformFn)
.then(res => {
allExperimentsConfig = res;
return Promise.resolve();
})
.catch(error => {
// Possible enhancement: Add a retry mechanism
console.error(error);
return Promise.reject(error);
});
};
/**
* Returns latest `allExperimentsConfig` data. Note that it doesn't subscribe the calling component to changes in `allExperimentsConfig`.
* Update the component UI manually if you're also doing a server state mutation using `setAllExperimentConfig`.
*/
const getAllExperimentConfig = () => {
return allExperimentsConfig;
};
return { getAllExperimentConfig, setAllExperimentConfig };
}

View File

@ -0,0 +1,7 @@
export { cloudDataServiceApiClient } from './cloudDataServiceApiClient';
export {
makeGrowthExperimentsClient,
GrowthExperimentsClient,
} from './growthExperimentsConfigClient';
export { growthExperimentsIds } from './constants';
export { ExperimentConfig } from './types';

View File

@ -0,0 +1,24 @@
export type ExperimentConfig = {
experiment: string;
status: string;
metadata: ExperimentsResponseData['data']['experiments_config'][0]['metadata'];
userActivity: ExperimentsResponseData['data']['experiments_cohort'][0]['activity'];
};
export type ExperimentsResponseData = {
data: {
experiments_config: [
{
experiment: string;
metadata: Record<string, unknown>;
status: string;
}
];
experiments_cohort: [
{
experiment: string;
activity: Record<string, unknown>;
}
];
};
};

View File

@ -0,0 +1,19 @@
import { ExperimentConfig, ExperimentsResponseData } from './types';
/**
* Transforms server returned data to the required format.
*/
export function transformFn(data: ExperimentsResponseData): ExperimentConfig[] {
return data.data.experiments_config.map(experimentConfig => {
const experimentCohort = data.data.experiments_cohort.find(
cohort => cohort.experiment === experimentConfig.experiment
);
const userActivity = experimentCohort?.activity ?? {};
return {
experiment: experimentConfig.experiment,
status: experimentConfig.status,
metadata: experimentConfig.metadata,
userActivity,
};
});
}

View File

@ -7,4 +7,18 @@ export default {
component: Root,
} as ComponentMeta<typeof Root>;
export const Base: Story = () => <Root />;
const mockGrowthClient = {
getAllExperimentConfig: () => [
{
experiment: 'console_onboarding_wizard_v1',
status: 'enabled',
metadata: {},
userActivity: {},
},
],
setAllExperimentConfig: () => Promise.resolve(),
};
export const Base: Story = () => (
<Root growthExperimentsClient={mockGrowthClient} />
);

View File

@ -1,19 +1,35 @@
import React, { useReducer } from 'react';
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { TopHeaderBar, ConnectDBScreen } from './components';
import { useWizardState } from './hooks';
import { GrowthExperimentsClient } from '../GrowthExperiments';
type Props = {
growthExperimentsClient: GrowthExperimentsClient;
};
/**
* Parent container for the onboarding wizard. Takes care of assembling and rendering all steps.
*/
export function Root(props: Props) {
const { growthExperimentsClient } = props;
export function Root() {
// dialog cannot be reopened once closed
const [isWizardOpen, closeWizard] = useReducer(() => false, true);
const { isWizardOpen, skipOnboarding, completeOnboarding, setIsWizardOpen } =
useWizardState(growthExperimentsClient);
// this dialog is being used to create a layover component over the whole app using react portal, and other handy functionalities radix dialog provides, which otherwise we'll have to implement manually
// note that we have a common radix dialog, but that component has very specific styling, doesn't include react portal, and it did not make sense to extend that to fit this particular one-off use case
// Radix dialog is being used for creating a layover component over the whole app using react portal,
// and for other handy functionalities radix-dialog provides, which otherwise we'll have to implement manually.
// Not extending the common dialog, as it does not make sense to update common component to fit this one-off use case.
return (
<Dialog.Root open={isWizardOpen} onOpenChange={closeWizard}>
<Dialog.Root open={isWizardOpen} onOpenChange={setIsWizardOpen}>
<Dialog.Portal>
<Dialog.Content className="fixed top-0 w-full h-full focus:outline-none bg-gray-50 overflow-hidden z-[101]">
<TopHeaderBar />
<ConnectDBScreen closeWizard={closeWizard} />
<ConnectDBScreen
skipOnboarding={skipOnboarding}
completeOnboarding={completeOnboarding}
/>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@ -9,7 +9,9 @@ export default {
component: ConnectDBScreen,
} as ComponentMeta<typeof ConnectDBScreen>;
export const Base: Story = () => <ConnectDBScreen closeWizard={() => {}} />;
export const Base: Story = () => (
<ConnectDBScreen skipOnboarding={() => {}} completeOnboarding={() => {}} />
);
Base.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);

View File

@ -5,19 +5,19 @@ import _push from '../../../../components/Services/Data/push';
import { OnboardingAnimation, OnboardingAnimationNavbar } from './components';
type ConnectDBScreenProps = {
closeWizard: () => void;
skipOnboarding: () => void;
completeOnboarding: () => void;
};
export function ConnectDBScreen(props: ConnectDBScreenProps) {
const { closeWizard } = props;
const { skipOnboarding, completeOnboarding } = props;
const dispatch = useAppDispatch();
const onClick = () => {
// we should change the route to `/data` first, and then close the wizard.
// this is because routing is slow on prod, but wizard closes instantaneously.
// if we close wizard first, it will show a flicker of `<Api />` tab before routing to `/data`.
// 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`));
closeWizard();
completeOnboarding();
};
return (
@ -34,7 +34,7 @@ export function ConnectDBScreen(props: ConnectDBScreenProps) {
<div className="flex items-center justify-between">
<div className="cursor-pointer text-secondary text-sm hover:text-secondary-dark">
<div data-trackid="onboarding-skip-button" onClick={closeWizard}>
<div data-trackid="onboarding-skip-button" onClick={skipOnboarding}>
Skip setup, continue to dashboard
</div>
</div>

View File

@ -0,0 +1,29 @@
import { growthExperimentsIds } from '@/features/GrowthExperiments';
import globals from '@/Globals';
export const experimentId = growthExperimentsIds.onboardingWizardV1;
export const graphQlMutation = `
mutation ($projectId: uuid!, $experimentId: String!, $kind: String!) {
trackExperimentsCohortActivity(experiment: $experimentId, payload: {kind: $kind, project_id: $projectId}) {
status
}
}
`;
const projectId = globals.hasuraCloudProjectId;
const mutationVariables = {
...(projectId && { projectId }),
experimentId,
};
export const onboardingCompleteVariables = {
...mutationVariables,
kind: 'onboarding_complete',
};
export const skippedOnboardingVariables = {
...mutationVariables,
kind: 'skipped_onboarding',
};

View File

@ -0,0 +1 @@
export { useWizardState } from './useWizardState';

View File

@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import {
cloudDataServiceApiClient,
GrowthExperimentsClient,
} from '@/features/GrowthExperiments';
import {
experimentId,
graphQlMutation,
onboardingCompleteVariables,
skippedOnboardingVariables,
} from './constants';
import { isExperimentActive, shouldShowOnboarding } from './utils';
type ResponseDataOnMutation = {
data: {
trackExperimentsCohortActivity: {
status: string;
};
};
};
export function useWizardState(
growthExperimentsClient: GrowthExperimentsClient
) {
// lux works with cookie auth and doesn't require admin-secret header
const headers = {
'content-type': 'application/json',
};
const { getAllExperimentConfig, setAllExperimentConfig } =
growthExperimentsClient;
const experimentData = getAllExperimentConfig();
const [isWizardOpen, setIsWizardOpen] = useState(
shouldShowOnboarding(experimentData, experimentId) &&
isExperimentActive(experimentData, experimentId)
);
useEffect(() => {
setIsWizardOpen(
shouldShowOnboarding(experimentData, experimentId) &&
isExperimentActive(experimentData, experimentId)
);
}, [experimentData]);
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, setIsWizardOpen };
}

View File

@ -0,0 +1,27 @@
import { ExperimentConfig } from '@/features/GrowthExperiments';
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;
}

View File

@ -9,6 +9,7 @@ interface IApiArgs {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: Record<any, any>;
credentials?: 'include' | 'omit' | 'same-origin';
}
async function fetchApi<T = unknown, V = T>(
@ -16,11 +17,12 @@ async function fetchApi<T = unknown, V = T>(
dataTransform?: (data: T) => V
): Promise<V> {
try {
const { headers, url, method, body } = args;
const { headers, url, method, body, credentials } = args;
const response = await fetch(url, {
headers,
method,
body: JSON.stringify(body),
credentials,
});
const contentType = response.headers.get('Content-Type');
const isResponseJson = `${contentType}`.includes('application/json');