From 6028a93078939650dd6ef9e02489c59f1e689027 Mon Sep 17 00:00:00 2001 From: Varun Choudhary <68095256+Varun-Choudhary@users.noreply.github.com> Date: Wed, 28 Sep 2022 10:58:58 +0530 Subject: [PATCH] console: add `Try it` button on table view to try operations from graphiql tab PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6016 Co-authored-by: Daniele Cammareri <5709409+dancamma@users.noreply.github.com> GitOrigin-RevId: a26d5c6e3b61a9f12f8e2ff476091a8dcef9e696 --- .../Common/EditableHeading/EditableHeading.js | 21 ++- .../Common/EditableHeading/TryOperation.tsx | 140 ++++++++++++++ .../generateGqlQueryFromTable.test.tsx | 150 +++++++++++++++ .../Common/EditableHeading/utils.ts | 177 ++++++++++++++++++ .../Services/Data/TableCommon/TableHeader.js | 2 + .../EventTriggers/Common/AutoCleanupForm.tsx | 2 +- .../features/Data/ManageTable/ManageTable.tsx | 2 +- .../QueryCollectionHeaderMenu.tsx | 6 +- .../QuickAdd.tsx | 2 +- .../QueryCollectionOperationsHeader.tsx | 2 + .../DropdownButton/DropdownButton.tsx | 1 + .../DropdownMenu/DropdownMenu.tsx | 24 ++- console/src/utils/localStorage.ts | 1 + 13 files changed, 512 insertions(+), 18 deletions(-) create mode 100644 console/src/components/Common/EditableHeading/TryOperation.tsx create mode 100644 console/src/components/Common/EditableHeading/generateGqlQueryFromTable.test.tsx create mode 100644 console/src/components/Common/EditableHeading/utils.ts diff --git a/console/src/components/Common/EditableHeading/EditableHeading.js b/console/src/components/Common/EditableHeading/EditableHeading.js index a8b3564799d..1990c90bd63 100644 --- a/console/src/components/Common/EditableHeading/EditableHeading.js +++ b/console/src/components/Common/EditableHeading/EditableHeading.js @@ -1,6 +1,7 @@ import React from 'react'; import { FaEdit } from 'react-icons/fa'; import styles from '../Common.module.scss'; +import { TryOperation } from './TryOperation'; class Heading extends React.Component { state = { @@ -32,12 +33,25 @@ class Heading extends React.Component { }; render = () => { - const { editable, currentValue, save, loading, property } = this.props; - + const { + editable, + currentValue, + save, + loading, + property, + table, + dispatch, + source, + } = this.props; const { text, isEditting } = this.state; if (!editable) { - return

{currentValue}

; + return ( +
+

{currentValue}

+ +
+ ); } if (!save) { @@ -48,6 +62,7 @@ class Heading extends React.Component { return (

{currentValue}

+
{ + const { table, dispatch, source } = props; + const { data: dbInfo } = useMetadataSource(source); + const tryOnGQL = (queryType: OperationType) => () => { + const { query, variables } = generateGqlQueryFromTable(queryType, table); + setLSItem(LS_KEYS.graphiqlQuery, query); + setLSItem(LS_KEYS.graphiqlVariables, JSON.stringify(variables)); + dispatch(_push('/api/api-explorer')); + }; + + const isStreamingSubscriptionAvailable = !!getInitialValueField(table); + const isTryitButtonAvailable = + isEmpty(table?.configuration) && + isEmpty(dbInfo?.customization?.root_fields) && + isEmpty(dbInfo?.customization?.type_names) && + dbInfo?.customization?.naming_convention !== 'graphql-default'; + + const streamingSubscripton = ( +
+ Streaming Subscription + + New + +
+ +
+
+ ); + if (isTryitButtonAvailable) { + return ( + + Try it in GraphiQL + , +
+ Query +
+ +
+
, +
+ Mutation +
+ +
+
, +
+ Subscription +
+ +
+
, + isStreamingSubscriptionAvailable ? ( + streamingSubscripton + ) : ( + + {streamingSubscripton} + + ), + ], + ]} + > + + Try it +
+ ); + } + return ( + + + + Try it + + + ); +}; diff --git a/console/src/components/Common/EditableHeading/generateGqlQueryFromTable.test.tsx b/console/src/components/Common/EditableHeading/generateGqlQueryFromTable.test.tsx new file mode 100644 index 00000000000..392e6793607 --- /dev/null +++ b/console/src/components/Common/EditableHeading/generateGqlQueryFromTable.test.tsx @@ -0,0 +1,150 @@ +import { setupServer } from 'msw/node'; +import { generateGqlQueryFromTable } from './utils'; +import { handlers } from '../../../features/PermissionsForm/mocks/handlers.mock'; +import { Table } from '../../../dataSources/types'; + +const table: Table = { + table_name: 'test', + table_schema: 'public', + table_type: 'TABLE', + columns: [ + { + column_default: "nextval('test_id_seq'::regclass)", + column_name: 'id', + comment: null, + data_type: 'integer', + data_type_name: 'int4', + identity_generation: null, + is_generated: false, + is_identity: false, + is_nullable: 'NO', + ordinal_position: 1, + table_name: 'test', + table_schema: 'public', + }, + { + column_default: null, + column_name: 'name', + comment: null, + data_type: 'text', + data_type_name: 'text', + identity_generation: null, + is_generated: false, + is_identity: false, + is_nullable: 'YES', + ordinal_position: 2, + table_name: 'test', + table_schema: 'public', + }, + ], + comment: null, + primary_key: { + columns: ['id'], + constraint_name: 'test_pkey', + table_name: 'test', + table_schema: 'public', + }, + is_table_tracked: true, + relationships: [], + remote_relationships: [], + view_info: null, + unique_constraints: [], + permissions: [], + opp_foreign_key_constraints: [], + foreign_key_constraints: [], + check_constraints: [], + computed_fields: [], + is_enum: false, +}; + +const server = setupServer(); + +beforeAll(() => server.listen()); +afterAll(() => server.close()); + +describe('generateGqlQueryFromTable', () => { + beforeEach(() => { + server.use(...handlers()); + }); + + test('When generateGqlQueryFromTable is used with a query and table Then it should genrate a operation', async () => { + const operationType = 'query'; + const { query, variables } = generateGqlQueryFromTable( + operationType, + table + ); + + // need snapshot to exactly match the query + expect(query).toMatchInlineSnapshot(` + "query GetTest { + test { + id + name + } + } + " + `); + expect(variables).toBe(undefined); + }); + test('When generateGqlQueryFromTable is used with a mutation and table Then it should genrate a operation', async () => { + const operationType = 'mutation'; + const { query, variables } = generateGqlQueryFromTable( + operationType, + table + ); + + // need snapshot to exactly match the mutation + expect(query).toMatchInlineSnapshot(` + "mutation InsertTest($name: String) { + insert_test(objects: {name: $name}) { + affected_rows + returning { + id + name + } + } + } + " + `); + expect(variables).toEqual({ name: '' }); + }); + test('When generateGqlQueryFromTable is used with a streaming subscription and table Then it should genrate a operation', async () => { + const operationType = 'streaming_subscription'; + const { query, variables } = generateGqlQueryFromTable( + operationType, + table + ); + + // need snapshot to exactly match the streaming_subscription + expect(query).toMatchInlineSnapshot(` + "subscription GetTestStreamingSubscription { + test_stream(batch_size: 10, cursor: {initial_value: {id: 0}}) { + id + name + } + } + " + `); + expect(variables).toBe(undefined); + }); + test('When generateGqlQueryFromTable is used with a subscription and table Then it should genrate a operation', async () => { + const operationType = 'subscription'; + const { query, variables } = generateGqlQueryFromTable( + operationType, + table + ); + + // need snapshot to exactly match the subscription + expect(query).toMatchInlineSnapshot(` + "subscription GetTestStreamingSubscription { + test { + id + name + } + } + + " + `); + expect(variables).toBe(undefined); + }); +}); diff --git a/console/src/components/Common/EditableHeading/utils.ts b/console/src/components/Common/EditableHeading/utils.ts new file mode 100644 index 00000000000..fecc7481219 --- /dev/null +++ b/console/src/components/Common/EditableHeading/utils.ts @@ -0,0 +1,177 @@ +import { Table, TableColumn } from '@/dataSources/types'; +import moment from 'moment'; +import { capitaliseFirstLetter } from '../ConfigureTransformation/utils'; + +const indentFields = (subFields: TableColumn[], depth: number) => + subFields + .map(sf => sf.column_name) + .join(`\n${Array(depth).fill('\t').join('')}`) + .trim(); + +const toPascalCase = (str: string) => + str.split(/[-_]/).map(capitaliseFirstLetter).join(''); + +const subFieldsTypeMapping: Record< + string, + { type: string; default: () => unknown } +> = { + integer: { + type: 'Int', + default: () => 0, + }, + text: { + type: 'String', + default: () => '', + }, + boolean: { + type: 'Boolean', + default: () => true, + }, + numeric: { + type: 'Float', + default: () => 0, + }, + + 'timestamp with time zone': { + type: 'String', + default: () => moment().format(), + }, + 'time with time zone': { + type: 'String', + default: () => moment().format('HH:mm:ss.SSSSSSZZ'), + }, + date: { + type: 'String', + default: () => moment().format('YYYY-MM-DD'), + }, + uuid: { + type: 'String', + default: () => '00000000-0000-0000-0000-000000000000', + }, + jsonb: { + type: 'String', + default: () => '{}', + }, + bigint: { + type: 'Int', + default: () => 0, + }, +}; + +export const getInitialValueField = (table: Table): TableColumn | undefined => + table.columns.find( + f => + (table.primary_key?.columns || []).includes(f.column_name) && + ['bigint', 'integer'].includes(f.data_type) + ) ?? + table.columns.find(f => + ['timestamp with time zone', 'time with time zone', 'date'].includes( + f.data_type + ) + ); + +export type OperationType = + | 'subscription' + | 'query' + | 'mutation' + | 'streaming_subscription'; + +export const generateGqlQueryFromTable = ( + operationType: OperationType = 'subscription', + table: Table +): { + query: string; + variables?: Record; +} => { + const fieldName = table.table_name; + const fields = table.columns; + const pascalCaseName = toPascalCase(fieldName); + + if (operationType === 'query') { + const query = `query Get${pascalCaseName} { + ${fieldName} { + ${indentFields(fields, 2)} + } +} + `; + return { + query, + }; + } + + if (operationType === 'mutation') { + const mandatoryFields = fields.filter( + sf => !sf.column_default && subFieldsTypeMapping[sf.data_type] + ); + const args = mandatoryFields + .map( + sf => `$${sf.column_name}: ${subFieldsTypeMapping[sf.data_type].type}` + ) + .join(', ') + .trim(); + const argsUsage = mandatoryFields + .map(sf => `${sf.column_name}: $${sf.column_name}`) + .join(', ') + .trim(); + const query = `mutation Insert${pascalCaseName}(${args}) { + insert_${fieldName}(objects: {${argsUsage}}) { + affected_rows + returning { + ${indentFields(fields, 3)} + } + } +} + `; + + const variables = mandatoryFields.reduce( + (acc, sf) => ({ + ...acc, + [sf.column_name]: subFieldsTypeMapping[sf.data_type].default(), + }), + {} + ); + + return { + query, + variables, + }; + } + + if (operationType === 'streaming_subscription') { + const initialValueColumn = getInitialValueField(table); + if (initialValueColumn) { + const initialValue = JSON.stringify( + subFieldsTypeMapping[initialValueColumn.data_type]?.default() + ); + + const query = `subscription Get${pascalCaseName}StreamingSubscription { + ${fieldName}_stream(batch_size: 10, cursor: {initial_value: {${ + initialValueColumn.column_name + }: ${initialValue}}}) { + ${indentFields(fields, 2)} + } +} + `; + return { + query, + }; + } + } + + if (operationType === 'subscription') { + const query = `subscription Get${pascalCaseName}StreamingSubscription { + ${fieldName} { + ${indentFields(fields, 2)} + } +} + + `; + return { + query, + }; + } + + return { + query: '', + }; +}; diff --git a/console/src/components/Services/Data/TableCommon/TableHeader.js b/console/src/components/Services/Data/TableCommon/TableHeader.js index 677aeebc79c..4deb8a76640 100644 --- a/console/src/components/Services/Data/TableCommon/TableHeader.js +++ b/console/src/components/Services/Data/TableCommon/TableHeader.js @@ -105,6 +105,8 @@ const TableHeader = ({ } dispatch={dispatch} property={isTableType ? 'table' : 'view'} + table={table} + source={source} />
    diff --git a/console/src/components/Services/Events/EventTriggers/Common/AutoCleanupForm.tsx b/console/src/components/Services/Events/EventTriggers/Common/AutoCleanupForm.tsx index 6a85aab1355..77f622bbb2c 100644 --- a/console/src/components/Services/Events/EventTriggers/Common/AutoCleanupForm.tsx +++ b/console/src/components/Services/Events/EventTriggers/Common/AutoCleanupForm.tsx @@ -97,7 +97,7 @@ export const AutoCleanupForm = (props: AutoCleanupFormProps) => { schedule: cron.value, }); }} - className="cursor-pointer mx-1 px-xs py-1 rounded hover:bg-gray-100" + className="py-xs cursor-pointer mx-1 px-xs py-1 rounded hover:bg-gray-100" >

    {cron.label} diff --git a/console/src/features/Data/ManageTable/ManageTable.tsx b/console/src/features/Data/ManageTable/ManageTable.tsx index 4b47049c253..e70738e1e39 100644 --- a/console/src/features/Data/ManageTable/ManageTable.tsx +++ b/console/src/features/Data/ManageTable/ManageTable.tsx @@ -93,7 +93,7 @@ export const ManageTable = (props: ManageTableProps) => { items={[ [ // TODO: To be implemented after metadata util functions have been added to the metadata library - {}}> + {}}> Untrack {tableName} , ], diff --git a/console/src/features/QueryCollections/components/QueryCollectionHeader/QueryCollectionHeaderMenu.tsx b/console/src/features/QueryCollections/components/QueryCollectionHeader/QueryCollectionHeaderMenu.tsx index ec621c49b1e..beed792b6a1 100644 --- a/console/src/features/QueryCollections/components/QueryCollectionHeader/QueryCollectionHeaderMenu.tsx +++ b/console/src/features/QueryCollections/components/QueryCollectionHeader/QueryCollectionHeaderMenu.tsx @@ -37,7 +37,7 @@ export const QueryCollectionHeaderMenu: React.FC items={[ [

    { // this is a workaround for a weird but caused by interaction of radix ui dialog and dropdown menu setTimeout(() => { @@ -51,7 +51,7 @@ export const QueryCollectionHeaderMenu: React.FC entry => entry.collection === queryCollection.name ) ? (
    { removeFromAllowList(queryCollection.name, { onSuccess: () => { @@ -101,7 +101,7 @@ export const QueryCollectionHeaderMenu: React.FC ], [
    { const confirmMessage = `This will permanently delete the query collection "${queryCollection.name}"`; const isOk = getConfirmation( diff --git a/console/src/features/QueryCollections/components/QueryCollectionOperationDialog/QuickAdd.tsx b/console/src/features/QueryCollections/components/QueryCollectionOperationDialog/QuickAdd.tsx index 6e9a54c0cee..b729864ec4a 100644 --- a/console/src/features/QueryCollections/components/QueryCollectionOperationDialog/QuickAdd.tsx +++ b/console/src/features/QueryCollections/components/QueryCollectionOperationDialog/QuickAdd.tsx @@ -138,7 +138,7 @@ export const QuickAdd = (props: QuickAddProps) => {
    onAdd(operation)} - className="cursor-pointer mx-1 px-xs py-1 rounded hover:bg-gray-100" + className="cursor-pointer mx-1 px-xs py-xs rounded hover:bg-gray-100" >

    {operation.name} diff --git a/console/src/features/QueryCollections/components/QueryCollectionOperations/QueryCollectionOperationsHeader.tsx b/console/src/features/QueryCollections/components/QueryCollectionOperations/QueryCollectionOperationsHeader.tsx index 145d40cdeaf..672ab1e1825 100644 --- a/console/src/features/QueryCollections/components/QueryCollectionOperations/QueryCollectionOperationsHeader.tsx +++ b/console/src/features/QueryCollections/components/QueryCollectionOperations/QueryCollectionOperationsHeader.tsx @@ -62,6 +62,7 @@ export const QueryCollectionsOperationsHeader: React.FC (

    moveOperationToQueryCollection( collectionName, @@ -105,6 +106,7 @@ export const QueryCollectionsOperationsHeader: React.FC (
    addOperationToQueryCollection( diff --git a/console/src/new-components/DropdownButton/DropdownButton.tsx b/console/src/new-components/DropdownButton/DropdownButton.tsx index ba199333b45..79376bba305 100644 --- a/console/src/new-components/DropdownButton/DropdownButton.tsx +++ b/console/src/new-components/DropdownButton/DropdownButton.tsx @@ -18,6 +18,7 @@ export const DropdownButton: React.FC = ({ } {...rest} + size="sm" /> ); diff --git a/console/src/new-components/DropdownMenu/DropdownMenu.tsx b/console/src/new-components/DropdownMenu/DropdownMenu.tsx index b6a991cf5b1..45da3cb05c7 100644 --- a/console/src/new-components/DropdownMenu/DropdownMenu.tsx +++ b/console/src/new-components/DropdownMenu/DropdownMenu.tsx @@ -63,15 +63,21 @@ export const DropdownMenu: React.FC = ({ {children} - - {items.map(group => ( - - {group.map(item => ( - {item} - ))} - - ))} - + +
    + {items.map(group => ( +
    + {group.map(item => ( + +
    + {item} +
    +
    + ))} +
    + ))} +
    +
    ); diff --git a/console/src/utils/localStorage.ts b/console/src/utils/localStorage.ts index 9daaa21d652..ecdb4f79dff 100644 --- a/console/src/utils/localStorage.ts +++ b/console/src/utils/localStorage.ts @@ -88,6 +88,7 @@ export const LS_KEYS = { dataPageSizeKey: 'data:pageSize', derivedActions: 'actions:derivedActions', graphiqlQuery: 'graphiql:query', + graphiqlVariables: 'graphiql:variables', loveConsent: 'console:loveIcon', oneGraphExplorerCodeExporterOpen: 'graphiql:codeExporterOpen', oneGraphExplorerOpen: 'graphiql:explorerOpen',