Add console support for Snowflake UDFs

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9268
Co-authored-by: Vijay Prasanna <11921040+vijayprasanna13@users.noreply.github.com>
GitOrigin-RevId: 046afcab867c3a91aea989bed92de894f4b67b16
This commit is contained in:
Luca Restagno 2023-05-25 16:03:35 +02:00 committed by hasura-bot
parent 39396c50b8
commit 5a2821bb01
32 changed files with 1194 additions and 159 deletions

View File

@ -39,6 +39,7 @@ import { ModifyTableContainer } from './TableModify/ModifyTableContainer';
import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage';
import { AddNativeQueryRoute } from '../../../features/Data/LogicalModels/AddNativeQuery/AddNativeQueryRoute';
import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidget.route';
import { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction';
const makeDataRouter = (
connect,
@ -66,6 +67,10 @@ const makeDataRouter = (
<IndexRedirect to="modify" />
<Route path=":operation" component={ManageTable} />
</Route>
<Route path="function" component={ManageFunction}>
<IndexRedirect to="modify" />
<Route path=":operation" component={ManageFunction} />
</Route>
<Route path="database" component={ManageDatabaseRoute} />
</Route>
<Route path="edit" component={Connect.EditConnection} />

View File

@ -30,6 +30,7 @@ export const useGDCTreeItemClick = (dispatch: Dispatch) => {
* Handling click for GDC DBs
*/
const isTableClicked = Object.keys(rest?.table || {}).length !== 0;
const isFunctionClicked = Object.keys(rest?.function || {}).length !== 0;
if (isTableClicked) {
dispatch(
_push(
@ -40,6 +41,8 @@ export const useGDCTreeItemClick = (dispatch: Dispatch) => {
)
)
);
} else if (isFunctionClicked) {
dispatch(_push(getRoute().function(database, rest.function)));
} else {
dispatch(_push(getRoute().database(database)));
}

View File

@ -14,18 +14,18 @@ export interface ManageDatabaseProps {
export const ManageDatabase = ({ dataSourceName }: ManageDatabaseProps) => {
const {
data: {
areFunctionsSupported = false,
areForeignKeysSupported = false,
areUserDefinedFunctionsSupported = false,
} = {},
} = useDriverCapabilities({
dataSourceName,
select: data => {
return {
areFunctionsSupported: !!get(data, 'functions'),
areForeignKeysSupported: !!get(
data,
'data_schema.supports_foreign_keys'
),
areUserDefinedFunctionsSupported: !!get(data, 'user_defined_functions'),
};
},
});
@ -60,7 +60,7 @@ export const ManageDatabase = ({ dataSourceName }: ManageDatabaseProps) => {
</CollapsibleResource>
)}
{areFunctionsSupported && (
{areUserDefinedFunctionsSupported && (
<CollapsibleResource
title="Untracked Custom Functions"
tooltip="Expose the functions available in your database via the GraphQL API"

View File

@ -0,0 +1,80 @@
import { QualifiedFunction } from '../../hasura-metadata-types';
import { Tabs } from '../../../new-components/Tabs';
import { getRoute } from '..';
import React from 'react';
import { useDispatch } from 'react-redux';
import _push from '../../../components/Services/Data/push';
import { useURLParameters } from './hooks/useUrlParameters';
import { Breadcrumbs } from './components/Breadcrumbs';
import { Heading } from './components/Heading';
import { Modify } from './components/Modify';
type AllowedTabs = 'modify';
export interface ManageFunctionProps {
params: {
operation: AllowedTabs;
};
}
type Tab = {
value: string;
label: string;
content: JSX.Element;
};
const availableTabs = (
dataSourceName: string,
qualifiedFunction: QualifiedFunction
): Tab[] => [
{
value: 'modify',
label: 'Modify',
content: (
<Modify
qualifiedFunction={qualifiedFunction}
dataSourceName={dataSourceName}
/>
),
},
];
export const ManageFunction: React.VFC<ManageFunctionProps> = (
props: ManageFunctionProps
) => {
const {
params: { operation },
} = props;
const urlData = useURLParameters(window.location);
const dispatch = useDispatch();
if (urlData.querystringParseResult === 'error')
throw Error('Unable to render');
const { database: dataSourceName, function: qualifiedFunction } =
urlData.data;
return (
<div className="w-full bg-gray-50">
<div className="px-md pt-md mb-xs">
<Breadcrumbs
dataSourceName={dataSourceName}
qualifiedFunction={qualifiedFunction}
/>
<Heading
dataSourceName={dataSourceName}
qualifiedFunction={qualifiedFunction}
/>
<Tabs
value={operation}
onValueChange={_operation => {
dispatch(
_push(getRoute().function(dataSourceName, qualifiedFunction))
);
}}
items={availableTabs(dataSourceName, qualifiedFunction)}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,24 @@
import React from 'react';
import { FaAngleRight, FaDatabase } from 'react-icons/fa';
import { FunctionDisplayName } from '../../TrackResources/TrackFunctions/components/FunctionDisplayName';
import { QualifiedFunction } from '../../../hasura-metadata-types';
export const Breadcrumbs: React.VFC<{
dataSourceName: string;
qualifiedFunction: QualifiedFunction;
}> = ({ dataSourceName, qualifiedFunction }) => (
<div className="flex items-center space-x-xs mb-4">
<div className="cursor-pointer flex items-center text-muted hover:text-gray-900">
<FaDatabase className="mr-1.5" />
<span className="text-sm">{dataSourceName}</span>
</div>
<FaAngleRight className="text-muted" />
<div className="cursor-pointer flex items-center text-muted hover:text-gray-900">
<FunctionDisplayName qualifiedFunction={qualifiedFunction} />
</div>
<FaAngleRight className="text-muted" />
<div className="cursor-pointer flex items-center">
<span className="text-sm font-semibold text-yellow-500">Manage</span>
</div>
</div>
);

View File

@ -0,0 +1,60 @@
import Skeleton from 'react-loading-skeleton';
import {
MetadataSelectors,
areTablesEqual,
useMetadata,
} from '../../../hasura-metadata-api';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { Badge } from '../../../../new-components/Badge';
import { TableDisplayName } from '../../ManageTable/components/TableDisplayName';
import { QualifiedFunction } from '../../../hasura-metadata-types';
export type DisplayConfigurationDetailsProps = {
dataSourceName: string;
qualifiedFunction: QualifiedFunction;
};
export const DisplayConfigurationDetails = (
props: DisplayConfigurationDetailsProps
) => {
const {
data: metadataFunction,
isLoading,
error,
} = useMetadata(m =>
MetadataSelectors.findSource(props.dataSourceName)(m)?.functions?.find(f =>
areTablesEqual(f.function, props.qualifiedFunction)
)
);
if (isLoading)
return (
<div className="mx-sm">
<Skeleton count={8} height={25} className="mb-2" />
</div>
);
if (!metadataFunction)
return (
<IndicatorCard
status="negative"
headline="Could not find function in metadata"
>
{JSON.stringify(error)}
</IndicatorCard>
);
return (
<div>
<div className="flex gap-4 mb-sm">
<b>Custom Name: </b>
{metadataFunction.configuration?.custom_name ?? <Badge>Not Set</Badge>}
</div>
<div className="flex gap-4">
<b>Return Type: </b>
<TableDisplayName
table={metadataFunction.configuration?.response?.table}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,81 @@
import { Badge } from '../../../../new-components/Badge';
import { Button } from '../../../../new-components/Button';
import { DropdownMenu } from '../../../../new-components/DropdownMenu';
import React from 'react';
import { FaChevronDown } from 'react-icons/fa';
import { QualifiedFunction } from '../../../hasura-metadata-types';
import { hasuraToast } from '../../../../new-components/Toasts';
import { useAppDispatch } from '../../../../storeHooks';
import { getRoute } from '../../../../utils/getDataRoute';
import _push from '../../../../components/Services/Data/push';
import { useTrackFunction } from '../../hooks/useTrackFunction';
import { DisplayToastErrorMessage } from '../../components/DisplayErrorMessage';
import { FunctionDisplayName } from '../../TrackResources/TrackFunctions/components/FunctionDisplayName';
export const Heading: React.VFC<{
dataSourceName: string;
qualifiedFunction: QualifiedFunction;
}> = ({ qualifiedFunction, dataSourceName }) => {
const dispatch = useAppDispatch();
const { untrackFunction } = useTrackFunction({
dataSourceName,
onSuccess: () => {
hasuraToast({
type: 'success',
title: 'Successfully untracked function',
});
dispatch(_push(getRoute().database(dataSourceName)));
},
onError: err => {
hasuraToast({
type: 'error',
title: 'Error while untracking table',
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
return (
<div className="flex items-center gap-3 mb-3">
<div className="group relative">
<div>
<DropdownMenu
items={[
[
<span
className="py-xs text-red-600"
onClick={() => {
untrackFunction({
functionsToBeUntracked: [qualifiedFunction],
});
}}
>
Untrack
</span>,
],
]}
>
<div className="flex gap-0.5 items-center">
<Button
iconPosition="end"
icon={
<FaChevronDown
size={12}
className="text-gray-400 text-sm transition-transform group-radix-state-open:rotate-180"
/>
}
>
<div className="flex flex-row items-center ">
<FunctionDisplayName qualifiedFunction={qualifiedFunction} />
</div>
</Button>
</div>
</DropdownMenu>
</div>
</div>
<div>
<Badge color="green">Tracked</Badge>
</div>
</div>
);
};

View File

@ -0,0 +1,48 @@
import { useState } from 'react';
import { Button } from '../../../../new-components/Button';
import { QualifiedFunction } from '../../../hasura-metadata-types';
import { ModifyFunctionConfiguration } from './ModifyFunctionConfiguration';
import { IconTooltip } from '../../../../new-components/Tooltip';
import { FaEdit, FaQuestionCircle } from 'react-icons/fa';
import { DisplayConfigurationDetails } from './DisplayConfigurationDetails';
export type ModifyProps = {
dataSourceName: string;
qualifiedFunction: QualifiedFunction;
};
export const Modify = (props: ModifyProps) => {
const [isEditConfigurationModalOpen, setIsEditConfigurationModalOpen] =
useState(false);
return (
<div className="py-4">
<div className="w-full bg-white p-4 rounded-sm border my-2">
<div className="flex gap-2 mb-sm items-center">
<div className="font-semibold text-2xl">Configuration</div>
<IconTooltip
message="allows you to customize any given function with a custom name and custom root fields of an already tracked function. This will replace the already present customization."
icon={<FaQuestionCircle />}
/>
</div>
<DisplayConfigurationDetails {...props} />
<div className="flex justify-end">
<Button
onClick={() => setIsEditConfigurationModalOpen(true)}
icon={<FaEdit />}
>
Edit Configuration
</Button>
</div>
{isEditConfigurationModalOpen && (
<ModifyFunctionConfiguration
{...props}
onSuccess={() => setIsEditConfigurationModalOpen(false)}
onClose={() => setIsEditConfigurationModalOpen(false)}
/>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,202 @@
import { z } from 'zod';
import { Dialog } from '../../../../new-components/Dialog';
import {
InputField,
Select,
useConsoleForm,
} from '../../../../new-components/Form';
import { QualifiedFunction } from '../../../hasura-metadata-types';
import { useEffect } from 'react';
import {
MetadataSelectors,
areTablesEqual,
useMetadata,
} from '../../../hasura-metadata-api';
import { getQualifiedTable } from '../../ManageTable/utils';
import { useSetFunctionConfiguration } from '../../hooks/useSetFunctionConfiguration';
import { hasuraToast } from '../../../../new-components/Toasts';
import { DisplayToastErrorMessage } from '../../components/DisplayErrorMessage';
import { cleanEmpty } from '../../../ConnectDBRedesign/components/ConnectPostgresWidget/utils/helpers';
import { Collapsible } from '../../../../new-components/Collapsible';
import { adaptFunctionName } from '../../TrackResources/TrackFunctions';
export type ModifyFunctionConfigurationProps = {
qualifiedFunction: QualifiedFunction;
dataSourceName: string;
onClose: () => void;
onSuccess: () => void;
};
const validationSchema = z.object({
custom_name: z.string().optional(),
custom_root_fields: z
.object({
function: z.string().optional(),
function_aggregate: z.string().optional(),
})
.optional(),
response: z
.object({
type: z.literal('table'),
table: z.string().min(1, { message: 'The return type is mandatory' }),
})
.optional(),
});
export type Schema = z.infer<typeof validationSchema>;
export const ModifyFunctionConfiguration = (
props: ModifyFunctionConfigurationProps
) => {
const { setFunctionConfiguration, isLoading } = useSetFunctionConfiguration({
dataSourceName: props.dataSourceName,
});
const { data: tableOptions = [] } = useMetadata(m =>
MetadataSelectors.findSource(props.dataSourceName)(m)?.tables.map(t => ({
label: getQualifiedTable(t.table).join(' / '),
value: JSON.stringify(t.table),
}))
);
const { data: metadataFunction, isFetched } = useMetadata(m =>
MetadataSelectors.findSource(props.dataSourceName)(m)?.functions?.find(f =>
areTablesEqual(f.function, props.qualifiedFunction)
)
);
const {
Form,
methods: { handleSubmit, reset, watch },
} = useConsoleForm({
schema: validationSchema,
});
useEffect(() => {
if (isFetched && metadataFunction) {
reset({
...metadataFunction.configuration,
response: {
type: metadataFunction.configuration?.response?.type ?? 'table',
table: JSON.stringify(
metadataFunction.configuration?.response?.table
),
},
});
}
}, [isFetched, metadataFunction, reset]);
const onHandleSubmit = (data: Schema) => {
setFunctionConfiguration({
qualifiedFunction: props.qualifiedFunction,
configuration: cleanEmpty({
...data,
response: {
...data.response,
table: data.response?.table ? JSON.parse(data.response?.table) : {},
},
}),
onSuccess: () => {
hasuraToast({
type: 'success',
title: 'Success',
message: `Updated successfully`,
});
props.onSuccess();
},
onError: err => {
hasuraToast({
type: 'error',
title: err.name,
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
};
const customName = watch('custom_name');
return (
<Dialog
hasBackdrop
title="Edit Function Configuration"
onClose={props.onClose}
footer={
<Dialog.Footer
onSubmit={() => {
handleSubmit(onHandleSubmit)();
}}
isLoading={isLoading}
onClose={props.onClose}
callToDeny="Cancel"
callToAction="Edit Configuration"
onSubmitAnalyticsName="actions-tab-generate-types-submit"
onCancelAnalyticsName="actions-tab-generate-types-cancel"
/>
}
>
<div className="p-4">
<Form
onSubmit={data => {
console.log('>>>', data);
}}
>
<InputField
name="custom_name"
label="Custom Name"
placeholder={adaptFunctionName(props.qualifiedFunction).join('_')}
clearButton
tooltip="The GraphQL nodes for the function will be generated according to the custom name"
/>
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">Custom Root Fields</div>
}
>
<InputField
name="custom_root_fields.function"
label="Function"
placeholder={
customName?.length
? customName
: adaptFunctionName(props.qualifiedFunction).join('_')
}
tooltip="Customize the <function-name> root field"
clearButton
/>
<InputField
name="custom_root_fields.function_aggregate"
label="Function Aggregate"
placeholder={`${
customName?.length
? customName
: adaptFunctionName(props.qualifiedFunction).join('_')
}_aggregate`}
tooltip="Customize the <function-name>_aggregate root field"
clearButton
/>
</Collapsible>
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">Response Settings</div>
}
defaultOpen
>
<div className="hidden">
<InputField name="response.type" label="type" />
</div>
<Select
label="Select a return type"
placeholder="Return type must be one of the tables tracked"
name="response.table"
options={tableOptions}
/>
</Collapsible>
</Form>
</div>
</Dialog>
);
};

View File

@ -0,0 +1,63 @@
import { QualifiedFunction } from '../../../hasura-metadata-types';
// TYPES
export type QueryStringParseResult =
| {
querystringParseResult: 'success';
data: FunctionDefinition;
}
| {
querystringParseResult: 'error';
errorType: 'invalidTableDefinition' | 'invalidDatabaseDefinition';
};
// TODO better types once GDC kicks in
type FunctionDefinition = { database: string; function?: QualifiedFunction };
// CONSTANTS
const FUNCTION_DEFINITION_SEARCH_KEY = 'function';
const DATASOURCE_DEFINITION_SEARCH_KEY = 'database';
const invalidTableDefinitionResult: QueryStringParseResult = {
querystringParseResult: 'error',
errorType: 'invalidTableDefinition',
};
const invalidDatabaseDefinitionResult: QueryStringParseResult = {
querystringParseResult: 'error',
errorType: 'invalidDatabaseDefinition',
};
// FUNCTION
const getFunctionDefinitionFromUrl = (
location: Location
): QueryStringParseResult => {
if (!location.search) return invalidTableDefinitionResult;
// if tableDefinition is present in query params;
// Idea is to use query params for GDC tables
const params = new URLSearchParams(location.search);
const qualifiedFunction = params.get(FUNCTION_DEFINITION_SEARCH_KEY);
const database = params.get(DATASOURCE_DEFINITION_SEARCH_KEY);
if (!database) {
return invalidDatabaseDefinitionResult;
}
if (!qualifiedFunction) {
return { querystringParseResult: 'success', data: { database } };
}
try {
return {
querystringParseResult: 'success',
data: { database, function: JSON.parse(qualifiedFunction) },
};
} catch (error) {
console.error('Unable to parse the function definition', error);
}
return invalidTableDefinitionResult;
};
export const useURLParameters = (location = window.location) => {
return getFunctionDefinitionFromUrl(location);
};

View File

@ -12,6 +12,8 @@ export const TableDisplayName = ({
dataSourceName?: string;
table: Table;
}) => {
if (!table) return null;
const tableName = getQualifiedTable(table);
const content = () => (
<>

View File

@ -1,5 +1,5 @@
import React from 'react';
import { FaAngleRight, FaDatabase } from 'react-icons/fa';
import { FaAngleRight, FaDatabase, FaTable } from 'react-icons/fa';
export const Breadcrumbs: React.VFC<{
dataSourceName: string;
@ -12,7 +12,7 @@ export const Breadcrumbs: React.VFC<{
</div>
<FaAngleRight className="text-muted" />
<div className="cursor-pointer flex items-center text-muted hover:text-gray-900">
<FaDatabase className="mr-1.5" />
<FaTable className="mr-1.5" />
<span className="text-sm">{tableName}</span>
</div>
<FaAngleRight className="text-muted" />

View File

@ -33,7 +33,7 @@ export const ManageTrackedFunctions = ({
return (
<TrackableResourceTabs
introText={
'Tracking tables adds them to your GraphQL API. All objects will be admin-only until permissions have been set.'
'Tracking functions adds them to your GraphQL API. All objects will be admin-only until permissions have been set.'
}
value={tab}
onValueChange={value => {

View File

@ -0,0 +1,179 @@
import { z } from 'zod';
import { Dialog } from '../../../../../new-components/Dialog';
import { Select, useConsoleForm } from '../../../../../new-components/Form';
import { AllowedFunctionTypes } from './UntrackedFunctions';
import { useUntrackedFunctions } from '../hooks/useUntrackedFunctions';
import { Feature } from '../../../../DataSource';
import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
import { adaptFunctionName } from '../utils';
import {
MetadataSelectors,
useMetadata,
} from '../../../../hasura-metadata-api';
import { getQualifiedTable } from '../../utils';
import { DisplayToastErrorMessage } from '../../../components/DisplayErrorMessage';
import { hasuraToast } from '../../../../../new-components/Toasts';
import { useTrackFunction } from '../../../hooks/useTrackFunction';
const validationSchema = z.object({
qualifiedFunction: z.any(),
table: z.string().min(1, { message: 'The return type is mandatory' }),
type: z.union([
z.literal('mutation'),
z.literal('query'),
z.literal('root_field'),
]),
});
export type TrackFunctionFormSchema = z.infer<typeof validationSchema>;
const allowedFunctionTypes: AllowedFunctionTypes[] = [
'mutation',
'query',
'root_field',
];
export type TrackFunctionFormProps = {
dataSourceName: string;
onSuccess: () => void;
onClose: () => void;
defaultValues?: TrackFunctionFormSchema;
};
export const TrackFunctionForm = (props: TrackFunctionFormProps) => {
const { data: untrackedFunctions = [] } = useUntrackedFunctions(
props.dataSourceName
);
const { trackFunction, isLoading: isTrackingInProgress } = useTrackFunction({
dataSourceName: props.dataSourceName,
onSuccess: () => {
hasuraToast({
type: 'success',
title: 'Success',
message: `Tracked object successfully`,
});
},
onError: err => {
hasuraToast({
type: 'error',
title: err.name,
children: <DisplayToastErrorMessage message={err.message} />,
});
},
});
const {
Form,
methods: {
handleSubmit,
formState: { errors },
},
} = useConsoleForm({
schema: validationSchema,
options: {
defaultValues: props.defaultValues,
},
});
const { data: tableOptions = [] } = useMetadata(m =>
MetadataSelectors.findSource(props.dataSourceName)(m)?.tables.map(t => ({
label: getQualifiedTable(t.table).join(' / '),
value: JSON.stringify(t.table),
}))
);
console.log(tableOptions);
if (untrackedFunctions === Feature.NotImplemented)
return (
<IndicatorCard headline="Feature is not implemented">
This feature is not available for {props.dataSourceName}
</IndicatorCard>
);
const onHandleSubmit = (data: TrackFunctionFormSchema) => {
console.log(data);
trackFunction({
functionsToBeTracked: [
{
function: JSON.parse(data.qualifiedFunction),
configuration: {
...(data.type !== 'root_field' ? { exposed_as: data.type } : {}),
response: {
type: 'table',
table: JSON.parse(data.table),
},
},
},
],
});
};
console.log(errors);
return (
<Dialog
hasBackdrop
title="Track Function"
onClose={props.onClose}
footer={
<Dialog.Footer
onSubmit={() => {
handleSubmit(onHandleSubmit)();
}}
isLoading={isTrackingInProgress}
onClose={props.onClose}
callToDeny="Cancel"
callToAction="Track Function"
onSubmitAnalyticsName="actions-tab-generate-types-submit"
onCancelAnalyticsName="actions-tab-generate-types-cancel"
/>
}
>
<div className="p-4">
<Form
onSubmit={data => {
console.log('>>>', data);
}}
>
<Select
label="Track Function"
name="qualifiedFunction"
placeholder="Select a function"
options={untrackedFunctions.map(f => ({
value: JSON.stringify(f.qualifiedFunction),
label: adaptFunctionName(f.qualifiedFunction).join(' / '),
}))}
disabled
/>
<Select
label="Tracked as"
name="type"
placeholder="Select type"
options={allowedFunctionTypes.map(type => ({
value: type,
label: type,
}))}
disabled
/>
<Select
label="Select a return type"
placeholder="Return type must be one of the tables tracked"
name="table"
options={tableOptions}
disabled={!tableOptions.length}
/>
{!tableOptions.length && (
<IndicatorCard headline="You do not have any tables tracked for this database">
Tables that are tracked in Hasura can be used as the return type
for your function.
</IndicatorCard>
)}
</Form>
</div>
</Dialog>
);
};

View File

@ -16,6 +16,8 @@ import { useTrackedFunctions } from '../hooks/useTrackedFunctions';
import { hasuraToast } from '../../../../../new-components/Toasts';
import { DisplayToastErrorMessage } from '../../../components/DisplayErrorMessage';
import { useTrackFunction } from '../../../hooks/useTrackFunction';
import { ModifyFunctionConfiguration } from '../../../ManageFunction/components/ModifyFunctionConfiguration';
import { FaEdit } from 'react-icons/fa';
export type TrackedFunctionsProps = {
dataSourceName: string;
@ -27,6 +29,9 @@ export const TrackedFunctions = (props: TrackedFunctionsProps) => {
const { data: trackedFunctions = [], isLoading } =
useTrackedFunctions(dataSourceName);
const [isConfigurationModalOpen, setIsConfigurationModalOpen] =
useState(false);
const [activeRow, setActiveRow] = useState<number | undefined>();
const functionsWithId = React.useMemo(() => {
@ -151,6 +156,22 @@ export const TrackedFunctions = (props: TrackedFunctionsProps) => {
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<div className="flex gap-2 justify-end">
{isConfigurationModalOpen ? (
<ModifyFunctionConfiguration
dataSourceName={dataSourceName}
qualifiedFunction={trackedFunction.qualifiedFunction}
onSuccess={() => setIsConfigurationModalOpen(false)}
onClose={() => setIsConfigurationModalOpen(false)}
/>
) : null}
<Button
onClick={() => {
setIsConfigurationModalOpen(true);
}}
icon={<FaEdit />}
>
Configure
</Button>
<Button
onClick={() => {
setActiveRow(index);

View File

@ -5,8 +5,12 @@ import { CardedTable } from '../../../../../new-components/CardedTable';
import { DropdownMenu } from '../../../../../new-components/DropdownMenu';
import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
import { LearnMoreLink } from '../../../../../new-components/LearnMoreLink';
import { Feature } from '../../../../DataSource';
import { useInvalidateMetadata } from '../../../../hasura-metadata-api';
import { Feature, nativeDrivers } from '../../../../DataSource';
import {
MetadataSelectors,
useInvalidateMetadata,
useMetadata,
} from '../../../../hasura-metadata-api';
import { FunctionDisplayName } from './FunctionDisplayName';
import React, { useState } from 'react';
@ -18,12 +22,16 @@ import { useTrackFunction } from '../../../hooks/useTrackFunction';
import { QualifiedFunction } from '../../../../hasura-metadata-types';
import { DisplayToastErrorMessage } from '../../../components/DisplayErrorMessage';
import { useHasuraAlert } from '../../../../../new-components/Alert';
import {
TrackFunctionForm,
TrackFunctionFormSchema,
} from './TrackFunctionForm';
export type UntrackedFunctionsProps = {
dataSourceName: string;
};
type AllowedFunctionTypes = 'mutation' | 'query' | 'root_field';
export type AllowedFunctionTypes = 'mutation' | 'query' | 'root_field';
export const UntrackedFunctions = (props: UntrackedFunctionsProps) => {
const { dataSourceName } = props;
@ -31,22 +39,33 @@ export const UntrackedFunctions = (props: UntrackedFunctionsProps) => {
const { data: untrackedFunctions = [], isLoading } =
useUntrackedFunctions(dataSourceName);
const functionsWithId = React.useMemo(() => {
if (Array.isArray(untrackedFunctions)) {
return untrackedFunctions.map(f => ({ ...f, id: f.name }));
} else {
return [];
}
}, [untrackedFunctions]);
const functionsWithId = React.useMemo(
() =>
Array.isArray(untrackedFunctions)
? untrackedFunctions.map(f => ({ ...f, id: f.name }))
: [],
[untrackedFunctions]
);
const invalidateMetadata = useInvalidateMetadata();
const [activeRow, setActiveRow] = useState<number | undefined>();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalFormDefaultValues, setModalFormDefaultValues] =
useState<TrackFunctionFormSchema>();
const [activeOperation, setActiveOperation] =
useState<AllowedFunctionTypes>();
const { hasuraConfirm } = useHasuraAlert();
const { data: driver = '' } = useMetadata(
m => MetadataSelectors.findSource(dataSourceName)(m)?.kind
);
const isNativeDriver = nativeDrivers.includes(driver);
const { trackFunction, isLoading: isTrackingInProgress } = useTrackFunction({
dataSourceName,
onSuccess: () => {
@ -92,6 +111,17 @@ export const UntrackedFunctions = (props: UntrackedFunctionsProps) => {
fn: QualifiedFunction,
type: AllowedFunctionTypes
) => {
// hack until capabilities or function schema can tell us if the function supports return types
if (!isNativeDriver) {
setModalFormDefaultValues({
qualifiedFunction: JSON.stringify(fn),
type,
table: '',
});
setIsModalOpen(true);
return;
}
setActiveRow(index);
setActiveOperation(type);
trackFunction({
@ -115,125 +145,137 @@ export const UntrackedFunctions = (props: UntrackedFunctionsProps) => {
};
return (
<div className="space-y-4">
<TrackableListMenu
checkActionText={`Track Selected (${checkedItems.length})`}
isLoading={isLoading}
{...listProps}
/>
<CardedTable.Table>
<CardedTable.TableHead>
<CardedTable.TableHeadRow>
<CardedTable.TableHeadCell>Function</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>
<div className="float-right">
<DropdownMenu
items={[
[
<span
className="py-2"
onClick={() => invalidateMetadata()}
>
Refresh
</span>,
],
]}
options={{
content: {
alignOffset: -50,
avoidCollisions: false,
},
}}
>
<SlOptionsVertical />
</DropdownMenu>
</div>
</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{paginatedData.map((untrackedFunction, index) => (
<CardedTable.TableBodyRow>
<CardedTable.TableBodyCell>
<FunctionDisplayName
qualifiedFunction={untrackedFunction.qualifiedFunction}
/>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<div className="flex gap-2 justify-end">
{untrackedFunction.isVolatile ? (
<>
<div>
<div className="space-y-4">
<TrackableListMenu
checkActionText={`Track Selected (${checkedItems.length})`}
isLoading={isLoading}
{...listProps}
/>
<CardedTable.Table>
<CardedTable.TableHead>
<CardedTable.TableHeadRow>
<CardedTable.TableHeadCell>Function</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>
<div className="float-right">
<DropdownMenu
items={[
[
<span
className="py-2"
onClick={() => invalidateMetadata()}
>
Refresh
</span>,
],
]}
options={{
content: {
alignOffset: -50,
avoidCollisions: false,
},
}}
>
<SlOptionsVertical />
</DropdownMenu>
</div>
</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{paginatedData.map((untrackedFunction, index) => (
<CardedTable.TableBodyRow key={untrackedFunction.id}>
<CardedTable.TableBodyCell>
<FunctionDisplayName
qualifiedFunction={untrackedFunction.qualifiedFunction}
/>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<div className="flex gap-2 justify-end">
{untrackedFunction.isVolatile ? (
<>
<Button
onClick={() => {
handleTrack(
index,
untrackedFunction.qualifiedFunction,
'mutation'
);
}}
isLoading={
activeRow === index &&
isTrackingInProgress &&
activeOperation === 'mutation'
}
disabled={activeRow === index && isTrackingInProgress}
loadingText="Please wait..."
>
Track as Mutation
</Button>
<Button
onClick={() =>
hasuraConfirm({
message:
'Queries are supposed to be read only and as such recommended to be STABLE or IMMUTABLE',
title: `Confirm tracking ${untrackedFunction.name} as a query`,
onClose: ({ confirmed }) => {
if (confirmed)
handleTrack(
index,
untrackedFunction.qualifiedFunction,
'query'
);
},
})
}
isLoading={
activeRow === index &&
isTrackingInProgress &&
activeOperation === 'query'
}
disabled={activeRow === index && isTrackingInProgress}
loadingText="Please wait..."
>
Track as Query
</Button>
</>
) : (
<Button
onClick={() => {
handleTrack(
index,
untrackedFunction.qualifiedFunction,
'mutation'
'root_field'
);
}}
isLoading={
activeRow === index &&
isTrackingInProgress &&
activeOperation === 'mutation'
activeOperation === 'root_field'
}
disabled={activeRow === index && isTrackingInProgress}
loadingText="Please wait..."
>
Track as Mutation
Track as Root Field
</Button>
<Button
onClick={() =>
hasuraConfirm({
message:
'Queries are supposed to be read only and as such recommended to be STABLE or IMMUTABLE',
title: `Confirm tracking ${untrackedFunction.name} as a query`,
onClose: ({ confirmed }) => {
if (confirmed)
handleTrack(
index,
untrackedFunction.qualifiedFunction,
'query'
);
},
})
}
isLoading={
activeRow === index &&
isTrackingInProgress &&
activeOperation === 'query'
}
disabled={activeRow === index && isTrackingInProgress}
loadingText="Please wait..."
>
Track as Query
</Button>
</>
) : (
<Button
onClick={() => {
handleTrack(
index,
untrackedFunction.qualifiedFunction,
'root_field'
);
}}
isLoading={
activeRow === index &&
isTrackingInProgress &&
activeOperation === 'root_field'
}
disabled={activeRow === index && isTrackingInProgress}
loadingText="Please wait..."
>
Track as Root Field
</Button>
)}
</div>
</CardedTable.TableBodyCell>
</CardedTable.TableBodyRow>
))}
</CardedTable.TableBody>
</CardedTable.Table>
)}
</div>
</CardedTable.TableBodyCell>
</CardedTable.TableBodyRow>
))}
</CardedTable.TableBody>
</CardedTable.Table>
</div>
<div>
{isModalOpen ? (
<TrackFunctionForm
dataSourceName={dataSourceName}
onSuccess={() => setIsModalOpen(false)}
onClose={() => setIsModalOpen(false)}
defaultValues={modalFormDefaultValues}
/>
) : null}
</div>
</div>
);
};

View File

@ -55,8 +55,6 @@ export const useCheckRows = <T,>(
setCheckedIds([]);
};
console.log('state of checked', checkedIds);
React.useEffect(() => {
if (!checkboxRef.current) return;
checkboxRef.current.indeterminate = inputStatus === 'indeterminate';

View File

@ -1,22 +1,21 @@
import { UseQueryOptions, useQuery } from 'react-query';
import { DataSource, DriverCapability, Feature } from '../../DataSource';
import { DataSource, Feature } from '../../DataSource';
import { useMetadata } from '../../hasura-metadata-api';
import { useHttpClient } from '../../Network';
import { APIError } from '../../../hooks/error';
import { Capabilities } from '@hasura/dc-api-types';
type UseDatabaseCapabilitiesArgs = {
dataSourceName: string;
};
export const useDriverCapabilities = <
FinalResult = Feature | DriverCapability
>({
export const useDriverCapabilities = <FinalResult = Feature | Capabilities>({
dataSourceName,
select,
options = {},
}: UseDatabaseCapabilitiesArgs & {
select?: (data: Feature | DriverCapability) => FinalResult;
options?: UseQueryOptions<Feature | DriverCapability, APIError, FinalResult>;
select?: (data: Feature | Capabilities) => FinalResult;
options?: UseQueryOptions<Feature | Capabilities, APIError, FinalResult>;
}) => {
const httpClient = useHttpClient();
@ -24,7 +23,7 @@ export const useDriverCapabilities = <
m => m.metadata.sources.find(source => source.name === dataSourceName)?.kind
);
return useQuery<Feature | DriverCapability, APIError, FinalResult>(
return useQuery<Feature | Capabilities, APIError, FinalResult>(
[dataSourceName, 'capabilities'],
async () => {
const result =

View File

@ -0,0 +1,87 @@
import { useCallback } from 'react';
import {
MetadataFunction,
QualifiedFunction,
} from '../../hasura-metadata-types';
import {
MetadataMigrationOptions,
useMetadataMigration,
} from '../../MetadataAPI/hooks/useMetadataMigration';
import {
MetadataSelectors,
areTablesEqual,
useInvalidateMetadata,
useMetadata,
} from '../../hasura-metadata-api';
import { transformErrorResponse } from '../errorUtils';
export type MetadataFunctionPayload = {
function: QualifiedFunction;
configuration?: MetadataFunction['configuration'];
source: string;
comment?: string;
};
export const useSetFunctionConfiguration = ({
dataSourceName,
...globalMutateOptions
}: { dataSourceName: string } & MetadataMigrationOptions) => {
const invalidateMetadata = useInvalidateMetadata();
const { mutate, ...rest } = useMetadataMigration({
...globalMutateOptions,
onSuccess: (data, variables, ctx) => {
invalidateMetadata();
globalMutateOptions?.onSuccess?.(data, variables, ctx);
},
errorTransform: transformErrorResponse,
});
const { data: { driver, resource_version, functions = [] } = {} } =
useMetadata(m => ({
driver: MetadataSelectors.findSource(dataSourceName)(m)?.kind,
resource_version: m.resource_version,
functions: MetadataSelectors.findSource(dataSourceName)(m)?.functions,
}));
const setFunctionConfiguration = useCallback(
({
qualifiedFunction,
configuration,
...mutationOptions
}: {
qualifiedFunction: QualifiedFunction;
configuration: MetadataFunction['configuration'];
} & MetadataMigrationOptions) => {
const metadataFunction = functions.find(fn =>
areTablesEqual(fn.function, qualifiedFunction)
);
const payload = {
type: `${driver}_set_function_customization`,
args: {
source: dataSourceName,
function: metadataFunction?.function,
configuration,
},
};
mutate(
{
query: {
type: 'bulk',
source: dataSourceName,
resource_version,
args: [payload],
},
},
{
...mutationOptions,
}
);
},
[functions, driver, dataSourceName, mutate, resource_version]
);
return { setFunctionConfiguration, ...rest };
};

View File

@ -1,13 +1,13 @@
import { DriverCapability } from '../types';
import { Capabilities } from '@hasura/dc-api-types';
export const postgresCapabilities: DriverCapability = {
export const postgresCapabilities: Capabilities = {
mutations: {
insert: {},
update: {},
delete: {},
},
queries: {},
functions: {},
user_defined_functions: {},
data_schema: {
supports_foreign_keys: true,
},

View File

@ -16,6 +16,7 @@ export const defaultIntrospectionProps = {
getIsTableView: async () => Feature.NotImplemented,
getSupportedDataTypes: async () => Feature.NotImplemented,
getStoredProcedures: async () => Feature.NotImplemented,
getTrackableObjects: async () => Feature.NotImplemented,
};
export const defaultDatabaseProps: Database = {

View File

@ -12,6 +12,7 @@ import {
getSupportedOperators,
getFKRelationships,
getDriverCapabilities,
getTrackableObjects,
} from './introspection';
import { getTableRows } from './query';
@ -21,6 +22,7 @@ import { getTableRows } from './query';
* you'd have just the table name -> ["Album"] but in a db with schemas -> ["Public", "Album"].
*/
export type GDCTable = string[];
export type GDCFunction = string[];
export const gdc: Database = {
...defaultDatabaseProps,
@ -30,6 +32,7 @@ export const gdc: Database = {
getDatabaseConfiguration,
getDriverCapabilities,
getTrackableTables,
getTrackableObjects,
getDatabaseHierarchy: async () => Feature.NotImplemented,
getTableColumns,
getFKRelationships,

View File

@ -5,6 +5,7 @@ import { GDCTable } from '..';
import { exportMetadata } from '../../api';
import { GetTablesListAsTreeProps } from '../../types';
import { convertToTreeData } from './utils';
// import { QualifiedFunction } from '../../../hasura-metadata-types';
export const getTablesListAsTree = async ({
dataSourceName,
@ -24,6 +25,11 @@ export const getTablesListAsTree = async ({
return table.table as GDCTable;
});
const functions = (source?.functions ?? []).map(f => {
if (typeof f.function === 'string') return [f.function] as GDCTable;
return f.function as GDCTable;
});
return {
title: (
<div className="inline-block">
@ -37,6 +43,11 @@ export const getTablesListAsTree = async ({
),
key: JSON.stringify({ database: source.name }),
icon: <FaDatabase />,
children: tables.length ? convertToTreeData(tables, [], source.name) : [],
children: tables.length
? [
...convertToTreeData(tables, [], source.name),
...convertToTreeData(functions, [], source.name, 'function'),
]
: [],
};
};

View File

@ -0,0 +1,79 @@
import { Table } from '../../../hasura-metadata-types';
import { runMetadataQuery } from '../../api';
import { GetTrackableTablesProps, IntrospectedFunction } from '../../types';
type TrackableObjects = {
functions: {
name: unknown;
volatility: 'STABLE' | 'VOLATILE';
}[];
tables: {
name: Table;
}[];
};
export type GetTrackableObjectsResponse = {
tables: {
name: string;
table: Table;
type: string;
}[];
functions: IntrospectedFunction[];
};
const adaptName = (name: unknown): string => {
if (typeof name === 'string') {
return name;
}
if (Array.isArray(name)) {
return name.join('.');
}
throw Error('getTrackableObjects: name is not string nor array:' + name);
};
export type GetTrackableObjectsProps = Pick<
GetTrackableTablesProps,
'httpClient' | 'dataSourceName'
>;
export const getTrackableObjects = async ({
httpClient,
dataSourceName,
}: GetTrackableObjectsProps) => {
try {
const result = await runMetadataQuery<TrackableObjects>({
httpClient,
body: {
type: 'reference_get_source_trackables',
args: {
source: dataSourceName,
},
},
});
const tables = result.tables.map(({ name }) => {
/**
* Ideally each table is supposed to be GDCTable, but the server fix has not yet been merged to main.
* Right now it returns string as a table.
*/
return {
name: adaptName(name),
table: name,
type: 'BASE TABLE',
};
});
const functions: IntrospectedFunction[] = result.functions.map(fn => {
return {
name: adaptName(fn.name),
qualifiedFunction: fn.name,
isVolatile: fn.volatility === 'VOLATILE',
};
});
return { tables, functions };
} catch (error) {
throw new Error('Error fetching GDC trackable objects');
}
};

View File

@ -5,5 +5,6 @@ export { getTableColumns } from './getTableColumns';
export { getFKRelationships } from './getFKRelationships';
export { getTablesListAsTree } from './getTablesListAsTree';
export { getTrackableTables } from './getTrackableTables';
export { getTrackableObjects } from './getTrackableObjects';
export { convertToTreeData } from './utils';
export type { GetTableInfoResponse } from './types';

View File

@ -3,22 +3,35 @@ import { DataNode } from 'antd/lib/tree';
import React from 'react';
import { FaTable, FaFolder } from 'react-icons/fa';
import { TableColumn } from '../../types';
import { TbMathFunction } from 'react-icons/tb';
export function convertToTreeData(
tables: string[][],
key: string[],
dataSourceName: string
dataSourceName: string,
mode?: 'function'
): DataNode[] {
if (tables.length === 0) return [];
if (tables[0].length === 1) {
const leafNodes: DataNode[] = tables.map(table => {
return {
icon: <FaTable />,
key: JSON.stringify({
database: dataSourceName,
table: [...key, table[0]],
}),
icon:
mode === 'function' ? (
<TbMathFunction className="text-muted mr-xs" />
) : (
<FaTable />
),
key:
mode === 'function'
? JSON.stringify({
database: dataSourceName,
function: [...key, table[0]],
})
: JSON.stringify({
database: dataSourceName,
table: [...key, table[0]],
}),
title: table[0],
};
});

View File

@ -1,4 +1,4 @@
import { OpenApiSchema } from '@hasura/dc-api-types';
import { Capabilities, OpenApiSchema } from '@hasura/dc-api-types';
import { DataNode } from 'antd/lib/tree';
import { AxiosInstance } from 'axios';
import pickBy from 'lodash/pickBy';
@ -35,7 +35,6 @@ import type {
TableRow,
Version,
WhereClause,
DriverCapability,
StoredProcedure,
GetStoredProceduresProps,
} from './types';
@ -57,6 +56,11 @@ import {
import { getAllSourceKinds } from './common/getAllSourceKinds';
import { getTableName } from './common/getTableName';
import { ReleaseType } from './types';
import {
GetTrackableObjectsProps,
GetTrackableObjectsResponse,
} from './gdc/introspection/getTrackableObjects';
import { isObject } from '../../components/Common/utils/jsUtils';
export * from './common/utils';
export type { GDCTable } from './gdc';
@ -121,7 +125,7 @@ export type Database = {
getDriverCapabilities: (
httpClient: AxiosInstance,
driver?: string
) => Promise<DriverCapability | Feature>;
) => Promise<Capabilities | Feature>;
getTrackableTables: (
props: GetTrackableTablesProps
) => Promise<IntrospectedTable[] | Feature.NotImplemented>;
@ -141,6 +145,9 @@ export type Database = {
getTrackableFunctions: (
props: GetTrackableFunctionProps
) => Promise<IntrospectedFunction[] | Feature.NotImplemented>;
getTrackableObjects: (
props: GetTrackableObjectsProps
) => Promise<GetTrackableObjectsResponse | Feature.NotImplemented>;
getDatabaseSchemas: (
props: GetDatabaseSchemaProps
) => Promise<string[] | Feature.NotImplemented>;
@ -547,13 +554,33 @@ export const DataSource = (httpClient: AxiosInstance) => ({
);
},
getTrackableFunctions: async (dataSourceName: string) => {
const functions: IntrospectedFunction[] = [];
const database = await getDatabaseMethods({ dataSourceName, httpClient });
return (
database.introspection?.getTrackableFunctions({
const trackableFunctions =
(await database.introspection?.getTrackableFunctions({
dataSourceName,
httpClient,
}) ?? Feature.NotImplemented
);
})) ?? [];
if (Array.isArray(trackableFunctions)) {
functions.push(...trackableFunctions);
}
const getTrackableObjectsFn = database.introspection?.getTrackableObjects;
if (getTrackableObjectsFn) {
const trackableObjects = await getTrackableObjectsFn({
dataSourceName,
httpClient,
});
if (isObject(trackableObjects) && 'functions' in trackableObjects) {
functions.push(...trackableObjects.functions);
}
}
return functions;
},
getDatabaseSchemas: async ({
dataSourceName,

View File

@ -1,4 +1,3 @@
import { Capabilities } from '@hasura/dc-api-types';
import {
Legacy_SourceToRemoteSchemaRelationship,
LocalTableArrayRelationship,
@ -195,10 +194,6 @@ export type GetIsTableViewProps = {
httpClient: NetworkArgs['httpClient'];
};
export type DriverCapability = Capabilities & {
functions?: Record<string, any>;
};
export type StoredProcedure = unknown;
export type GetStoredProceduresProps = {
dataSourceName: string;

View File

@ -7,7 +7,7 @@ import {
} from './configuration';
import { LogicalModel } from './logicalModel';
import { NativeQuery } from './nativeQuery';
import { MetadataTable } from './table';
import { MetadataTable, Table } from './table';
import { StoredProcedure } from './storedProcedure';
export type NativeDrivers =
@ -48,6 +48,10 @@ export type MetadataFunction = {
};
session_argument?: string;
exposed_as?: 'mutation' | 'query';
response?: {
type: 'table';
table: Table;
};
};
};

View File

@ -1,4 +1,4 @@
import { Table } from '../features/hasura-metadata-types';
import { QualifiedFunction, Table } from '../features/hasura-metadata-types';
export const getRoute = () => ({
connectDatabase: (driver?: string) =>
@ -21,4 +21,11 @@ export const getRoute = () => ({
)}`
);
},
function: (dataSourceName: string, fn: QualifiedFunction) => {
return encodeURI(
`/data/v2/manage/function?database=${dataSourceName}&function=${JSON.stringify(
fn
)}`
);
},
});

View File

@ -36,7 +36,7 @@
"@babel/plugin-transform-runtime": "^7.14.5",
"@graphql-codegen/core": "^1.17.8",
"@graphql-codegen/typescript": "^1.17.10",
"@hasura/dc-api-types": "^0.30.0",
"@hasura/dc-api-types": "^0.32.0",
"@hookform/resolvers": "2.8.10",
"@radix-ui/colors": "^0.1.8",
"@radix-ui/react-alert-dialog": "^1.0.3",

View File

@ -3375,10 +3375,10 @@ __metadata:
languageName: node
linkType: hard
"@hasura/dc-api-types@npm:^0.30.0":
version: 0.30.0
resolution: "@hasura/dc-api-types@npm:0.30.0"
checksum: fbf0376dae3252cac85dd343d77faaf82d7e9100ce5c3a18822e87e7d35550bca0c110f901da766e4099698228eb3b7e25d10f4b305b454e1f64d9ad4c87ae2b
"@hasura/dc-api-types@npm:^0.32.0":
version: 0.32.0
resolution: "@hasura/dc-api-types@npm:0.32.0"
checksum: c95adc03c894cf737a0e1d366e5726f17156f13ec5d30be205dc705e2e0b55a0796d33603d8eee89926b1153503c171b461c4bace8475598bae7db33f94e0910
languageName: node
linkType: hard
@ -18392,7 +18392,7 @@ __metadata:
"@graphql-codegen/core": ^1.17.8
"@graphql-codegen/typescript": ^1.17.10
"@graphql-codegen/typescript-operations": ^2.5.5
"@hasura/dc-api-types": ^0.30.0
"@hasura/dc-api-types": ^0.32.0
"@hookform/devtools": 4.0.1
"@hookform/resolvers": 2.8.10
"@nrwl/cli": 15.8.1