mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-12 14:05:16 +03:00
console (feature): add support for native query relationships and editing
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9523 Co-authored-by: Matthew Goodwin <49927862+m4ttheweric@users.noreply.github.com> GitOrigin-RevId: 968a45a82cddc3d7a7d90c8edb61454768644f98
This commit is contained in:
parent
65316ee5b9
commit
8aec505707
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@ -42,6 +42,7 @@ Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.serve.env
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
@ -43,10 +43,8 @@ import {
|
||||
LogicalModelPermissionsRoute,
|
||||
} from '../../../features/Data/LogicalModels';
|
||||
import { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction';
|
||||
import {
|
||||
UpdateNativeQueryRoute,
|
||||
AddNativeQueryRoute,
|
||||
} from '../../../features/Data/LogicalModels/AddNativeQuery';
|
||||
import { AddNativeQueryRoute } from '../../../features/Data/LogicalModels/AddNativeQuery';
|
||||
import { NativeQueryLandingPage } from '../../../features/Data/LogicalModels/AddNativeQuery/NativeQueryLandingPage';
|
||||
|
||||
const makeDataRouter = (
|
||||
connect,
|
||||
@ -89,10 +87,7 @@ const makeDataRouter = (
|
||||
<Route path="native-queries">
|
||||
<IndexRoute component={NativeQueries} />
|
||||
<Route path="create" component={AddNativeQueryRoute} />
|
||||
<Route
|
||||
path="native-query/:source/:name"
|
||||
component={UpdateNativeQueryRoute}
|
||||
/>
|
||||
|
||||
<Route path="logical-models">
|
||||
<IndexRoute component={NativeQueries} />
|
||||
<Redirect from=":source" to="/data/native-queries/logical-models" />
|
||||
@ -104,11 +99,16 @@ const makeDataRouter = (
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="stored-procedures" component={NativeQueries} />
|
||||
<Route
|
||||
path="stored-procedures/track"
|
||||
component={TrackStoredProcedureRoute}
|
||||
/>
|
||||
<Route path=":source/:name" component={NativeQueryLandingPage}>
|
||||
<IndexRedirect to="details" />
|
||||
<Route path=":tabName" component={NativeQueryLandingPage} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="manage/connect" component={ConnectDatabase} />
|
||||
<Route path="manage/create" component={ConnectedCreateDataSourcePage} />
|
||||
|
@ -7,7 +7,6 @@ import { NativeQuery } from '../../../hasura-metadata-types';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
import { AddNativeQuery } from './AddNativeQuery';
|
||||
import { nativeQueryHandlers } from './mocks';
|
||||
import { normalizeArguments } from './utils';
|
||||
|
||||
type Story = StoryObj<typeof AddNativeQuery>;
|
||||
|
||||
@ -79,9 +78,7 @@ const fillAndSubmitForm: Story['play'] = async ({ canvasElement }) => {
|
||||
};
|
||||
|
||||
const defaultArgs: Story['args'] = {
|
||||
defaultFormValues: {
|
||||
code: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
|
||||
},
|
||||
defaultSql: `SELECT * FROM (VALUES ('hello', 'world'), ('welcome', 'friend')) as t("one", "two")`,
|
||||
};
|
||||
|
||||
export const Basic: Story = {
|
||||
@ -222,6 +219,8 @@ const existingNativeQuery: Required<NativeQuery> = {
|
||||
code: "select\n AlbumId,\n a.ArtistId,\n Title [AlbumTitle],\n a.Name [Artist],\n (\n select\n count(*)\n from\n Track t\n where\n t.AlbumId = b.AlbumId\n ) [TrackCount]\n from\n album b\n join artist a on a.ArtistId = b.ArtistId\n \n \n -- search option for later: \n WHERE\n b.Title like '%' + {{query}} + '%'",
|
||||
returns: 'hello_world',
|
||||
root_field_name: 'AlbumDetail',
|
||||
object_relationships: [],
|
||||
array_relationships: [],
|
||||
};
|
||||
|
||||
export const Update: Story = {
|
||||
@ -233,11 +232,9 @@ export const Update: Story = {
|
||||
}),
|
||||
},
|
||||
args: {
|
||||
mode: 'update',
|
||||
defaultFormValues: {
|
||||
...existingNativeQuery,
|
||||
source: 'postgres',
|
||||
arguments: normalizeArguments(existingNativeQuery.arguments ?? {}),
|
||||
editDetails: {
|
||||
nativeQuery: existingNativeQuery,
|
||||
dataSourceName: 'postgres',
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
|
@ -1,50 +1,59 @@
|
||||
import React from 'react';
|
||||
import { FaPlusCircle, FaSave } from 'react-icons/fa';
|
||||
import { FaSave } from 'react-icons/fa';
|
||||
import { useHasuraAlert } from '../../../../new-components/Alert';
|
||||
import { Button } from '../../../../new-components/Button';
|
||||
import {
|
||||
GraphQLSanitizedInputField,
|
||||
InputField,
|
||||
Select,
|
||||
useConsoleForm,
|
||||
} from '../../../../new-components/Form';
|
||||
// import { FormDebugWindow } from '../../../../new-components/Form/dev-components/FormDebugWindow';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { useConsoleForm } from '../../../../new-components/Form';
|
||||
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||
import { hasuraToast } from '../../../../new-components/Toasts';
|
||||
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
|
||||
import { Feature } from '../../../DataSource';
|
||||
import {
|
||||
useEnvironmentState,
|
||||
usePushRoute,
|
||||
} from '../../../ConnectDBRedesign/hooks';
|
||||
import { Feature, nativeDrivers } from '../../../DataSource';
|
||||
import { useMetadata } from '../../../hasura-metadata-api';
|
||||
import { NativeQuery } from '../../../hasura-metadata-types';
|
||||
import { useSupportedDataTypes } from '../../hooks/useSupportedDataTypes';
|
||||
import { useTrackNativeQuery } from '../../hooks/useTrackNativeQuery';
|
||||
import { LogicalModelWidget } from '../LogicalModelWidget/LogicalModelWidget';
|
||||
import { ArgumentsField } from './components/ArgumentsField';
|
||||
import { SqlEditorField } from './components/SqlEditorField';
|
||||
import { NativeQueryFormFields } from './components/NativeQueryDetailsForm';
|
||||
import { schema } from './schema';
|
||||
import { NativeQueryForm } from './types';
|
||||
import { transformFormOutputToMetadata } from './utils';
|
||||
import { useSupportedDrivesForNativeQueries } from '../hook';
|
||||
import { LimitedFeatureWrapper } from '../../../ConnectDBRedesign/components/LimitedFeatureWrapper/LimitedFeatureWrapper';
|
||||
import { normalizeArguments, transformFormOutputToMetadata } from './utils';
|
||||
import { useIsStorybook } from '../../../../utils/StoryUtils';
|
||||
|
||||
type AddNativeQueryProps = {
|
||||
defaultFormValues?: Partial<NativeQueryForm>;
|
||||
mode?: 'create' | 'update';
|
||||
editDetails?: {
|
||||
nativeQuery: NativeQuery;
|
||||
dataSourceName: string;
|
||||
};
|
||||
// this is purely for storybook
|
||||
defaultSql?: string;
|
||||
};
|
||||
|
||||
export const AddNativeQuery = ({
|
||||
defaultFormValues,
|
||||
mode = 'create',
|
||||
editDetails,
|
||||
defaultSql,
|
||||
}: AddNativeQueryProps) => {
|
||||
const { mode, defaultFormValues } = useDefaultFormValues({
|
||||
editDetails,
|
||||
defaultSql,
|
||||
});
|
||||
|
||||
const {
|
||||
Form,
|
||||
methods: { watch, setValue },
|
||||
methods: { watch, setValue, formState },
|
||||
} = useConsoleForm({
|
||||
schema,
|
||||
options: { defaultValues: defaultFormValues },
|
||||
options: {
|
||||
defaultValues: defaultFormValues,
|
||||
},
|
||||
});
|
||||
|
||||
const formHasChanges = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const push = usePushRoute();
|
||||
|
||||
const allowedDrivers = useSupportedDrivesForNativeQueries();
|
||||
const { consoleType } = useEnvironmentState();
|
||||
const allowedDrivers = consoleType === 'oss' ? ['postgres'] : nativeDrivers;
|
||||
|
||||
const {
|
||||
data: sources,
|
||||
@ -54,8 +63,6 @@ export const AddNativeQuery = ({
|
||||
return s.metadata.sources.filter(s => allowedDrivers.includes(s.kind));
|
||||
});
|
||||
|
||||
const selectedSource = watch('source');
|
||||
|
||||
React.useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
if (name === 'source' && type === 'change') {
|
||||
@ -67,24 +74,26 @@ export const AddNativeQuery = ({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch]);
|
||||
|
||||
const logicalModels = sources?.find(
|
||||
s => s.name === selectedSource
|
||||
)?.logical_models;
|
||||
const selectedSource = watch('source');
|
||||
|
||||
const { trackNativeQuery, isLoading: isSaving } = useTrackNativeQuery();
|
||||
|
||||
const [isLogicalModelsDialogOpen, setIsLogicalModelsDialogOpen] =
|
||||
React.useState(false);
|
||||
|
||||
const { hasuraConfirm } = useHasuraAlert();
|
||||
const handleFormSubmit = (values: NativeQueryForm) => {
|
||||
const metadataNativeQuery = transformFormOutputToMetadata(values);
|
||||
|
||||
trackNativeQuery({
|
||||
data: { ...metadataNativeQuery, source: values.source },
|
||||
// if this is an "edit", supply the original root_field_name:
|
||||
editDetails: editDetails
|
||||
? { rootFieldName: editDetails.nativeQuery.root_field_name }
|
||||
: undefined,
|
||||
onSuccess: () => {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
message: `Successfully tracked native query as: ${values.root_field_name}`,
|
||||
message: `Successfully ${
|
||||
mode === 'create' ? 'tracked' : 'updated'
|
||||
} native query as: ${values.root_field_name}`,
|
||||
title: 'Track Native Query',
|
||||
toastOptions: { duration: 3000 },
|
||||
});
|
||||
@ -102,23 +111,6 @@ export const AddNativeQuery = ({
|
||||
});
|
||||
};
|
||||
|
||||
const logicalModelSelectPlaceholder = () => {
|
||||
if (!selectedSource) {
|
||||
return 'Select a database first...';
|
||||
} else if (!!selectedSource && (logicalModels ?? []).length === 0) {
|
||||
return `No logical models found for ${selectedSource}.`;
|
||||
} else {
|
||||
return `Select a logical model...`;
|
||||
}
|
||||
};
|
||||
|
||||
const { data: isThereBigQueryOrMssqlSource } = useMetadata(
|
||||
m =>
|
||||
!!m.metadata.sources.find(
|
||||
s => s.kind === 'mssql' || s.kind === 'bigquery'
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Options for the data source types
|
||||
*/
|
||||
@ -146,117 +138,86 @@ export const AddNativeQuery = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{mode === 'update' && (
|
||||
<IndicatorCard status="info">
|
||||
The current release does not support editing Native Queries. This
|
||||
feature will be available in a future release. You can still edit
|
||||
directly by modifying the Metadata.
|
||||
</IndicatorCard>
|
||||
)}
|
||||
<Form onSubmit={handleFormSubmit}>
|
||||
<fieldset disabled={mode === 'update'}>
|
||||
{/* <FormDebugWindow /> */}
|
||||
<div className="max-w-xl flex flex-col">
|
||||
<GraphQLSanitizedInputField
|
||||
name="root_field_name"
|
||||
label="Native Query Name"
|
||||
placeholder="Name that exposes this model in GraphQL API"
|
||||
hideTips
|
||||
/>
|
||||
<InputField
|
||||
name="comment"
|
||||
label="Comment"
|
||||
placeholder="A description of this logical model"
|
||||
/>
|
||||
<Select
|
||||
name="source"
|
||||
label="Database"
|
||||
// saving prop for future update
|
||||
//noOptionsMessage="No databases found."
|
||||
loading={isSourcesLoading}
|
||||
options={(sources ?? []).map(m => ({
|
||||
label: m.name,
|
||||
value: m.name,
|
||||
}))}
|
||||
placeholder="Select a database..."
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-4xl">
|
||||
{isThereBigQueryOrMssqlSource && (
|
||||
<LimitedFeatureWrapper
|
||||
title="Looking to add Native Queries for SQL Server/Big Query databases?"
|
||||
id="native-queries"
|
||||
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Form onSubmit={handleFormSubmit}>
|
||||
<div className="py-2" />
|
||||
<NativeQueryFormFields
|
||||
isIntrospectionLoading={isIntrospectionLoading}
|
||||
isSourcesLoading={isSourcesLoading}
|
||||
typeOptions={typeOptions}
|
||||
sources={sources}
|
||||
/>
|
||||
<div className="sticky bottom-0 z-10 bg-slate-50 p-3 border-t-slate-200 border-t flex flex-row justify-end gap-2 ">
|
||||
<Button
|
||||
type={'button'}
|
||||
onClick={() => {
|
||||
if (formHasChanges) {
|
||||
hasuraConfirm({
|
||||
title: 'Unsaved changes!',
|
||||
confirmText: 'Discard Changes',
|
||||
cancelText: 'Stay Here',
|
||||
destructive: true,
|
||||
message:
|
||||
'Are you sure you want to leave this page? Your changes will not be saved.',
|
||||
|
||||
{isIntrospectionLoading ? (
|
||||
<div>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : (
|
||||
<ArgumentsField types={typeOptions} />
|
||||
)}
|
||||
<SqlEditorField />
|
||||
<div className="flex w-full">
|
||||
{/* Logical Model Dropdown */}
|
||||
<Select
|
||||
name="returns"
|
||||
selectClassName="max-w-xl"
|
||||
// saving prop for future update
|
||||
// noOptionsMessage={
|
||||
// !selectedSource ? 'Select a database first.' : 'No models found.'
|
||||
// }
|
||||
// force component re-init on source change
|
||||
//key={selectedSource}
|
||||
label="Query Return Type"
|
||||
placeholder={logicalModelSelectPlaceholder()}
|
||||
loading={isSourcesLoading}
|
||||
options={(logicalModels ?? []).map(m => ({
|
||||
label: m.name,
|
||||
value: m.name,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
icon={<FaPlusCircle />}
|
||||
onClick={e => {
|
||||
setIsLogicalModelsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Logical Model
|
||||
</Button>
|
||||
</div>
|
||||
{isLogicalModelsDialogOpen ? (
|
||||
<LogicalModelWidget
|
||||
onCancel={() => {
|
||||
setIsLogicalModelsDialogOpen(false);
|
||||
}}
|
||||
onSubmit={() => {
|
||||
setIsLogicalModelsDialogOpen(false);
|
||||
}}
|
||||
asDialog
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{/*
|
||||
Validate Button will remain hidden until we have more information about how to handle standalone validation
|
||||
Slack thread: https://hasurahq.slack.com/archives/C04LV93JNSH/p1682965503376129
|
||||
*/}
|
||||
{/* <Button icon={<FaPlay />}>Validate</Button> */}
|
||||
<Button
|
||||
type="submit"
|
||||
icon={<FaSave />}
|
||||
mode="primary"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
onClose: ({ confirmed }) => {
|
||||
if (confirmed) push('/data/native-queries');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
push('/data/native-queries');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={<FaSave />}
|
||||
mode="primary"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
// this is kind of a distracting bit of logic for the component, so just hiding it here
|
||||
// it
|
||||
function useDefaultFormValues({
|
||||
editDetails,
|
||||
defaultSql,
|
||||
}: AddNativeQueryProps) {
|
||||
const mode = editDetails ? 'update' : 'create';
|
||||
|
||||
const _defaultFormValues: Partial<NativeQueryForm> = React.useMemo(
|
||||
() =>
|
||||
mode === 'update'
|
||||
? {
|
||||
...editDetails?.nativeQuery,
|
||||
source: editDetails?.dataSourceName,
|
||||
arguments: normalizeArguments(
|
||||
editDetails?.nativeQuery?.arguments ?? {}
|
||||
),
|
||||
}
|
||||
: {},
|
||||
[mode]
|
||||
);
|
||||
|
||||
const { isStorybook } = useIsStorybook();
|
||||
|
||||
const defaultFormValues = _defaultFormValues;
|
||||
|
||||
if (defaultSql) {
|
||||
if (!isStorybook)
|
||||
throw new Error('defaultSql prop is only allowed in Storybook.');
|
||||
|
||||
defaultFormValues.code = defaultSql;
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
defaultFormValues,
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,102 @@
|
||||
import { InjectedRouter, withRouter } from 'react-router';
|
||||
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||
import { Tabs } from '../../../../new-components/Tabs';
|
||||
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
|
||||
import { MetadataSelectors, useMetadata } from '../../../hasura-metadata-api';
|
||||
import { NativeQueryRelationships } from '../NativeQueryRelationships/NativeQueryRelationships';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
import { injectRouteDetails } from '../components/route-wrapper-utils';
|
||||
import { NATIVE_QUERY_ROUTES } from '../constants';
|
||||
import { AddNativeQuery } from './AddNativeQuery';
|
||||
|
||||
type allowedTabs = 'details' | 'relationships';
|
||||
|
||||
export type NativeQueryLandingPage = {
|
||||
params: { source: string; name: string; tabName?: allowedTabs };
|
||||
location: Location;
|
||||
router: InjectedRouter;
|
||||
};
|
||||
|
||||
export const NativeQueryLandingPage = withRouter<{
|
||||
location: Location;
|
||||
router: InjectedRouter;
|
||||
params: { source: string; name: string; tabName?: allowedTabs };
|
||||
}>(props => {
|
||||
const {
|
||||
params: { source: dataSourceName, name: nativeQueryName, tabName },
|
||||
} = props;
|
||||
|
||||
const { data: nativeQuery } = useMetadata(
|
||||
MetadataSelectors.findNativeQuery(dataSourceName, nativeQueryName)
|
||||
);
|
||||
|
||||
const push = usePushRoute();
|
||||
|
||||
const route: keyof typeof NATIVE_QUERY_ROUTES =
|
||||
'/data/native-queries/{{source}}/{{name}}/{{tab}}';
|
||||
|
||||
if (!nativeQuery) {
|
||||
return (
|
||||
<IndicatorCard status="negative">
|
||||
Native Query {nativeQueryName} not found in {dataSourceName}
|
||||
</IndicatorCard>
|
||||
);
|
||||
}
|
||||
|
||||
const relationshipsCount =
|
||||
(nativeQuery.array_relationships?.length ?? 0) +
|
||||
(nativeQuery.object_relationships?.length ?? 0);
|
||||
|
||||
return (
|
||||
<RouteWrapper
|
||||
route={route}
|
||||
itemSourceName={dataSourceName}
|
||||
itemName={nativeQuery?.root_field_name}
|
||||
itemTabName={tabName}
|
||||
subtitle={
|
||||
tabName === 'details'
|
||||
? 'Make changes to your Native Query'
|
||||
: 'Add/Remove relationships to your Native Query'
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
value={tabName ?? 'details'}
|
||||
onValueChange={tab =>
|
||||
push(
|
||||
injectRouteDetails(route, {
|
||||
itemName: nativeQuery.root_field_name,
|
||||
itemSourceName: dataSourceName,
|
||||
itemTabName: tab,
|
||||
})
|
||||
)
|
||||
}
|
||||
items={[
|
||||
{
|
||||
content: (
|
||||
<AddNativeQuery editDetails={{ dataSourceName, nativeQuery }} />
|
||||
),
|
||||
label: 'Details',
|
||||
value: 'details',
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<NativeQueryRelationships
|
||||
dataSourceName={dataSourceName}
|
||||
nativeQueryName={nativeQueryName}
|
||||
/>
|
||||
),
|
||||
label: (
|
||||
<div className="flex items-center" data-testid="untracked-tab">
|
||||
Relationships
|
||||
<span className="bg-gray-300 ml-1 px-1.5 py-0.5 rounded text-xs">
|
||||
{relationshipsCount}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
value: 'relationships',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</RouteWrapper>
|
||||
);
|
||||
});
|
@ -1,58 +0,0 @@
|
||||
import { InjectedRouter, withRouter } from 'react-router';
|
||||
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||
import { MetadataUtils, useMetadata } from '../../../hasura-metadata-api';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
import { AddNativeQuery } from './AddNativeQuery';
|
||||
import { normalizeArguments } from './utils';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
export const UpdateNativeQueryRoute = withRouter<{
|
||||
location: Location;
|
||||
router: InjectedRouter;
|
||||
params: { source: string; name: string };
|
||||
}>(({ location, router, params: { source, name } }) => {
|
||||
if (!source || !name) {
|
||||
return (
|
||||
<IndicatorCard status="negative">
|
||||
Unable to parse data from url.
|
||||
</IndicatorCard>
|
||||
);
|
||||
}
|
||||
|
||||
const { data: sourceQueries, isLoading } = useMetadata(
|
||||
m => MetadataUtils.findMetadataSource(source, m)?.native_queries
|
||||
);
|
||||
|
||||
const nativeQuery = sourceQueries?.find(s => s.root_field_name === name);
|
||||
|
||||
if (!nativeQuery) {
|
||||
return (
|
||||
<IndicatorCard status="negative">
|
||||
Native Query {name} not found in {source}
|
||||
</IndicatorCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteWrapper
|
||||
route={'/data/native-queries/{{source}}/{{name}}'}
|
||||
itemSourceName={source}
|
||||
itemName={name}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div>
|
||||
<Skeleton count={10} />
|
||||
</div>
|
||||
) : (
|
||||
<AddNativeQuery
|
||||
mode={'update'}
|
||||
defaultFormValues={{
|
||||
...nativeQuery,
|
||||
source: source,
|
||||
arguments: normalizeArguments(nativeQuery.arguments ?? {}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RouteWrapper>
|
||||
);
|
||||
});
|
@ -0,0 +1,144 @@
|
||||
import { FaPlusCircle } from 'react-icons/fa';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import {
|
||||
GraphQLSanitizedInputField,
|
||||
InputField,
|
||||
Select,
|
||||
} from '../../../../../new-components/Form';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { Source } from '../../../../hasura-metadata-types';
|
||||
import { LogicalModelWidget } from '../../LogicalModelWidget/LogicalModelWidget';
|
||||
import { ArgumentsField } from '../components/ArgumentsField';
|
||||
import { SqlEditorField } from '../components/SqlEditorField';
|
||||
import { NativeQueryForm } from '../types';
|
||||
import { LimitedFeatureWrapper } from '../../../../ConnectDBRedesign/components/LimitedFeatureWrapper/LimitedFeatureWrapper';
|
||||
import { useMetadata } from '../../../../hasura-metadata-api';
|
||||
|
||||
export const NativeQueryFormFields = ({
|
||||
isSourcesLoading,
|
||||
sources,
|
||||
isIntrospectionLoading,
|
||||
typeOptions,
|
||||
}: {
|
||||
sources?: Source[];
|
||||
isSourcesLoading: boolean;
|
||||
isIntrospectionLoading: boolean;
|
||||
typeOptions: string[];
|
||||
}) => {
|
||||
const { watch, setValue } = useFormContext<NativeQueryForm>();
|
||||
const selectedSource = watch('source');
|
||||
|
||||
const logicalModels = sources?.find(
|
||||
s => s.name === selectedSource
|
||||
)?.logical_models;
|
||||
|
||||
const logicalModelSelectPlaceholder = () => {
|
||||
if (!selectedSource) {
|
||||
return 'Select a database first...';
|
||||
}
|
||||
|
||||
if (logicalModels?.length === 0) {
|
||||
return `No logical models found for ${selectedSource}.`;
|
||||
}
|
||||
|
||||
return 'Select a logical model...';
|
||||
};
|
||||
|
||||
const { data: isThereBigQueryOrMssqlSource } = useMetadata(
|
||||
m =>
|
||||
!!m.metadata.sources.find(
|
||||
s => s.kind === 'mssql' || s.kind === 'bigquery'
|
||||
)
|
||||
);
|
||||
|
||||
const [isLogicalModelsDialogOpen, setIsLogicalModelsDialogOpen] =
|
||||
React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-xl flex flex-col">
|
||||
<GraphQLSanitizedInputField
|
||||
name="root_field_name"
|
||||
label="Native Query Name"
|
||||
placeholder="Name that exposes this model in GraphQL API"
|
||||
hideTips
|
||||
/>
|
||||
<InputField
|
||||
name="comment"
|
||||
label="Comment"
|
||||
placeholder="A description of this logical model"
|
||||
/>
|
||||
<Select
|
||||
name="source"
|
||||
label="Database"
|
||||
// saving prop for future update
|
||||
//noOptionsMessage="No databases found."
|
||||
loading={isSourcesLoading}
|
||||
options={(sources ?? []).map(m => ({
|
||||
label: m.name,
|
||||
value: m.name,
|
||||
}))}
|
||||
placeholder="Select a database..."
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-4xl">
|
||||
{isThereBigQueryOrMssqlSource && (
|
||||
<LimitedFeatureWrapper
|
||||
title="Looking to add Native Queries for SQL Server/Big Query databases?"
|
||||
id="native-queries"
|
||||
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isIntrospectionLoading ? (
|
||||
<div>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : (
|
||||
<ArgumentsField types={typeOptions} />
|
||||
)}
|
||||
<SqlEditorField />
|
||||
<div className="flex w-full">
|
||||
{/* Logical Model Dropdown */}
|
||||
<Select
|
||||
name="returns"
|
||||
selectClassName="max-w-xl"
|
||||
label="Query Return Type"
|
||||
placeholder={logicalModelSelectPlaceholder()}
|
||||
loading={isSourcesLoading}
|
||||
options={(logicalModels ?? []).map(m => ({
|
||||
label: m.name,
|
||||
value: m.name,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
icon={<FaPlusCircle />}
|
||||
onClick={e => {
|
||||
setIsLogicalModelsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Logical Model
|
||||
</Button>
|
||||
</div>
|
||||
{isLogicalModelsDialogOpen ? (
|
||||
<LogicalModelWidget
|
||||
defaultValues={{ dataSourceName: selectedSource }}
|
||||
disabled={{ dataSourceName: !!selectedSource }}
|
||||
onCancel={() => {
|
||||
setIsLogicalModelsDialogOpen(false);
|
||||
}}
|
||||
onSubmit={data => {
|
||||
if (data.dataSourceName !== selectedSource) {
|
||||
setValue('source', data.dataSourceName);
|
||||
}
|
||||
setIsLogicalModelsDialogOpen(false);
|
||||
}}
|
||||
asDialog
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,3 +1,3 @@
|
||||
export { AddNativeQueryRoute } from './AddNativeQuery.route';
|
||||
export { AddNativeQuery } from './AddNativeQuery';
|
||||
export { UpdateNativeQueryRoute } from './UpdateNativeQuery.route';
|
||||
// export { UpdateNativeQueryRoute } from './UpdateNativeQuery.route';
|
||||
|
@ -22,6 +22,11 @@ type HandlersOptions = {
|
||||
enabledFeatureFlag?: boolean;
|
||||
};
|
||||
|
||||
type BulkArgsType = {
|
||||
type: string;
|
||||
args: { root_field_name?: string; name?: string; source?: string };
|
||||
};
|
||||
|
||||
export const nativeQueryHandlers = ({
|
||||
metadataOptions,
|
||||
trackNativeQueryResult = 'success',
|
||||
@ -33,7 +38,7 @@ export const nativeQueryHandlers = ({
|
||||
rest.post('http://localhost:8080/v1/metadata', async (req, res, ctx) => {
|
||||
const reqBody = await req.json<{
|
||||
type: string;
|
||||
args: { root_field_name?: string; name?: string; source?: string };
|
||||
args: BulkArgsType[];
|
||||
}>();
|
||||
|
||||
const response = (
|
||||
@ -45,7 +50,12 @@ export const nativeQueryHandlers = ({
|
||||
return response(buildMetadata(metadataOptions));
|
||||
}
|
||||
|
||||
if (reqBody.type.endsWith('_track_native_query')) {
|
||||
// use the final bulk step as the command to reference:
|
||||
const finalBulkStep = reqBody.args[reqBody.args.length - 1];
|
||||
// get the type from the final step
|
||||
const type = finalBulkStep.type;
|
||||
|
||||
if (type.endsWith('_track_native_query')) {
|
||||
switch (trackNativeQueryResult) {
|
||||
case 'success':
|
||||
return response({ message: 'success' });
|
||||
@ -53,7 +63,7 @@ export const nativeQueryHandlers = ({
|
||||
return response(
|
||||
{
|
||||
code: 'already-tracked',
|
||||
error: `Native query '${reqBody.args.root_field_name}' is already tracked.`,
|
||||
error: `Native query '${finalBulkStep.args.root_field_name}' is already tracked.`,
|
||||
path: '$.args',
|
||||
},
|
||||
400
|
||||
@ -100,7 +110,7 @@ export const nativeQueryHandlers = ({
|
||||
return response(
|
||||
{
|
||||
code: 'not-found',
|
||||
error: `Native query "${reqBody.args.root_field_name}" not found in source "${reqBody.args.source}".`,
|
||||
error: `Native query "${finalBulkStep.args.root_field_name}" not found in source "${finalBulkStep.args.source}".`,
|
||||
path: '$.args',
|
||||
},
|
||||
400
|
||||
@ -125,7 +135,7 @@ export const nativeQueryHandlers = ({
|
||||
return response(
|
||||
{
|
||||
code: 'already-tracked',
|
||||
error: `Logical model '${reqBody.args.name}' is already tracked.`,
|
||||
error: `Logical model '${finalBulkStep.args.name}' is already tracked.`,
|
||||
path: '$.args',
|
||||
},
|
||||
400
|
||||
@ -149,7 +159,7 @@ export const nativeQueryHandlers = ({
|
||||
return response(
|
||||
{
|
||||
code: 'not-found',
|
||||
error: `Logical model "${reqBody.args.name}" not found in source "${reqBody.args.source}".`,
|
||||
error: `Logical model "${finalBulkStep.args.name}" not found in source "${finalBulkStep.args.source}".`,
|
||||
path: '$.args',
|
||||
},
|
||||
400
|
||||
@ -158,7 +168,7 @@ export const nativeQueryHandlers = ({
|
||||
return response(
|
||||
{
|
||||
code: 'constraint-violation',
|
||||
error: `Custom type "${reqBody.args.name}" still being used by native query "hello_mssql_function".`,
|
||||
error: `Custom type "${finalBulkStep.args.name}" still being used by native query "hello_mssql_function".`,
|
||||
path: '$.args',
|
||||
},
|
||||
400
|
||||
|
@ -24,4 +24,36 @@ export const schema = implement<NativeQueryForm>().with({
|
||||
.array(),
|
||||
code: reqString('Sql Query'),
|
||||
returns: reqString('Query Return Type'),
|
||||
array_relationships: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
using: z.object({
|
||||
column_mapping: z.record(z.string()),
|
||||
insertion_order: z.union([
|
||||
z.literal('before_parent'),
|
||||
z.literal('after_parent'),
|
||||
z.literal(null),
|
||||
]),
|
||||
remote_native_query: z.string(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
object_relationships: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
using: z.object({
|
||||
column_mapping: z.record(z.string()),
|
||||
insertion_order: z.union([
|
||||
z.literal('before_parent'),
|
||||
z.literal('after_parent'),
|
||||
z.literal(null),
|
||||
]),
|
||||
remote_native_query: z.string(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
@ -20,6 +20,14 @@ export const transformFormOutputToMetadata = (
|
||||
};
|
||||
}, {});
|
||||
|
||||
// remove source from the object and get the rest...
|
||||
const { source, ...rest } = formValues;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
arguments: queryArgsForMetadata,
|
||||
};
|
||||
|
||||
const { code, returns, root_field_name, comment, type } = formValues;
|
||||
|
||||
return {
|
||||
|
@ -137,7 +137,7 @@ export const LandingPage = ({ pathname }: { pathname: string }) => {
|
||||
isLoading={isLoading}
|
||||
onEditClick={query => {
|
||||
push?.(
|
||||
`data/native-queries/native-query/${query.source.name}/${query.root_field_name}`
|
||||
`data/native-queries/${query.source.name}/${query.root_field_name}`
|
||||
);
|
||||
}}
|
||||
onRemoveClick={handleRemoveNativeQuery}
|
||||
|
@ -4,8 +4,7 @@ import {
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import React from 'react';
|
||||
import { CgDetailsMore } from 'react-icons/cg';
|
||||
import { FaTrash } from 'react-icons/fa';
|
||||
import { FaEdit, FaTrash } from 'react-icons/fa';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import { CardedTableFromReactTable } from '../../components/CardedTableFromReactTable';
|
||||
@ -46,14 +45,8 @@ export const ListNativeQueries = ({
|
||||
header: 'Actions',
|
||||
cell: ({ cell, row }) => (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
// icon={<FaEdit />}
|
||||
icon={<CgDetailsMore />}
|
||||
onClick={() => onEditClick(row.original)}
|
||||
>
|
||||
{/* Edit */}
|
||||
{/* Change back to Edit once we support it */}
|
||||
View
|
||||
<Button icon={<FaEdit />} onClick={() => onEditClick(row.original)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
mode="destructive"
|
||||
|
@ -25,9 +25,9 @@ import { formFieldToLogicalModelField } from './mocks/utils/formFieldToLogicalMo
|
||||
import { useSupportedDrivesForNativeQueries } from '../hook';
|
||||
|
||||
export type AddLogicalModelDialogProps = {
|
||||
defaultValues?: AddLogicalModelFormData;
|
||||
defaultValues?: Partial<AddLogicalModelFormData>;
|
||||
onCancel?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onSubmit?: (data: AddLogicalModelFormData) => void;
|
||||
disabled?: CreateBooleanMap<
|
||||
AddLogicalModelFormData & {
|
||||
callToAction?: boolean;
|
||||
@ -115,7 +115,7 @@ export const LogicalModelWidget = (props: AddLogicalModelDialogProps) => {
|
||||
type: 'success',
|
||||
title: LOGICAL_MODEL_CREATE_SUCCESS,
|
||||
});
|
||||
props.onSubmit?.();
|
||||
props.onSubmit?.(data);
|
||||
},
|
||||
onError: err => {
|
||||
hasuraToast({
|
||||
|
@ -0,0 +1,200 @@
|
||||
import {
|
||||
LogicalModel,
|
||||
LogicalModelField,
|
||||
NativeQuery,
|
||||
NativeQueryRelationship,
|
||||
} from '../../hasura-metadata-types';
|
||||
|
||||
type CommonParams = {
|
||||
dataSourceName: string;
|
||||
driver: string;
|
||||
};
|
||||
interface MigrationPayloadBuilderParams<T extends NativeQuery | LogicalModel> {
|
||||
entity: T;
|
||||
commandEntity: 'logical_model' | 'native_query';
|
||||
entityKey: keyof T;
|
||||
}
|
||||
|
||||
/**
|
||||
* This class lets you build a sequence of metadata commands for bulk_atomic
|
||||
*
|
||||
* The track() / untrack() methods should be used to add commands to the sequence
|
||||
*
|
||||
* The other methods mutate the nativeQuery initially passed in that will get used in the track() command
|
||||
*/
|
||||
export class MigrationPayloadBuilder<T extends NativeQuery | LogicalModel> {
|
||||
protected payloadSequence: Record<string, unknown>[] = [];
|
||||
protected source: string;
|
||||
protected driver: string;
|
||||
protected entity: T;
|
||||
protected commandEntity: 'logical_model' | 'native_query';
|
||||
protected entityKey: keyof T;
|
||||
|
||||
constructor({
|
||||
dataSourceName,
|
||||
driver,
|
||||
entity,
|
||||
commandEntity,
|
||||
entityKey,
|
||||
}: CommonParams & MigrationPayloadBuilderParams<T>) {
|
||||
if (!dataSourceName) {
|
||||
throw new Error('Source is required');
|
||||
}
|
||||
if (!driver) {
|
||||
throw new Error('Driver is required');
|
||||
}
|
||||
if (!entity) {
|
||||
throw new Error('Native Query is required');
|
||||
}
|
||||
|
||||
if (!commandEntity) {
|
||||
throw new Error('Command is required');
|
||||
}
|
||||
|
||||
this.source = dataSourceName;
|
||||
this.driver = driver;
|
||||
this.entity = entity;
|
||||
this.commandEntity = commandEntity;
|
||||
this.entityKey = entityKey;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// can update with some or all propeties
|
||||
updateEntity(newEntity: Partial<T>): this {
|
||||
this.entity = { ...this.entity, ...newEntity };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param entityKey Optional: can provide the name or it will default to the name of the entity passed into the constructor
|
||||
*/
|
||||
untrack(entityKey?: string): this {
|
||||
this.payloadSequence = [
|
||||
...this.payloadSequence,
|
||||
{
|
||||
type: `${this.driver}_untrack_${this.commandEntity}`,
|
||||
args: {
|
||||
source: this.source,
|
||||
[this.entityKey]: entityKey ?? this.entity[this.entityKey],
|
||||
},
|
||||
},
|
||||
];
|
||||
return this;
|
||||
}
|
||||
|
||||
track(): this {
|
||||
this.payloadSequence = [
|
||||
...this.payloadSequence,
|
||||
{
|
||||
type: `${this.driver}_track_${this.commandEntity}`,
|
||||
args: {
|
||||
source: this.source,
|
||||
...this.entity,
|
||||
},
|
||||
},
|
||||
];
|
||||
return this;
|
||||
}
|
||||
|
||||
// returns the payload sequence
|
||||
payload(): Record<string, unknown>[] {
|
||||
return this.payloadSequence;
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeQueryMigrationBuilder extends MigrationPayloadBuilder<NativeQuery> {
|
||||
constructor({
|
||||
dataSourceName,
|
||||
driver,
|
||||
nativeQuery,
|
||||
}: CommonParams & {
|
||||
nativeQuery: NativeQuery;
|
||||
}) {
|
||||
super({
|
||||
dataSourceName,
|
||||
driver,
|
||||
commandEntity: 'native_query',
|
||||
entity: nativeQuery,
|
||||
entityKey: 'root_field_name',
|
||||
});
|
||||
|
||||
this.initializeRelationshipArrays();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private initializeRelationshipArrays() {
|
||||
if (!this.entity.array_relationships) {
|
||||
this.entity.array_relationships = [];
|
||||
}
|
||||
if (!this.entity.object_relationships) {
|
||||
this.entity.object_relationships = [];
|
||||
}
|
||||
}
|
||||
|
||||
addRelationship(
|
||||
type: 'object' | 'array',
|
||||
relationshipDetails: NativeQueryRelationship
|
||||
): this {
|
||||
const relationshipsKey =
|
||||
type === 'object' ? 'object_relationships' : 'array_relationships';
|
||||
// call this just in case it was modified between constructor and here
|
||||
this.initializeRelationshipArrays();
|
||||
|
||||
this.entity[relationshipsKey]?.push(relationshipDetails);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// removes any relationships that match a string name
|
||||
removeRelationship(type: 'object' | 'array', name: string): this {
|
||||
const relationshipsKey =
|
||||
type === 'object' ? 'object_relationships' : 'array_relationships';
|
||||
|
||||
// call this just in case it was modified between constructor and here
|
||||
this.initializeRelationshipArrays();
|
||||
|
||||
this.entity[relationshipsKey] = this.entity[relationshipsKey]?.filter(
|
||||
rel => rel.name !== name
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
override track(): this {
|
||||
// this makes sure it's marked as query which for now is hard coded but not part of the form or UI
|
||||
this.entity.type = 'query';
|
||||
|
||||
return super.track();
|
||||
}
|
||||
}
|
||||
|
||||
export class LogicalModelMigrationBuilder extends MigrationPayloadBuilder<LogicalModel> {
|
||||
constructor({
|
||||
dataSourceName,
|
||||
driver,
|
||||
logicalModel,
|
||||
}: CommonParams & { logicalModel: LogicalModel }) {
|
||||
super({
|
||||
dataSourceName,
|
||||
driver,
|
||||
entity: logicalModel,
|
||||
entityKey: 'name',
|
||||
commandEntity: 'logical_model',
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
addField(field: LogicalModelField): this {
|
||||
this.entity.fields.push(field);
|
||||
return this;
|
||||
}
|
||||
|
||||
removeField(name: string): this {
|
||||
this.entity.fields = this.entity.fields.filter(f => f.name === name);
|
||||
return this;
|
||||
}
|
||||
}
|
@ -0,0 +1,193 @@
|
||||
import { AiOutlineReload } from 'react-icons/ai';
|
||||
import { Button } from '../../../../new-components/Button';
|
||||
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||
import { hasuraToast } from '../../../../new-components/Toasts';
|
||||
import { MetadataSelectors, useMetadata } from '../../../hasura-metadata-api';
|
||||
import { DisplayToastErrorMessage } from '../../components/DisplayErrorMessage';
|
||||
import { useTrackNativeQueryRelationships } from '../../hooks/useTrackNativeQueryRelationships/useTrackNativeQueryRelationships';
|
||||
import { ListNativeQueryRelationships } from './components';
|
||||
import { useWidget } from './hooks/useWidget';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { ListNativeQueryRow } from './components/ListNativeQueryRelationships';
|
||||
import { useDestructiveAlert } from '../../../../new-components/Alert';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type NativeQueryRelationshipProps = {
|
||||
dataSourceName: string;
|
||||
nativeQueryName: string;
|
||||
};
|
||||
|
||||
export const NativeQueryRelationships = (
|
||||
props: NativeQueryRelationshipProps
|
||||
) => {
|
||||
const { dataSourceName, nativeQueryName } = props;
|
||||
|
||||
const { untrackNativeQueryRelationship, trackNativeQueryRelationship } =
|
||||
useTrackNativeQueryRelationships(dataSourceName, nativeQueryName);
|
||||
|
||||
const {
|
||||
data: sourceNativeQuery,
|
||||
isLoading: isMetadataLoading,
|
||||
error: sourceNativeQueryNotFoundError,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useMetadata(m =>
|
||||
MetadataSelectors.findNativeQuery(dataSourceName, nativeQueryName)(m)
|
||||
);
|
||||
|
||||
const handleError = useCallback((err: Error) => {
|
||||
const error: string = err.message;
|
||||
try {
|
||||
const parsed = JSON.parse(error);
|
||||
if (Array.isArray(parsed) && Array.isArray(parsed[parsed.length - 1])) {
|
||||
const lastItem = parsed[parsed.length - 1];
|
||||
if (Array.isArray(lastItem)) {
|
||||
const listOfReasons: string[] = [];
|
||||
lastItem.forEach(err => {
|
||||
if ('reason' in err) {
|
||||
listOfReasons.push(err.reason);
|
||||
}
|
||||
});
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Failed to edit relationship',
|
||||
children: (
|
||||
<DisplayToastErrorMessage message={listOfReasons.join('\n')} />
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Error message is not an array, falling back to showing error.message in raw string format'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Failed to edit relationship',
|
||||
children: <DisplayToastErrorMessage message={err.message} />,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { WidgetUI, openCreate, openEdit, closeWidget } = useWidget({
|
||||
nativeQueryName,
|
||||
dataSourceName,
|
||||
onSubmit: params => {
|
||||
const { values, mode } = params;
|
||||
trackNativeQueryRelationship({
|
||||
data: {
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
using: {
|
||||
column_mapping: values.columnMapping,
|
||||
remote_native_query: values.toNativeQuery,
|
||||
insertion_order: null,
|
||||
},
|
||||
},
|
||||
editDetails:
|
||||
params.mode === 'edit'
|
||||
? {
|
||||
name: params.originalRelationshipName,
|
||||
type: params.originalRelationshipType,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
onSuccess: () => {
|
||||
closeWidget();
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
title: `Successfully ${
|
||||
mode === 'create' ? 'created' : 'edited'
|
||||
} relationship "${values.name}"`,
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { destructiveConfirm } = useDestructiveAlert();
|
||||
|
||||
const handleDelete = (data: ListNativeQueryRow) => {
|
||||
destructiveConfirm({
|
||||
resourceName: data.name,
|
||||
resourceType: 'relationship',
|
||||
destroyTerm: 'remove',
|
||||
onConfirm: () =>
|
||||
new Promise(resolve => {
|
||||
untrackNativeQueryRelationship({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
},
|
||||
onSuccess: () => {
|
||||
resolve(true);
|
||||
},
|
||||
onError: err => {
|
||||
resolve(false);
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Failed to delete relationship',
|
||||
children: <DisplayToastErrorMessage message={err.message} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (data: ListNativeQueryRow) => {
|
||||
openEdit({
|
||||
name: data.name,
|
||||
columnMapping: data.using.column_mapping,
|
||||
type: data.type,
|
||||
toNativeQuery: data.using.remote_native_query,
|
||||
});
|
||||
};
|
||||
|
||||
if (isMetadataLoading) return <Skeleton count={10} />;
|
||||
|
||||
if (sourceNativeQueryNotFoundError)
|
||||
return (
|
||||
<div>
|
||||
<IndicatorCard status="negative">
|
||||
{sourceNativeQueryNotFoundError.message ??
|
||||
JSON.stringify(sourceNativeQueryNotFoundError)}
|
||||
</IndicatorCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!sourceNativeQuery)
|
||||
return (
|
||||
<div>
|
||||
<IndicatorCard status="info">
|
||||
Could not find Native Query : {nativeQueryName} in metadata. Please
|
||||
reload metadata and try again.
|
||||
<Button
|
||||
icon={<AiOutlineReload />}
|
||||
onClick={() => refetch()}
|
||||
isLoading={isRefetching}
|
||||
>
|
||||
Reload Metadata
|
||||
</Button>
|
||||
</IndicatorCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="h-4" />
|
||||
<ListNativeQueryRelationships
|
||||
dataSourceName={dataSourceName}
|
||||
nativeQueryName={nativeQueryName}
|
||||
onEditRow={handleEdit}
|
||||
onDeleteRow={handleDelete}
|
||||
/>
|
||||
<Button mode="primary" onClick={() => openCreate()}>
|
||||
Add Relationship
|
||||
</Button>
|
||||
{WidgetUI()}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,34 @@
|
||||
import { StoryObj, Meta } from '@storybook/react';
|
||||
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
|
||||
import { ListNativeQueryRelationships } from './ListNativeQueryRelationships';
|
||||
import { ReduxDecorator } from '../../../../../storybook/decorators/redux-decorator';
|
||||
import { nativeQueryHandlers } from '../../AddNativeQuery/mocks';
|
||||
|
||||
export default {
|
||||
component: ListNativeQueryRelationships,
|
||||
decorators: [
|
||||
ReactQueryDecorator(),
|
||||
ReduxDecorator({
|
||||
tables: {},
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
msw: nativeQueryHandlers({
|
||||
metadataOptions: { postgres: { models: true, queries: true } },
|
||||
trackNativeQueryResult: 'success',
|
||||
}),
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
argTypes: {
|
||||
onDeleteRow: { action: 'clicked delete' },
|
||||
onEditRow: { action: 'clicked edit' },
|
||||
},
|
||||
} as Meta<typeof ListNativeQueryRelationships>;
|
||||
|
||||
export const DefaultView: StoryObj<typeof ListNativeQueryRelationships> = {
|
||||
args: {
|
||||
dataSourceName: 'postgres',
|
||||
nativeQueryName: 'customer_native_query',
|
||||
},
|
||||
render: args => <ListNativeQueryRelationships {...args} />,
|
||||
};
|
@ -0,0 +1,116 @@
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import {
|
||||
MetadataSelectors,
|
||||
useMetadata,
|
||||
} from '../../../../hasura-metadata-api';
|
||||
import {
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import React from 'react';
|
||||
import { useCardedTableFromReactTableWithRef } from '../../components/CardedTableFromReactTable';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import { FaEdit, FaTrash } from 'react-icons/fa';
|
||||
import { NativeQueryRelationship } from '../../../../hasura-metadata-types';
|
||||
|
||||
export type ListNativeQueryRow = NativeQueryRelationship & {
|
||||
type: 'object' | 'array';
|
||||
};
|
||||
|
||||
export type ListNativeQueryRelationships = {
|
||||
dataSourceName: string;
|
||||
nativeQueryName: string;
|
||||
onDeleteRow?: (data: ListNativeQueryRow) => void;
|
||||
onEditRow?: (data: ListNativeQueryRow) => void;
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<ListNativeQueryRow>();
|
||||
|
||||
export const ListNativeQueryRelationships = (
|
||||
props: ListNativeQueryRelationships
|
||||
) => {
|
||||
const { dataSourceName, nativeQueryName, onDeleteRow, onEditRow } = props;
|
||||
|
||||
const { data: nativeQueryRelationships = [], isLoading } = useMetadata<
|
||||
ListNativeQueryRow[]
|
||||
>(m => {
|
||||
const currentNativeQuery = MetadataSelectors.findNativeQuery(
|
||||
dataSourceName,
|
||||
nativeQueryName
|
||||
)(m);
|
||||
|
||||
return [
|
||||
...(currentNativeQuery?.array_relationships?.map(relationship => ({
|
||||
...relationship,
|
||||
type: 'array' as ListNativeQueryRow['type'],
|
||||
})) ?? []),
|
||||
...(currentNativeQuery?.object_relationships?.map(relationship => ({
|
||||
...relationship,
|
||||
type: 'object' as ListNativeQueryRow['type'],
|
||||
})) ?? []),
|
||||
];
|
||||
});
|
||||
|
||||
const tableRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const columns = React.useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('name', {
|
||||
id: 'name',
|
||||
cell: data => <span>{data.getValue()}</span>,
|
||||
header: 'Name',
|
||||
}),
|
||||
columnHelper.accessor('type', {
|
||||
id: 'type',
|
||||
cell: data => <span>{data.getValue()}</span>,
|
||||
header: 'Type',
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-8">
|
||||
<Button
|
||||
icon={<FaEdit />}
|
||||
onClick={() => {
|
||||
onEditRow?.(row.original);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FaTrash />}
|
||||
mode="destructive"
|
||||
onClick={() => {
|
||||
onDeleteRow?.(row.original);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
header: 'Actions',
|
||||
}),
|
||||
],
|
||||
[onDeleteRow, onEditRow]
|
||||
);
|
||||
|
||||
const relationshipsTable = useReactTable({
|
||||
data: nativeQueryRelationships,
|
||||
columns: columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const NativeQueryRelationshipsTable =
|
||||
useCardedTableFromReactTableWithRef<ListNativeQueryRow>();
|
||||
|
||||
if (isLoading) return <Skeleton count={10} />;
|
||||
|
||||
return (
|
||||
<NativeQueryRelationshipsTable
|
||||
table={relationshipsTable}
|
||||
ref={tableRef}
|
||||
noRowsMessage={'No relationships added'}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,165 @@
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import { Dialog } from '../../../../../new-components/Dialog';
|
||||
import { useConsoleForm } from '../../../../../new-components/Form';
|
||||
import { MetadataUtils, useMetadata } from '../../../../hasura-metadata-api';
|
||||
import {
|
||||
NativeQueryRelationshipFormSchema,
|
||||
nativeQueryRelationshipValidationSchema,
|
||||
} from '../schema';
|
||||
import { TrackNativeQueryRelationshipForm } from './TrackNativeQueryRelationshipForm';
|
||||
import { useCallback } from 'react';
|
||||
import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
|
||||
|
||||
export type NativeQueryRelationshipWidgetProps = {
|
||||
fromNativeQuery: string;
|
||||
dataSourceName: string;
|
||||
defaultValues?: NativeQueryRelationshipFormSchema;
|
||||
onCancel?: () => void;
|
||||
onSubmit?: (data: NativeQueryRelationshipFormSchema) => void;
|
||||
mode: 'create' | 'edit';
|
||||
asDialog?: boolean;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const NativeQueryRelationshipWidget = ({
|
||||
fromNativeQuery,
|
||||
dataSourceName,
|
||||
asDialog,
|
||||
...props
|
||||
}: NativeQueryRelationshipWidgetProps) => {
|
||||
const isEditMode = props.mode === 'edit';
|
||||
|
||||
const {
|
||||
Form,
|
||||
methods: { watch, handleSubmit },
|
||||
} = useConsoleForm({
|
||||
schema: nativeQueryRelationshipValidationSchema,
|
||||
options: {
|
||||
defaultValues: props.defaultValues,
|
||||
},
|
||||
});
|
||||
|
||||
const targetNativeQuery = watch();
|
||||
|
||||
const metadataSelector = useCallback(
|
||||
m => {
|
||||
const source = MetadataUtils.findMetadataSource(dataSourceName, m);
|
||||
|
||||
if (!source)
|
||||
throw new Error(
|
||||
`Unabled to find source ${dataSourceName} for Native Query Relationships Widget`
|
||||
);
|
||||
|
||||
const data = {
|
||||
models: source.logical_models ?? [],
|
||||
queries: source.native_queries ?? [],
|
||||
};
|
||||
|
||||
const fromQuery = data.queries.find(
|
||||
q => q.root_field_name === fromNativeQuery
|
||||
);
|
||||
const targetQuery = data.queries.find(
|
||||
q => q.root_field_name === targetNativeQuery.toNativeQuery
|
||||
);
|
||||
const fromModel = data.models.find(
|
||||
model => model.name === fromQuery?.returns
|
||||
);
|
||||
|
||||
const targetModel = data.models.find(
|
||||
model => model.name === targetQuery?.returns
|
||||
);
|
||||
|
||||
return {
|
||||
...data,
|
||||
fromQuery,
|
||||
targetQuery,
|
||||
fromModel,
|
||||
targetModel,
|
||||
};
|
||||
},
|
||||
[dataSourceName, fromNativeQuery, targetNativeQuery.toNativeQuery]
|
||||
);
|
||||
|
||||
const { data, error: metadataError, status } = useMetadata(metadataSelector);
|
||||
|
||||
const onSubmit = (data: NativeQueryRelationshipFormSchema) => {
|
||||
// add/remove relationship
|
||||
props?.onSubmit?.(data);
|
||||
};
|
||||
|
||||
const fields = () => {
|
||||
if (status !== 'success') return null;
|
||||
return (
|
||||
<TrackNativeQueryRelationshipForm
|
||||
fromNativeQuery={data.fromQuery?.root_field_name ?? ''}
|
||||
nativeQueryOptions={data.queries
|
||||
.map(q => q.root_field_name)
|
||||
.filter(q => q !== fromNativeQuery)}
|
||||
fromFieldOptions={data.fromModel?.fields.map(f => f.name) ?? []}
|
||||
toFieldOptions={data.targetModel?.fields.map(f => f.name) ?? []}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const body = () => {
|
||||
if (status === 'loading') {
|
||||
return <Skeleton count={8} height={20} />;
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<IndicatorCard>
|
||||
A metadata related error has occurred:
|
||||
<div>
|
||||
{metadataError.message ?? JSON.stringify(metadataError, null, 2)}
|
||||
</div>
|
||||
</IndicatorCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-md">
|
||||
<Form onSubmit={!asDialog ? onSubmit : () => {}}>
|
||||
{fields()}
|
||||
{!asDialog && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" mode="primary">
|
||||
{isEditMode ? 'Edit Relationship' : 'Add Relationship'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// for transparency, as of this comment, there are no !asDialog implementations of this component.
|
||||
// see ../useWidget.tsx for implemenation
|
||||
if (!asDialog) {
|
||||
return body();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
size="xl"
|
||||
description="Create object or arrary relationships between Native Queries."
|
||||
footer={{
|
||||
onSubmit: () => {
|
||||
handleSubmit(onSubmit)();
|
||||
},
|
||||
onClose: props.onCancel,
|
||||
isLoading: props.isLoading,
|
||||
callToDeny: 'Cancel',
|
||||
callToAction: 'Save',
|
||||
onSubmitAnalyticsName: 'actions-tab-generate-types-submit',
|
||||
onCancelAnalyticsName: 'actions-tab-generate-types-cancel',
|
||||
}}
|
||||
title={`${props.mode === 'create' ? 'Add' : 'Edit'} Relationship`}
|
||||
hasBackdrop
|
||||
onClose={props.onCancel}
|
||||
>
|
||||
{body()}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1,46 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import { SimpleForm } from '../../../../../new-components/Form';
|
||||
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
|
||||
import { ReduxDecorator } from '../../../../../storybook/decorators/redux-decorator';
|
||||
import { TrackNativeQueryRelationshipForm } from './TrackNativeQueryRelationshipForm';
|
||||
import { nativeQueryRelationshipValidationSchema } from '../schema';
|
||||
|
||||
export default {
|
||||
component: TrackNativeQueryRelationshipForm,
|
||||
decorators: [
|
||||
ReactQueryDecorator(),
|
||||
ReduxDecorator({
|
||||
tables: {
|
||||
// dataHeaders: {
|
||||
// 'x-hasura-admin-secret': 'myadminsecretkey',
|
||||
// } as any,
|
||||
},
|
||||
}),
|
||||
],
|
||||
} as Meta<typeof TrackNativeQueryRelationshipForm>;
|
||||
|
||||
export const DefaultView: StoryObj<typeof TrackNativeQueryRelationshipForm> = {
|
||||
render: () => {
|
||||
return (
|
||||
<SimpleForm
|
||||
schema={nativeQueryRelationshipValidationSchema}
|
||||
onSubmit={data => {
|
||||
console.log(data);
|
||||
}}
|
||||
>
|
||||
<TrackNativeQueryRelationshipForm
|
||||
name="relationship"
|
||||
fromNativeQuery="get_authors"
|
||||
nativeQueryOptions={['get_authors', 'get_articles']}
|
||||
fromFieldOptions={['field1', 'field2']}
|
||||
toFieldOptions={['field3', 'field4']}
|
||||
/>
|
||||
|
||||
<Button type="submit">Submit</Button>
|
||||
</SimpleForm>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// write more tests for this part
|
@ -0,0 +1,62 @@
|
||||
import {
|
||||
GraphQLSanitizedInputField,
|
||||
Select,
|
||||
} from '../../../../../new-components/Form';
|
||||
import { ListMap } from '../../../../../new-components/ListMap';
|
||||
|
||||
export const TrackNativeQueryRelationshipForm = ({
|
||||
name,
|
||||
fromNativeQuery,
|
||||
nativeQueryOptions,
|
||||
fromFieldOptions,
|
||||
toFieldOptions,
|
||||
}: {
|
||||
fromNativeQuery: string;
|
||||
nativeQueryOptions: string[];
|
||||
name?: string;
|
||||
fromFieldOptions: string[];
|
||||
toFieldOptions: string[];
|
||||
}) => {
|
||||
const allowedNativeQueryOptions = nativeQueryOptions
|
||||
.filter(nq => nq !== fromNativeQuery)
|
||||
.map(nq => ({ value: nq, label: nq }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GraphQLSanitizedInputField
|
||||
hideTips
|
||||
label="Relationship Name"
|
||||
placeholder="Name your native query relationship"
|
||||
name={'name'}
|
||||
/>
|
||||
<Select
|
||||
name={'toNativeQuery'}
|
||||
options={allowedNativeQueryOptions}
|
||||
label="Target Native Query"
|
||||
placeholder="Select target native query"
|
||||
/>
|
||||
|
||||
<Select
|
||||
name={'type'}
|
||||
options={[
|
||||
{ value: 'object', label: 'Object' },
|
||||
{ value: 'array', label: 'Array' },
|
||||
]}
|
||||
label="Relationship Type"
|
||||
/>
|
||||
|
||||
<ListMap
|
||||
name={'columnMapping'}
|
||||
from={{
|
||||
options: fromFieldOptions,
|
||||
label: 'Source Field',
|
||||
}}
|
||||
to={{
|
||||
type: 'array',
|
||||
options: toFieldOptions,
|
||||
label: 'Target Field',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export { ListNativeQueryRelationships } from './ListNativeQueryRelationships';
|
||||
export { NativeQueryRelationshipWidget } from './NativeQueryRelationshipWidget';
|
||||
export { TrackNativeQueryRelationshipForm } from './TrackNativeQueryRelationshipForm';
|
@ -0,0 +1,177 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useHasuraAlert } from '../../../../../new-components/Alert';
|
||||
import {
|
||||
MetadataSelectors,
|
||||
MetadataUtils,
|
||||
useMetadata,
|
||||
} from '../../../../hasura-metadata-api';
|
||||
import { NativeQueryRelationshipWidget } from '../components';
|
||||
import { NativeQueryRelationshipFormSchema } from '../schema';
|
||||
|
||||
type WidgetMode = 'create' | 'edit';
|
||||
|
||||
type onSubmitParams = {
|
||||
values: NativeQueryRelationshipFormSchema;
|
||||
} & (
|
||||
| {
|
||||
mode: 'edit';
|
||||
originalRelationshipName: string;
|
||||
originalRelationshipType: 'object' | 'array';
|
||||
}
|
||||
| { mode: 'create' }
|
||||
);
|
||||
|
||||
// the purpose of this hook is to enclose some of the complexity in working with the relationship widget and give the consumer a simpler API to interact with
|
||||
export function useWidget({
|
||||
nativeQueryName,
|
||||
dataSourceName,
|
||||
onSubmit,
|
||||
}: {
|
||||
nativeQueryName: string;
|
||||
dataSourceName: string;
|
||||
onSubmit: (params: onSubmitParams) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mode, setMode] = useState<WidgetMode>('create');
|
||||
const [defaultValues, setDefaultValues] =
|
||||
useState<NativeQueryRelationshipFormSchema>();
|
||||
|
||||
const metadataSelector = useCallback(
|
||||
m => {
|
||||
const source = MetadataUtils.findMetadataSource(dataSourceName, m);
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
`Unable to find source ${dataSourceName} for Relationship Widget`
|
||||
);
|
||||
}
|
||||
const otherNativeQueries = (source.native_queries ?? []).filter(
|
||||
q => q.root_field_name !== nativeQueryName
|
||||
);
|
||||
|
||||
const thisNativeQuery = MetadataSelectors.findNativeQuery(
|
||||
dataSourceName,
|
||||
nativeQueryName
|
||||
)(m);
|
||||
|
||||
return {
|
||||
otherNativeQueries,
|
||||
otherRelationships: {
|
||||
object: thisNativeQuery?.object_relationships ?? [],
|
||||
array: thisNativeQuery?.array_relationships ?? [],
|
||||
},
|
||||
};
|
||||
},
|
||||
[dataSourceName, nativeQueryName]
|
||||
);
|
||||
|
||||
const { data: { otherNativeQueries, otherRelationships } = {} } =
|
||||
useMetadata(metadataSelector);
|
||||
|
||||
const { hasuraAlert, hasuraConfirm } = useHasuraAlert();
|
||||
|
||||
const openCreate = () => {
|
||||
if (!otherNativeQueries || otherNativeQueries.length === 0) {
|
||||
alertUserNoPossibleQueries();
|
||||
return;
|
||||
}
|
||||
setMode('create');
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (initialValues: NativeQueryRelationshipFormSchema) => {
|
||||
setDefaultValues(initialValues);
|
||||
setMode('edit');
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const closeWidget = () => setIsOpen(false);
|
||||
|
||||
const alertUserNoPossibleQueries = () => {
|
||||
hasuraAlert({
|
||||
message: (
|
||||
<span>
|
||||
There are no other native queries to create a relationship with.
|
||||
Create another Native Queries on{' '}
|
||||
<span className="font-bold">{dataSourceName}</span> and try again.
|
||||
</span>
|
||||
),
|
||||
title: 'Error',
|
||||
onClose: closeWidget,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (values: NativeQueryRelationshipFormSchema) => {
|
||||
const doSubmit = () => {
|
||||
if (mode === 'edit') {
|
||||
if (!defaultValues?.name || !defaultValues.type) {
|
||||
throw new Error(
|
||||
'Name or type of original relationship was not able to be determined.'
|
||||
);
|
||||
}
|
||||
onSubmit({
|
||||
values,
|
||||
mode,
|
||||
originalRelationshipName: defaultValues.name,
|
||||
originalRelationshipType: defaultValues.type,
|
||||
});
|
||||
} else {
|
||||
onSubmit({ values, mode });
|
||||
}
|
||||
};
|
||||
|
||||
const hasSameName = otherRelationships?.[values.type]?.some(
|
||||
r => r.name === values.name
|
||||
);
|
||||
|
||||
const isCreating = mode === 'create';
|
||||
|
||||
const changedTypeWhileEditing =
|
||||
mode === 'edit' && defaultValues?.type !== values.type;
|
||||
|
||||
const warnOfOverwrite =
|
||||
hasSameName && (isCreating || changedTypeWhileEditing);
|
||||
|
||||
if (warnOfOverwrite) {
|
||||
hasuraConfirm({
|
||||
title: 'Overwrite Existing Relationship?',
|
||||
message: (
|
||||
<div>
|
||||
There is already an existing <strong>{values.type}</strong>{' '}
|
||||
relationship with the name <strong>{values.name}</strong>. Do you
|
||||
want to overwrite this relationship?
|
||||
</div>
|
||||
),
|
||||
confirmText: 'Overwrite',
|
||||
destructive: true,
|
||||
onClose: ({ confirmed }) => {
|
||||
if (confirmed) doSubmit();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
doSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const WidgetUI = () => (
|
||||
<>
|
||||
{isOpen && (
|
||||
<NativeQueryRelationshipWidget
|
||||
fromNativeQuery={nativeQueryName}
|
||||
dataSourceName={dataSourceName}
|
||||
defaultValues={mode === 'create' ? undefined : defaultValues}
|
||||
mode={mode}
|
||||
asDialog
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
WidgetUI,
|
||||
closeWidget,
|
||||
openCreate,
|
||||
openEdit,
|
||||
};
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const nativeQueryRelationshipValidationSchema = z.object({
|
||||
name: z.string().min(1, 'Relationship name required!'),
|
||||
toNativeQuery: z.string().min(1, 'Target Native Query is required!'),
|
||||
type: z.union([z.literal('object'), z.literal('array')]),
|
||||
columnMapping: z.record(z.string()),
|
||||
});
|
||||
|
||||
export type NativeQueryRelationshipFormSchema = z.infer<
|
||||
typeof nativeQueryRelationshipValidationSchema
|
||||
>;
|
@ -1,63 +1,53 @@
|
||||
import startCase from 'lodash/startCase';
|
||||
import React, { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { Breadcrumbs } from '../../../../new-components/Breadcrumbs';
|
||||
import { usePushRoute } from '../../../ConnectDBRedesign/hooks';
|
||||
import { LimitedFeatureWrapper } from '../../../ConnectDBRedesign/components/LimitedFeatureWrapper/LimitedFeatureWrapper';
|
||||
import {
|
||||
useEnvironmentState,
|
||||
usePushRoute,
|
||||
} from '../../../ConnectDBRedesign/hooks';
|
||||
import { NATIVE_QUERY_ROUTES } from '../constants';
|
||||
import { injectRouteDetails, pathsToBreadcrumbs } from './route-wrapper-utils';
|
||||
|
||||
export const RouteWrapper: React.FC<{
|
||||
export type RouteWrapperProps = {
|
||||
route: keyof typeof NATIVE_QUERY_ROUTES;
|
||||
itemSourceName?: string;
|
||||
itemName?: string;
|
||||
}> = ({ children, route, itemSourceName, itemName }) => {
|
||||
const paths =
|
||||
route
|
||||
?.split('/')
|
||||
.filter(Boolean)
|
||||
.filter(p => p !== '{{source}}') ?? [];
|
||||
itemTabName?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export const RouteWrapper: React.FC<RouteWrapperProps> = props => {
|
||||
const { children, route, subtitle: subtitleOverride } = props;
|
||||
|
||||
const paths = route?.split('/').filter(Boolean);
|
||||
|
||||
const { title, subtitle } = NATIVE_QUERY_ROUTES[route];
|
||||
|
||||
const push = usePushRoute();
|
||||
// const { consoleType } = useEnvironmentState();
|
||||
const { consoleType } = useEnvironmentState();
|
||||
|
||||
return (
|
||||
<div className="py-md px-md w-full">
|
||||
{/* <LimitedFeatureWrapper
|
||||
<LimitedFeatureWrapper
|
||||
title="Looking to add Native Queries?"
|
||||
id="native-queries"
|
||||
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
|
||||
override={consoleType === 'oss'}
|
||||
> */}
|
||||
<div className="flex flex-col">
|
||||
<Breadcrumbs
|
||||
items={paths.map((path: string, index) => {
|
||||
return {
|
||||
title: startCase(
|
||||
path
|
||||
// we don't need to display source
|
||||
.replace('{{source}}', itemSourceName ?? '')
|
||||
.replace('{{name}}', itemName ?? '')
|
||||
),
|
||||
onClick:
|
||||
index === paths.length - 1
|
||||
? undefined
|
||||
: () => {
|
||||
push(`/${paths.slice(0, index + 1).join('/')}`);
|
||||
},
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<div className="flex w-full justify-between px-2">
|
||||
<div className="mb-sm">
|
||||
<div className="text-xl font-bold mt-2">
|
||||
{title.replace('{{name}}', itemName ?? '')}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Breadcrumbs items={pathsToBreadcrumbs(paths, props, push)} />
|
||||
<div className="flex w-full justify-between px-2">
|
||||
<div className="mb-sm">
|
||||
<div className="text-xl font-bold mt-2">
|
||||
{injectRouteDetails(title, props)}
|
||||
</div>
|
||||
<div className="text-muted">{subtitleOverride ?? subtitle}</div>
|
||||
</div>
|
||||
<div className="text-muted">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">{children}</div>
|
||||
</div>
|
||||
{/* </LimitedFeatureWrapper> */}
|
||||
</LimitedFeatureWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,55 @@
|
||||
import startCase from 'lodash/startCase';
|
||||
import { BreadcrumbItem } from '../../../../new-components/Breadcrumbs/Breadcrumbs';
|
||||
import { RouteWrapperProps } from './RouteWrapper';
|
||||
|
||||
export const injectRouteDetails = (
|
||||
path: string,
|
||||
{
|
||||
itemName,
|
||||
itemSourceName,
|
||||
itemTabName,
|
||||
}: Pick<RouteWrapperProps, 'itemName' | 'itemSourceName' | 'itemTabName'>
|
||||
) => {
|
||||
return path
|
||||
.replace('{{source}}', itemSourceName ?? '')
|
||||
.replace('{{name}}', itemName ?? '')
|
||||
.replace('{{tab}}', itemTabName ?? '');
|
||||
};
|
||||
|
||||
export const pathsToBreadcrumbs = (
|
||||
paths: string[],
|
||||
props: RouteWrapperProps,
|
||||
_push: (path: string) => void
|
||||
): BreadcrumbItem[] =>
|
||||
paths.reduce<BreadcrumbItem[]>((prev, path, index, arr) => {
|
||||
// skip source in path
|
||||
if (path === '{{source}}') return prev;
|
||||
|
||||
let title = startCase(injectRouteDetails(path, props));
|
||||
|
||||
//prepend source to the item directly following the source so the source is visible in the breadcrumb but not it's own entry in the crumbs
|
||||
if (arr[index - 1] === '{{source}}') {
|
||||
const source = injectRouteDetails(arr[index - 1], props);
|
||||
title = `${source} / ${title}`;
|
||||
}
|
||||
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
title,
|
||||
onClick:
|
||||
index === paths.length - 1
|
||||
? undefined
|
||||
: () => {
|
||||
const pathIndex = paths.indexOf(path);
|
||||
|
||||
const newPath = injectRouteDetails(
|
||||
`/${paths.slice(0, pathIndex + 1).join('/')}`,
|
||||
props
|
||||
);
|
||||
|
||||
_push(newPath);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
@ -14,13 +14,16 @@ export const NATIVE_QUERY_ROUTES = {
|
||||
title: 'Native Queries',
|
||||
subtitle: 'Access more queries and operators through SQL on your database',
|
||||
},
|
||||
// create new native query
|
||||
'/data/native-queries/create': {
|
||||
title: 'Create Native Query',
|
||||
subtitle: 'Access more queries and operators through SQL on your database',
|
||||
},
|
||||
'/data/native-queries/{{source}}/{{name}}': {
|
||||
// edit/view native query:
|
||||
'/data/native-queries/{{source}}/{{name}}/{{tab}}': {
|
||||
title: '{{name}}',
|
||||
subtitle: 'Access more queries and operators through SQL on your database',
|
||||
//setting via the RouteWrapper props for this one so it can be dynamic based on tab
|
||||
subtitle: '',
|
||||
},
|
||||
'/data/native-queries/logical-models': {
|
||||
title: 'Logical Models',
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Metadata } from '../../../hasura-metadata-types';
|
||||
import { LogicalModel, Metadata } from '../../../hasura-metadata-types';
|
||||
|
||||
const testQueries = {
|
||||
postgres: [
|
||||
@ -14,6 +14,30 @@ const testQueries = {
|
||||
returns: 'hello_world2',
|
||||
root_field_name: 'hello_world_function2',
|
||||
},
|
||||
{
|
||||
arguments: {},
|
||||
code: 'select "CustomerId", "BillingCity" as "City", "BillingCountry" as "Country" from "public"."Invoice"',
|
||||
returns: 'customer_location_model',
|
||||
root_field_name: 'customer_location',
|
||||
},
|
||||
{
|
||||
arguments: {},
|
||||
code: 'select "CustomerId" as "Id", "FirstName" as "Name" from "public"."Customer"',
|
||||
object_relationships: [
|
||||
{
|
||||
name: 'location',
|
||||
using: {
|
||||
column_mapping: {
|
||||
Id: 'CustomerId',
|
||||
},
|
||||
insertion_order: null,
|
||||
remote_native_query: 'customer_location',
|
||||
},
|
||||
},
|
||||
],
|
||||
returns: 'customer_model',
|
||||
root_field_name: 'customer_native_query',
|
||||
},
|
||||
],
|
||||
mssql: [
|
||||
{
|
||||
@ -31,19 +55,17 @@ const testQueries = {
|
||||
],
|
||||
};
|
||||
|
||||
const testModels = {
|
||||
const testModels: Record<string, LogicalModel[]> = {
|
||||
postgres: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'one',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
],
|
||||
name: 'hello_world',
|
||||
@ -52,30 +74,72 @@ const testModels = {
|
||||
fields: [
|
||||
{
|
||||
name: 'one',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
],
|
||||
name: 'hello_world2',
|
||||
},
|
||||
// for testing relationships:
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'CustomerId',
|
||||
type: {
|
||||
nullable: false,
|
||||
scalar: 'integer',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'City',
|
||||
type: {
|
||||
nullable: true,
|
||||
scalar: 'varchar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Country',
|
||||
type: {
|
||||
nullable: true,
|
||||
scalar: 'varchar',
|
||||
},
|
||||
},
|
||||
],
|
||||
name: 'customer_location_model',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'Id',
|
||||
type: {
|
||||
nullable: false,
|
||||
scalar: 'integer',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Name',
|
||||
type: {
|
||||
nullable: true,
|
||||
scalar: 'varchar',
|
||||
},
|
||||
},
|
||||
],
|
||||
name: 'customer_model',
|
||||
},
|
||||
],
|
||||
mssql: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'one',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
],
|
||||
name: 'hello_mssql',
|
||||
@ -84,13 +148,11 @@ const testModels = {
|
||||
fields: [
|
||||
{
|
||||
name: 'one',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
{
|
||||
name: 'two',
|
||||
nullable: false,
|
||||
type: 'text',
|
||||
type: { scalar: 'string', nullable: true },
|
||||
},
|
||||
],
|
||||
name: 'hello_mssql2',
|
||||
|
@ -3,6 +3,7 @@ import { useMetadataMigration } from '../../MetadataAPI';
|
||||
import { MetadataMigrationOptions } from '../../MetadataAPI/hooks/useMetadataMigration';
|
||||
import { useMetadata } from '../../hasura-metadata-api';
|
||||
import { NativeQuery, Source } from '../../hasura-metadata-types';
|
||||
import { NativeQueryMigrationBuilder } from '../LogicalModels/MigrationBuilder';
|
||||
import { getSourceDriver } from './utils';
|
||||
|
||||
export type TrackNativeQuery = {
|
||||
@ -35,16 +36,39 @@ export const useTrackNativeQuery = (
|
||||
|
||||
const trackNativeQuery = async ({
|
||||
data: args,
|
||||
editDetails,
|
||||
...options
|
||||
}: {
|
||||
data: TrackNativeQuery;
|
||||
editDetails?: { rootFieldName: string };
|
||||
} & MetadataMigrationOptions) => {
|
||||
const { source, ...nativeQuery } = args;
|
||||
const driver = getSourceDriver(sources, args.source);
|
||||
|
||||
if (!driver) {
|
||||
throw new Error('Source could not be found. Unable to identify driver.');
|
||||
}
|
||||
|
||||
const builder = new NativeQueryMigrationBuilder({
|
||||
dataSourceName: source,
|
||||
driver,
|
||||
nativeQuery,
|
||||
});
|
||||
|
||||
// we need the untrack command to use the old root_field_name
|
||||
// if a user is editing the native query, there's a chance that the root field name changed
|
||||
// so, we have to manually set that when using untrack()
|
||||
const argz = editDetails
|
||||
? builder.untrack(editDetails.rootFieldName).track().payload()
|
||||
: builder.track().payload();
|
||||
|
||||
mutate(
|
||||
{
|
||||
query: {
|
||||
resource_version,
|
||||
type: `${getSourceDriver(sources, args.source)}_track_native_query`,
|
||||
args,
|
||||
type: 'bulk_atomic',
|
||||
|
||||
args: argz,
|
||||
},
|
||||
},
|
||||
options
|
||||
|
@ -0,0 +1,134 @@
|
||||
import { transformErrorResponse } from '../../../ConnectDBRedesign/utils';
|
||||
import { useMetadataMigration } from '../../../MetadataAPI';
|
||||
import { MetadataMigrationOptions } from '../../../MetadataAPI/hooks/useMetadataMigration';
|
||||
import { MetadataSelectors, useMetadata } from '../../../hasura-metadata-api';
|
||||
import {
|
||||
NativeQuery,
|
||||
NativeQueryRelationship,
|
||||
Source,
|
||||
} from '../../../hasura-metadata-types';
|
||||
import { NativeQueryMigrationBuilder } from '../../LogicalModels/MigrationBuilder';
|
||||
|
||||
export type TrackNativeQueryRelationshipsProps = NativeQueryRelationship & {
|
||||
type: 'object' | 'array';
|
||||
};
|
||||
|
||||
export type UntrackNativeQuery = { source: Source } & Pick<
|
||||
NativeQuery,
|
||||
'root_field_name'
|
||||
>;
|
||||
|
||||
export const useTrackNativeQueryRelationships = (
|
||||
dataSourceName: string,
|
||||
nativeQueryName: string,
|
||||
globalMutateOptions?: MetadataMigrationOptions
|
||||
) => {
|
||||
/**
|
||||
* Get the required metadata variables - sources & resource_version
|
||||
*/
|
||||
const { data: { driver, originNativeQuery, resource_version } = {} } =
|
||||
useMetadata(m => ({
|
||||
driver: MetadataSelectors.findSource(dataSourceName)(m)?.kind,
|
||||
originNativeQuery: MetadataSelectors.findSource(dataSourceName)(
|
||||
m
|
||||
)?.native_queries?.find(nq => nq.root_field_name === nativeQueryName),
|
||||
resource_version: m.resource_version,
|
||||
}));
|
||||
|
||||
const { mutate, ...rest } = useMetadataMigration({
|
||||
...globalMutateOptions,
|
||||
errorTransform: transformErrorResponse,
|
||||
onSuccess: (data, variable, ctx) => {
|
||||
globalMutateOptions?.onSuccess?.(data, variable, ctx);
|
||||
},
|
||||
});
|
||||
|
||||
const trackNativeQueryRelationship = async ({
|
||||
data: args,
|
||||
editDetails,
|
||||
...options
|
||||
}: {
|
||||
data: TrackNativeQueryRelationshipsProps;
|
||||
// these are the original name/type before edit.
|
||||
// we need these b/c if the user alters either one, we have to do some special handling
|
||||
editDetails?: { name: string; type: 'object' | 'array' };
|
||||
} & MetadataMigrationOptions) => {
|
||||
if (!driver || !originNativeQuery)
|
||||
throw Error('Driver/Native Query not found');
|
||||
|
||||
const { type, ...relationshipDetails } = args;
|
||||
|
||||
const nativeQueryPayload = new NativeQueryMigrationBuilder({
|
||||
driver,
|
||||
nativeQuery: originNativeQuery,
|
||||
dataSourceName,
|
||||
}).untrack();
|
||||
|
||||
if (editDetails) {
|
||||
// if editing, remove the relationship matching the original name and type prior to edit
|
||||
// if a user changed the type or name, we need the originals to make sure we are dropping the right object from the correct array of relationships
|
||||
nativeQueryPayload.removeRelationship(editDetails.type, editDetails.name);
|
||||
}
|
||||
|
||||
nativeQueryPayload
|
||||
// add a relationship to the native query with the details passed in
|
||||
.addRelationship(type, relationshipDetails)
|
||||
// add a track call
|
||||
.track();
|
||||
|
||||
mutate(
|
||||
{
|
||||
query: {
|
||||
resource_version,
|
||||
type: `bulk_atomic`,
|
||||
args: nativeQueryPayload.payload(),
|
||||
},
|
||||
},
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
const untrackNativeQueryRelationship = async ({
|
||||
data: { name: relationshipName, type },
|
||||
...options
|
||||
}: {
|
||||
data: Pick<TrackNativeQueryRelationshipsProps, 'name' | 'type'>;
|
||||
} & MetadataMigrationOptions) => {
|
||||
if (!driver || !originNativeQuery)
|
||||
throw Error('Driver/Native Query not found');
|
||||
|
||||
const relationship = originNativeQuery[`${type}_relationships`]?.find(
|
||||
n => n.name === relationshipName
|
||||
);
|
||||
|
||||
if (!relationship) {
|
||||
throw new Error('Unable to find relationship');
|
||||
}
|
||||
|
||||
const nativeQueryPayload = new NativeQueryMigrationBuilder({
|
||||
driver,
|
||||
nativeQuery: originNativeQuery,
|
||||
dataSourceName,
|
||||
})
|
||||
.untrack()
|
||||
.removeRelationship(type, relationshipName)
|
||||
.track();
|
||||
|
||||
mutate(
|
||||
{
|
||||
query: {
|
||||
resource_version,
|
||||
type: `bulk_atomic`,
|
||||
args: nativeQueryPayload.payload(),
|
||||
},
|
||||
},
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
trackNativeQueryRelationship,
|
||||
untrackNativeQueryRelationship,
|
||||
...rest,
|
||||
};
|
||||
};
|
@ -27,6 +27,12 @@ export const getSources = () => (m: Metadata) => m?.metadata.sources;
|
||||
export const findSource = (dataSourceName: string) => (m: Metadata) =>
|
||||
utils.findMetadataSource(dataSourceName, m);
|
||||
|
||||
export const findNativeQuery =
|
||||
(dataSourceName: string, nativeQueryName: string) => (m: Metadata) =>
|
||||
utils
|
||||
.findMetadataSource(dataSourceName, m)
|
||||
?.native_queries?.find(nq => nq.root_field_name === nativeQueryName);
|
||||
|
||||
export const getTables = (dataSourceName: string) => (m: Metadata) =>
|
||||
utils.findMetadataSource(dataSourceName, m)?.tables;
|
||||
|
||||
|
@ -4,6 +4,15 @@ export type NativeQueryArgument = {
|
||||
nullable?: boolean;
|
||||
};
|
||||
|
||||
export type NativeQueryRelationship = {
|
||||
name: string;
|
||||
using: {
|
||||
column_mapping: Record<string, string>;
|
||||
insertion_order: 'before_parent' | 'after_parent' | null;
|
||||
remote_native_query: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type NativeQuery = {
|
||||
root_field_name: string;
|
||||
code: string;
|
||||
@ -11,4 +20,6 @@ export type NativeQuery = {
|
||||
arguments?: Record<string, NativeQueryArgument>;
|
||||
type?: 'query' | 'mutation'; // only query supported for now
|
||||
comment?: string;
|
||||
object_relationships?: NativeQueryRelationship[];
|
||||
array_relationships?: NativeQueryRelationship[];
|
||||
};
|
||||
|
@ -112,3 +112,5 @@ export type BulkKeepGoingResponse = [
|
||||
path: string;
|
||||
}
|
||||
];
|
||||
|
||||
export { NativeQueryRelationship } from './nativeQuery';
|
||||
|
@ -113,6 +113,7 @@ export const metadataQueryTypes = [
|
||||
'untrack_tables',
|
||||
'track_stored_procedure',
|
||||
'untrack_stored_procedure',
|
||||
'bulk_atomic',
|
||||
] as const;
|
||||
|
||||
export type MetadataQueryType = (typeof metadataQueryTypes)[number];
|
||||
|
@ -70,3 +70,9 @@ export const cancelAlert = async (
|
||||
timeout: removalTimout,
|
||||
});
|
||||
};
|
||||
|
||||
export const useIsStorybook = () => {
|
||||
return {
|
||||
isStorybook: !!process.env.STORYBOOK,
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user