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:
Rishichandra Wawhal 2022-09-30 00:05:14 +05:30 committed by hasura-bot
parent a21c66fcf1
commit 75aed6f933
17 changed files with 497 additions and 219 deletions

View File

@ -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} />;

View File

@ -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();
};

View File

@ -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>

View File

@ -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>

View File

@ -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();

View File

@ -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

View File

@ -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}
/>
);
}

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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';
/**

View File

@ -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

View File

@ -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 };
}

View File

@ -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;
};

View 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],
});
}

View File

@ -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 });
}