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
This commit is contained in:
Varun Choudhary 2022-09-28 10:58:58 +05:30 committed by hasura-bot
parent d03d86a5e7
commit 6028a93078
13 changed files with 512 additions and 18 deletions

View File

@ -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 <h2 className={styles.heading_text}>{currentValue}</h2>;
return (
<div className={styles.editable_heading_text}>
<h2>{currentValue}</h2>
<TryOperation table={table} dispatch={dispatch} source={source} />
</div>
);
}
if (!save) {
@ -48,6 +62,7 @@ class Heading extends React.Component {
return (
<div className={styles.editable_heading_text}>
<h2>{currentValue}</h2>
<TryOperation table={table} dispatch={dispatch} source={source} />
<div
onClick={this.toggleEditting}
className={styles.editable_heading_action}

View File

@ -0,0 +1,140 @@
import React from 'react';
import clsx from 'clsx';
import { Dispatch } from 'redux';
// eslint-disable-next-line import/no-extraneous-dependencies
import { isEmpty } from 'lodash';
import { Badge } from '@/new-components/Badge';
import { DropdownButton } from '@/new-components/DropdownButton';
import { FaArrowRight, FaFlask } from 'react-icons/fa';
import { LS_KEYS, setLSItem } from '@/utils/localStorage';
import { Tooltip } from '@/new-components/Tooltip';
import { useMetadataSource } from '@/features/MetadataAPI';
import {
getInitialValueField,
generateGqlQueryFromTable,
OperationType,
} from './utils';
import { Table } from '../../../dataSources/types';
import _push from '../../Services/Data/push';
type Props = {
table: Table;
dispatch: Dispatch;
source: string;
};
export const TryOperation = (props: Props) => {
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 = (
<div
onClick={
isStreamingSubscriptionAvailable
? tryOnGQL('streaming_subscription')
: undefined
}
data-trackid="data-tab-btn-try-streaming-subscriptions"
className={clsx(
'py-xs w-full flex justify-between align-center',
isStreamingSubscriptionAvailable
? 'cursor-pointer'
: 'cursor-not-allowed text-muted'
)}
>
Streaming Subscription
<Badge className="mx-2" color="yellow">
New
</Badge>
<div className="text-muted">
<FaArrowRight className="w-3 h-3" />
</div>
</div>
);
if (isTryitButtonAvailable) {
return (
<DropdownButton
items={[
[
<span className="py-xs text-xs font-semibold text-muted uppercase tracking-wider whitespace-nowrap">
Try it in GraphiQL
</span>,
<div
onClick={tryOnGQL('query')}
className="py-xs w-full flex justify-between align-center"
data-trackid="data-tab-btn-try-query"
>
Query
<div className="text-muted">
<FaArrowRight className="w-3 h-3" />
</div>
</div>,
<div
onClick={tryOnGQL('mutation')}
className="py-xs w-full flex justify-between align-center"
data-trackid="data-tab-btn-try-streaming-mutation"
>
Mutation
<div className="text-muted">
<FaArrowRight className="w-3 h-3" />
</div>
</div>,
<div
onClick={tryOnGQL('subscription')}
className="py-xs w-full flex justify-between align-center"
data-trackid="data-tab-btn-try-subscriptions"
>
Subscription
<div className="text-muted">
<FaArrowRight className="w-3 h-3" />
</div>
</div>,
isStreamingSubscriptionAvailable ? (
streamingSubscripton
) : (
<Tooltip
className="ml-0"
tooltipContentChildren="Streaming subscriptions are not available for this table because there is no valid field to use as the initial value."
>
{streamingSubscripton}
</Tooltip>
),
],
]}
>
<FaFlask className="mr-xs" />
Try it
</DropdownButton>
);
}
return (
<Tooltip
className="ml-0"
tooltipContentChildren='"Try it" is disabled for this table because it has customizations. Head to API Explorer to try out a query.'
>
<DropdownButton
items={[]}
className={clsx(
'py-xs w-full flex justify-between align-center cursor-not-allowed text-muted'
)}
onClick={undefined}
>
<FaFlask className="mr-xs" />
Try it
</DropdownButton>
</Tooltip>
);
};

View File

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

View File

@ -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<string, unknown>;
} => {
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: '',
};
};

View File

@ -105,6 +105,8 @@ const TableHeader = ({
}
dispatch={dispatch}
property={isTableType ? 'table' : 'view'}
table={table}
source={source}
/>
<div className={styles.nav}>
<ul className="nav nav-pills">

View File

@ -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"
>
<p className="mb-0 font-semibold whitespace-nowrap">
{cron.label}

View File

@ -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
<span className="text-red-600" onClick={() => {}}>
<span className="py-xs text-red-600" onClick={() => {}}>
Untrack {tableName}
</span>,
],

View File

@ -37,7 +37,7 @@ export const QueryCollectionHeaderMenu: React.FC<QueryCollectionHeaderMenuProps>
items={[
[
<div
className="font-semibold"
className="py-xs font-semibold"
onClick={() => {
// 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<QueryCollectionHeaderMenuProps>
entry => entry.collection === queryCollection.name
) ? (
<div
className="font-semibold"
className="py-xs font-semibold"
onClick={() => {
removeFromAllowList(queryCollection.name, {
onSuccess: () => {
@ -101,7 +101,7 @@ export const QueryCollectionHeaderMenu: React.FC<QueryCollectionHeaderMenuProps>
],
[
<div
className="font-semibold text-red-600"
className="py-xs font-semibold text-red-600"
onClick={() => {
const confirmMessage = `This will permanently delete the query collection "${queryCollection.name}"`;
const isOk = getConfirmation(

View File

@ -138,7 +138,7 @@ export const QuickAdd = (props: QuickAddProps) => {
<div
key={operation.name}
onClick={() => 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"
>
<p className="mb-0 font-semibold whitespace-nowrap">
{operation.name}

View File

@ -62,6 +62,7 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
items={[
otherCollections.map(collection => (
<div
className="py-xs"
onClick={() =>
moveOperationToQueryCollection(
collectionName,
@ -105,6 +106,7 @@ export const QueryCollectionsOperationsHeader: React.FC<QueryCollectionsOperatio
items={[
otherCollections.map(collection => (
<div
className="py-xs"
data-testid={`add-to-${collection.name}`}
onClick={() =>
addOperationToQueryCollection(

View File

@ -18,6 +18,7 @@ export const DropdownButton: React.FC<DropdownButtonProps> = ({
<FaChevronDown className="transition-transform group-radix-state-open:rotate-180 w-3 h-3" />
}
{...rest}
size="sm"
/>
</DropdownMenu>
);

View File

@ -63,15 +63,21 @@ export const DropdownMenu: React.FC<DropdownMenuProps> = ({
<DropdownMenuPrimitive.Root {...options?.root}>
<DropdownMenuTrigger {...options?.trigger}>{children}</DropdownMenuTrigger>
<DropdownMenuPrimitive.Portal {...options?.portal}>
<DropdownMenuContent {...options?.content}>
{items.map(group => (
<DropdownMenuPrimitive.Group className="">
{group.map(item => (
<DropdownMenuItem {...options?.item}>{item}</DropdownMenuItem>
))}
</DropdownMenuPrimitive.Group>
))}
</DropdownMenuContent>
<DropdownMenuPrimitive.Content align="start" {...options?.content}>
<div className="origin-top-left absolute left-0 z-10 mt-xs w-max max-w-xs rounded shadow-md bg-white ring-1 ring-gray-300 divide-y divide-gray-300 focus:outline-none">
{items.map(group => (
<div className="py-1">
{group.map(item => (
<DropdownMenuPrimitive.Item {...options?.item}>
<div className="cursor-pointer flex items-center mx-1 px-xs rounded whitespace-nowrap hover:bg-gray-100">
{item}
</div>
</DropdownMenuPrimitive.Item>
))}
</div>
))}
</div>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
);

View File

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