diff --git a/frontend/.gitignore b/frontend/.gitignore index a1d3215deb9..5d8c460c7ac 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -42,6 +42,7 @@ Thumbs.db # Env .env +.serve.env npm-debug.log* yarn-debug.log* diff --git a/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js b/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js index fbaa985e18a..b3f55c8932d 100644 --- a/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js +++ b/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js @@ -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 = ( - + @@ -104,11 +99,16 @@ const makeDataRouter = ( /> + + + + + diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.stories.tsx index 25e671a1bc2..9b475b62a14 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.stories.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.stories.tsx @@ -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; @@ -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 = { 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 }) => { diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.tsx index 6e7181796be..46d5161faf0 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/AddNativeQuery.tsx @@ -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; - 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 ( -
- {mode === 'update' && ( - - 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. - - )} -
-
- {/* */} -
- - - ({ - label: m.name, - value: m.name, - }))} - /> - -
- {isLogicalModelsDialogOpen ? ( - { - setIsLogicalModelsDialogOpen(false); - }} - onSubmit={() => { - setIsLogicalModelsDialogOpen(false); - }} - asDialog - /> - ) : null} -
- {/* - 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 - */} - {/* */} - -
-
-
-
+ onClose: ({ confirmed }) => { + if (confirmed) push('/data/native-queries'); + }, + }); + } else { + push('/data/native-queries'); + } + }} + > + Cancel + + + + ); }; + +// 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 = 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, + }; +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/NativeQueryLandingPage.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/NativeQueryLandingPage.tsx new file mode 100644 index 00000000000..f075637b746 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/NativeQueryLandingPage.tsx @@ -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 ( + + Native Query {nativeQueryName} not found in {dataSourceName} + + ); + } + + const relationshipsCount = + (nativeQuery.array_relationships?.length ?? 0) + + (nativeQuery.object_relationships?.length ?? 0); + + return ( + + + push( + injectRouteDetails(route, { + itemName: nativeQuery.root_field_name, + itemSourceName: dataSourceName, + itemTabName: tab, + }) + ) + } + items={[ + { + content: ( + + ), + label: 'Details', + value: 'details', + }, + { + content: ( + + ), + label: ( +
+ Relationships + + {relationshipsCount} + +
+ ), + value: 'relationships', + }, + ]} + /> +
+ ); +}); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/UpdateNativeQuery.route.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/UpdateNativeQuery.route.tsx deleted file mode 100644 index aa1d1b07bd0..00000000000 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/UpdateNativeQuery.route.tsx +++ /dev/null @@ -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 ( - - Unable to parse data from url. - - ); - } - - 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 ( - - Native Query {name} not found in {source} - - ); - } - - return ( - - {isLoading ? ( -
- -
- ) : ( - - )} -
- ); -}); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/components/NativeQueryDetailsForm.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/components/NativeQueryDetailsForm.tsx new file mode 100644 index 00000000000..5301b543814 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/components/NativeQueryDetailsForm.tsx @@ -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(); + 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 ( + <> +
+ + + ({ + label: m.name, + value: m.name, + }))} + /> + +
+ {isLogicalModelsDialogOpen ? ( + { + setIsLogicalModelsDialogOpen(false); + }} + onSubmit={data => { + if (data.dataSourceName !== selectedSource) { + setValue('source', data.dataSourceName); + } + setIsLogicalModelsDialogOpen(false); + }} + asDialog + /> + ) : null} + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/index.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/index.ts index b30e68f341a..36df5b98bc0 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/index.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/index.ts @@ -1,3 +1,3 @@ export { AddNativeQueryRoute } from './AddNativeQuery.route'; export { AddNativeQuery } from './AddNativeQuery'; -export { UpdateNativeQueryRoute } from './UpdateNativeQuery.route'; +// export { UpdateNativeQueryRoute } from './UpdateNativeQuery.route'; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/mocks/native-query-handlers.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/mocks/native-query-handlers.ts index caad8a5fb96..984a0889af8 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/mocks/native-query-handlers.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/mocks/native-query-handlers.ts @@ -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 diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/schema.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/schema.ts index 9892b98eb9b..c4c4141ae88 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/schema.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/schema.ts @@ -24,4 +24,36 @@ export const schema = implement().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(), }); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/utils.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/utils.ts index e52ce0a0e20..43d5347221f 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/utils.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/AddNativeQuery/utils.ts @@ -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 { diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/LandingPage.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/LandingPage.tsx index be78ad3303f..b898c79f794 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/LandingPage.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/LandingPage.tsx @@ -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} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.tsx index 20ad887a414..3cd18c4e902 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.tsx @@ -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 }) => (
- + +
+ ); + + return ( +
+
+ + + {WidgetUI()} +
+ ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/ListNativeQueryRelationships.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/ListNativeQueryRelationships.stories.tsx new file mode 100644 index 00000000000..a24ada7c8f1 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/ListNativeQueryRelationships.stories.tsx @@ -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; + +export const DefaultView: StoryObj = { + args: { + dataSourceName: 'postgres', + nativeQueryName: 'customer_native_query', + }, + render: args => , +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/ListNativeQueryRelationships.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/ListNativeQueryRelationships.tsx new file mode 100644 index 00000000000..ae2d4e51598 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/ListNativeQueryRelationships.tsx @@ -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(); + +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(null); + + const columns = React.useMemo( + () => [ + columnHelper.accessor('name', { + id: 'name', + cell: data => {data.getValue()}, + header: 'Name', + }), + columnHelper.accessor('type', { + id: 'type', + cell: data => {data.getValue()}, + header: 'Type', + }), + columnHelper.display({ + id: 'actions', + cell: ({ row }) => ( +
+ + +
+ ), + header: 'Actions', + }), + ], + [onDeleteRow, onEditRow] + ); + + const relationshipsTable = useReactTable({ + data: nativeQueryRelationships, + columns: columns, + getCoreRowModel: getCoreRowModel(), + }); + + const NativeQueryRelationshipsTable = + useCardedTableFromReactTableWithRef(); + + if (isLoading) return ; + + return ( + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/NativeQueryRelationshipWidget.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/NativeQueryRelationshipWidget.tsx new file mode 100644 index 00000000000..fe1b199faad --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/NativeQueryRelationshipWidget.tsx @@ -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 ( + 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 ; + } + + if (status === 'error') { + return ( + + A metadata related error has occurred: +
+ {metadataError.message ?? JSON.stringify(metadataError, null, 2)} +
+
+ ); + } + + return ( +
+
{}}> + {fields()} + {!asDialog && ( +
+ +
+ )} +
+
+ ); + }; + + // for transparency, as of this comment, there are no !asDialog implementations of this component. + // see ../useWidget.tsx for implemenation + if (!asDialog) { + return body(); + } + + return ( + { + 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()} + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/TrackNativeQueryRelationshipForm.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/TrackNativeQueryRelationshipForm.stories.tsx new file mode 100644 index 00000000000..993ec0bd628 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/TrackNativeQueryRelationshipForm.stories.tsx @@ -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; + +export const DefaultView: StoryObj = { + render: () => { + return ( + { + console.log(data); + }} + > + + + + + ); + }, +}; + +// write more tests for this part diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/TrackNativeQueryRelationshipForm.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/TrackNativeQueryRelationshipForm.tsx new file mode 100644 index 00000000000..c1c739614f0 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/TrackNativeQueryRelationshipForm.tsx @@ -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 ( +
+ + + + +
+ ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/index.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/index.ts new file mode 100644 index 00000000000..bfca0dfa515 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/components/index.ts @@ -0,0 +1,3 @@ +export { ListNativeQueryRelationships } from './ListNativeQueryRelationships'; +export { NativeQueryRelationshipWidget } from './NativeQueryRelationshipWidget'; +export { TrackNativeQueryRelationshipForm } from './TrackNativeQueryRelationshipForm'; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/hooks/useWidget.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/hooks/useWidget.tsx new file mode 100644 index 00000000000..0873b566b0a --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/hooks/useWidget.tsx @@ -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('create'); + const [defaultValues, setDefaultValues] = + useState(); + + 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: ( + + There are no other native queries to create a relationship with. + Create another Native Queries on{' '} + {dataSourceName} and try again. + + ), + 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: ( +
+ There is already an existing {values.type}{' '} + relationship with the name {values.name}. Do you + want to overwrite this relationship? +
+ ), + confirmText: 'Overwrite', + destructive: true, + onClose: ({ confirmed }) => { + if (confirmed) doSubmit(); + }, + }); + } else { + doSubmit(); + } + }; + + const WidgetUI = () => ( + <> + {isOpen && ( + setIsOpen(false)} + /> + )} + + ); + + return { + WidgetUI, + closeWidget, + openCreate, + openEdit, + }; +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/schema.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/schema.ts new file mode 100644 index 00000000000..347083d68de --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/schema.ts @@ -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 +>; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/utils.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/NativeQueryRelationships/utils.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx index 193206ce6b9..13fc80ea4fb 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx @@ -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 = 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 (
- {/* */} -
- { - 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('/')}`); - }, - }; - })} - /> -
-
-
- {title.replace('{{name}}', itemName ?? '')} + > +
+ +
+
+
+ {injectRouteDetails(title, props)} +
+
{subtitleOverride ?? subtitle}
{subtitle}
{children}
-
- {/* */} +
); }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/route-wrapper-utils.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/route-wrapper-utils.tsx new file mode 100644 index 00000000000..7fe0584faa4 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/route-wrapper-utils.tsx @@ -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 +) => { + 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((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); + }, + }, + ]; + }, []); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts index 08de5846309..bca78e04e2c 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts @@ -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', diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/mocks/metadata.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/mocks/metadata.ts index 871c2cac7f2..c5108b2691b 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/mocks/metadata.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/mocks/metadata.ts @@ -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 = { 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', diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/hooks/useTrackNativeQuery.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/hooks/useTrackNativeQuery.ts index 8359fdcc985..d84f43cae70 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/hooks/useTrackNativeQuery.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/hooks/useTrackNativeQuery.ts @@ -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 diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/hooks/useTrackNativeQueryRelationships/useTrackNativeQueryRelationships.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/hooks/useTrackNativeQueryRelationships/useTrackNativeQueryRelationships.tsx new file mode 100644 index 00000000000..4eeb86c754a --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/hooks/useTrackNativeQueryRelationships/useTrackNativeQueryRelationships.tsx @@ -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; + } & 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, + }; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts index cc8dd0637d9..a57a11586b9 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts @@ -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; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/nativeQuery.ts b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/nativeQuery.ts index 365b76c67e9..b5b9e861a61 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/nativeQuery.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/nativeQuery.ts @@ -4,6 +4,15 @@ export type NativeQueryArgument = { nullable?: boolean; }; +export type NativeQueryRelationship = { + name: string; + using: { + column_mapping: Record; + 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; type?: 'query' | 'mutation'; // only query supported for now comment?: string; + object_relationships?: NativeQueryRelationship[]; + array_relationships?: NativeQueryRelationship[]; }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/source.ts b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/source.ts index 9487f7e1708..2080840f437 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/source.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/source.ts @@ -112,3 +112,5 @@ export type BulkKeepGoingResponse = [ path: string; } ]; + +export { NativeQueryRelationship } from './nativeQuery'; diff --git a/frontend/libs/console/legacy-ce/src/lib/metadata/queryUtils.ts b/frontend/libs/console/legacy-ce/src/lib/metadata/queryUtils.ts index d1867271391..7e19ad9d67d 100644 --- a/frontend/libs/console/legacy-ce/src/lib/metadata/queryUtils.ts +++ b/frontend/libs/console/legacy-ce/src/lib/metadata/queryUtils.ts @@ -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]; diff --git a/frontend/libs/console/legacy-ce/src/lib/utils/StoryUtils.tsx b/frontend/libs/console/legacy-ce/src/lib/utils/StoryUtils.tsx index c50e31215c3..68d6e4fe283 100644 --- a/frontend/libs/console/legacy-ce/src/lib/utils/StoryUtils.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/utils/StoryUtils.tsx @@ -70,3 +70,9 @@ export const cancelAlert = async ( timeout: removalTimout, }); }; + +export const useIsStorybook = () => { + return { + isStorybook: !!process.env.STORYBOOK, + }; +};