mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
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:
parent
37601d7303
commit
150fbaea7c
@ -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/';
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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
|
||||
);
|
||||
}
|
20
console/src/features/GrowthExperiments/constants.ts
Normal file
20
console/src/features/GrowthExperiments/constants.ts
Normal 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',
|
||||
};
|
@ -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 };
|
||||
}
|
7
console/src/features/GrowthExperiments/index.ts
Normal file
7
console/src/features/GrowthExperiments/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { cloudDataServiceApiClient } from './cloudDataServiceApiClient';
|
||||
export {
|
||||
makeGrowthExperimentsClient,
|
||||
GrowthExperimentsClient,
|
||||
} from './growthExperimentsConfigClient';
|
||||
export { growthExperimentsIds } from './constants';
|
||||
export { ExperimentConfig } from './types';
|
24
console/src/features/GrowthExperiments/types.ts
Normal file
24
console/src/features/GrowthExperiments/types.ts
Normal 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>;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
19
console/src/features/GrowthExperiments/utils.ts
Normal file
19
console/src/features/GrowthExperiments/utils.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
@ -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} />
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
29
console/src/features/OnboardingWizard/hooks/constants.ts
Normal file
29
console/src/features/OnboardingWizard/hooks/constants.ts
Normal 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',
|
||||
};
|
1
console/src/features/OnboardingWizard/hooks/index.ts
Normal file
1
console/src/features/OnboardingWizard/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useWizardState } from './useWizardState';
|
@ -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 };
|
||||
}
|
27
console/src/features/OnboardingWizard/hooks/utils.ts
Normal file
27
console/src/features/OnboardingWizard/hooks/utils.ts
Normal 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;
|
||||
}
|
@ -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');
|
||||
|
Loading…
Reference in New Issue
Block a user