pro-console: Make caching discoverable on console

### Description
Since caching is now enabled for free tier, the following changes are to be done to ensure caching is more discoverable:

Spec: https://docs.google.com/document/d/1Ic2I7wQYj_A8qJhRffboQPdDsozd463fy-wuvb1bERw/edit

### Affected components
- [x] Console

### Related Issues
#2340

### Solution and Design
1. Added a new Cache button to the GraphiQL toolbar
2. Added a GraphiQL footer to display response time and response size for queries
3. Removed 'Copy' and 'Voyager' buttons to make space for 'Cache' button

### Steps to test and verify
1. Click on the 'Cache' button on the GraphiQL toolbar to add @cached directive to your query which enables caching for query response.
2. The response time and response size details will be displayed in the footer below the query response
3. The Cache keyword with a success icon is displayed in the footer when response caching is enabled

### Screenshots
1. Queries without response caching
<img width="1440" alt="Screenshot 2021-09-15 at 11 09 23 AM" src="https://user-images.githubusercontent.com/59638722/133379080-9e4fa2ac-2a60-4030-9152-739564fd2419.png">

2. Response caching enabled
<img width="1440" alt="Screenshot 2021-09-15 at 11 09 50 AM" src="https://user-images.githubusercontent.com/59638722/133379089-3ddb321f-ae82-4d57-ab50-0284f151819c.png">

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2353
Co-authored-by: Rishichandra Wawhal <27274869+wawhal@users.noreply.github.com>
GitOrigin-RevId: abc7566085c8ed7e362a0abc2b3906c2c8974cee
This commit is contained in:
Aishwarya Rao 2021-10-19 14:36:20 +05:30 committed by hasura-bot
parent 7553ca170b
commit e2e740aa68
7 changed files with 220 additions and 43 deletions

View File

@ -46,6 +46,8 @@ const CREATE_WEBSOCKET_CLIENT = 'ApiExplorer/CREATE_WEBSOCKET_CLIENT';
const FOCUS_ROLE_HEADER = 'ApiExplorer/FOCUS_ROLE_HEADER';
const UNFOCUS_ROLE_HEADER = 'ApiExplorer/UNFOCUS_ROLE_HEADER';
const TRACK_RESPONSE_DETAILS = 'ApiExplorer/TRACK_RESPONSE_DETAILS';
let websocketSubscriptionClient;
const getSubscriptionInstance = (url, headers) => {
@ -216,16 +218,30 @@ const isSubscription = graphQlParams => {
return false;
};
const graphQLFetcherFinal = (graphQLParams, url, headers, dispatch) => {
const graphQLFetcherFinal = (
graphQLParams,
url,
headers,
dispatch,
requestTrackingId
) => {
if (isSubscription(graphQLParams)) {
return graphqlSubscriber(graphQLParams, url, headers);
}
return dispatch(
requestAction(url, {
method: 'POST',
headers: getHeadersAsJSON(headers),
body: JSON.stringify(graphQLParams),
})
requestAction(
url,
{
method: 'POST',
headers: getHeadersAsJSON([...headers]),
body: JSON.stringify(graphQLParams),
},
undefined,
undefined,
true,
false,
requestTrackingId
)
);
};
@ -460,6 +476,18 @@ const getRemoteQueries = (queryUrl, cb, dispatch) => {
.catch(e => console.error('Invalid query file URL: ', e));
};
const processResponseDetails = (
responseTime,
responseSize,
isResponseCached,
responseTrackingId
) => dispatch => {
dispatch({
type: TRACK_RESPONSE_DETAILS,
data: { responseTime, responseSize, isResponseCached, responseTrackingId },
});
};
const apiExplorerReducer = (state = defaultState, action) => {
switch (action.type) {
case CHANGE_TAB:
@ -675,6 +703,20 @@ const apiExplorerReducer = (state = defaultState, action) => {
...state,
loading: action.data,
};
case TRACK_RESPONSE_DETAILS:
return {
...state,
explorerData: {
...state.explorerData,
response: {
...state.explorerData.response,
responseTime: action.data.responseTime,
responseSize: action.data.responseSize,
isResponseCached: action.data.isResponseCached,
responseTrackingId: action.data.responseTrackingId,
},
},
};
default:
return state;
}
@ -707,4 +749,5 @@ export {
analyzeFetcher,
verifyJWTToken,
setHeadersBulk,
processResponseDetails,
};

View File

@ -609,6 +609,7 @@ class ApiRequest extends Component {
dispatch={this.props.dispatch}
headerFocus={this.props.headerFocus}
urlParams={this.props.urlParams}
response={this.props.explorerData.response}
/>
</div>
);

View File

@ -2150,3 +2150,37 @@ li.CodeMirror-hint-active {
.CodeMirror-hint-deprecation :last-child {
margin-bottom: 0;
}
.graphiql_footer {
display: flex;
align-items: center;
position: sticky;
bottom: 0;
width: 100%;
padding: 8px;
background: #eee;
z-index: 100;
}
.graphiql_footer_label {
font-size: 10px;
color: #777777;
font-weight: 600;
margin-right: 8px;
text-transform: uppercase;
}
.graphiql_footer_value {
margin-right: 16px;
font-size: 13px;
}
.graphiql_footer_icon {
color: #777;
margin-right: 8px;
margin-left: 0px;
}
.color_green {
color: #047857 !important;
}

View File

@ -11,19 +11,18 @@ import {
persistCodeExporterOpen,
} from '../OneGraphExplorer/utils';
import {
clearCodeMirrorHints,
setQueryVariableSectionHeight,
copyToClipboard,
} from './utils';
import { clearCodeMirrorHints, setQueryVariableSectionHeight } from './utils';
import { generateRandomString } from '../../../Services/Data/DataSources/CreateDataSource/Heroku/utils';
import { analyzeFetcher, graphQLFetcherFinal } from '../Actions';
import { parse as sdlParse, print } from 'graphql';
import deriveAction from '../../../../shared/utils/deriveAction';
import {
getActionDefinitionSdl,
getTypesSdl,
toggleCacheDirective,
} from '../../../../shared/utils/sdlUtils';
import { showErrorNotification } from '../../Common/Notification';
import ToolTip from '../../../Common/Tooltip/Tooltip';
import { getActionsCreateRoute } from '../../../Common/utils/routesUtils';
import { getConfirmation } from '../../../Common/utils/jsUtils';
import {
@ -33,6 +32,7 @@ import {
} from '../../Actions/Add/reducer';
import { getGraphQLEndpoint } from '../utils';
import snippets from './snippets';
import globals from '../../../../Globals';
import 'graphiql/graphiql.css';
import './GraphiQL.css';
@ -49,6 +49,7 @@ class GraphiQLWrapper extends Component {
onBoardingEnabled: false,
copyButtonText: 'Copy',
codeExporterOpen: false,
requestTrackingId: null,
};
}
@ -82,18 +83,29 @@ class GraphiQLWrapper extends Component {
mode,
loading,
} = this.props;
const { codeExporterOpen } = this.state;
const { codeExporterOpen, requestTrackingId } = this.state;
const graphqlNetworkData = this.props.data;
const {
responseTime,
responseSize,
isResponseCached,
responseTrackingId,
} = this.props.response;
const graphQLFetcher = graphQLParams => {
if (headerFocus) {
return null;
}
const trackingId = generateRandomString();
this.setState({ requestTrackingId: trackingId });
return graphQLFetcherFinal(
graphQLParams,
getGraphQLEndpoint(mode),
graphqlNetworkData.headers,
dispatch
dispatch,
trackingId
);
};
@ -111,20 +123,6 @@ class GraphiQLWrapper extends Component {
editor.setValue(prettyText);
};
const handleCopyQuery = () => {
const editor = graphiqlContext.getQueryEditor();
const query = editor.getValue();
if (!query) {
return;
}
copyToClipboard(query);
this.setState({ copyButtonText: 'Copied' });
setTimeout(() => {
this.setState({ copyButtonText: 'Copy' });
}, 1500);
};
const handleToggleHistory = () => {
graphiqlContext.setState(prevState => ({
historyPaneOpen: !prevState.historyPaneOpen,
@ -179,6 +177,42 @@ class GraphiQLWrapper extends Component {
dispatch(_push('/api/rest/create'));
};
const _toggleCacheDirective = () => {
const editor = graphiqlContext.getQueryEditor();
const operationString = editor.getValue();
const cacheToggledOperationString = toggleCacheDirective(operationString);
editor.setValue(cacheToggledOperationString);
};
const renderGraphiqlFooter = responseTime &&
responseTrackingId === requestTrackingId && (
<GraphiQL.Footer>
<div className="graphiql_footer">
<span className="graphiql_footer_label">Response Time</span>
<span className="graphiql_footer_value">{responseTime} ms</span>
{responseSize && (
<>
<span className="graphiql_footer_label">Response Size</span>
<span className="graphiql_footer_value">
{responseSize} bytes
</span>
</>
)}
{isResponseCached && (
<>
<span className="graphiql_footer_label">Cached</span>
<ToolTip
message="This query response was cached using the @cached directive"
placement="top"
tooltipStyle="graphiql_footer_icon"
/>
<i className="fa fa-check-circle color_green" />
</>
)}
</div>
</GraphiQL.Footer>
);
const renderGraphiql = graphiqlProps => {
const voyagerUrl = graphqlNetworkData.consoleUrl + '/voyager-view';
let analyzerProps = {};
@ -199,27 +233,22 @@ class GraphiQLWrapper extends Component {
title: 'Show History',
onClick: handleToggleHistory,
},
{
label: this.state.copyButtonText,
title: 'Copy Query',
onClick: handleCopyQuery,
},
{
label: 'Explorer',
title: 'Toggle Explorer',
onClick: graphiqlProps.toggleExplorer,
},
{
label: 'Cache',
title: 'Cache the response of this query',
onClick: _toggleCacheDirective,
hide: globals.consoleType !== 'cloud',
},
{
label: 'Code Exporter',
title: 'Toggle Code Exporter',
onClick: this._handleToggleCodeExporter,
},
{
label: 'Voyager',
title: 'GraphQL Voyager',
onClick: () => window.open(voyagerUrl, '_blank'),
icon: <i className="fa fa-external-link" aria-hidden="true" />,
},
{
label: 'REST',
title: 'REST Endpoints',
@ -233,9 +262,11 @@ class GraphiQLWrapper extends Component {
onClick: deriveActionFromOperation,
});
}
return buttons.map(b => {
return <GraphiQL.Button key={b.label} {...b} />;
});
return buttons
.filter(b => !b.hide)
.map(b => {
return <GraphiQL.Button key={b.label} {...b} />;
});
};
return (
@ -258,6 +289,7 @@ class GraphiQLWrapper extends Component {
{...analyzerProps}
/>
</GraphiQL.Toolbar>
{renderGraphiqlFooter}
</GraphiQL>
{codeExporterOpen ? (
<CodeExporter
@ -301,6 +333,7 @@ GraphiQLWrapper.propTypes = {
numberOfTables: PropTypes.number.isRequired,
headerFocus: PropTypes.bool.isRequired,
urlParams: PropTypes.object.isRequired,
response: PropTypes.object,
};
const mapStateToProps = state => ({

View File

@ -47,7 +47,10 @@ const dataApis = {
const explorerData = {
sendingRequest: false,
enableResponseSection: false,
response: {},
response: {
responseTime: null,
responseSize: null,
},
fileObj: null,
};

View File

@ -1,4 +1,5 @@
import { parse as sdlParse } from 'graphql/language/parser';
import { print as sdlPrint } from 'graphql/language/printer';
import { getAstTypeMetadata, wrapTypename } from './wrappingTypeUtils';
import {
reformCustomTypes,
@ -367,3 +368,46 @@ export const getSdlComplete = (allActions, allTypes) => {
sdl += getTypesSdl(allTypes);
return sdl;
};
export const toggleCacheDirective = operationString => {
let operationAst;
try {
operationAst = sdlParse(operationString);
} catch (e) {
console.error(e);
return;
}
const shouldAddCacheDirective = !operationAst.definitions.some(def => {
return def.directives.some(dir => dir.name.value === 'cached');
});
const newOperationAst = JSON.parse(JSON.stringify(operationAst));
newOperationAst.definitions = operationAst.definitions.map(def => {
if (def.kind === 'OperationDefinition' && def.operation === 'query') {
const newDef = {
...def,
directives: def.directives.filter(dir => dir.name.value !== 'cached'),
};
if (shouldAddCacheDirective) {
newDef.directives.push({
kind: 'Directive',
name: {
kind: 'Name',
value: 'cached',
},
});
}
return newDef;
}
return def;
});
try {
const newString = sdlPrint(newOperationAst);
return newString;
} catch {
throw new Error('cannot build the operation string');
}
};

View File

@ -11,6 +11,7 @@ import {
CONNECTION_FAILED,
} from '../components/App/Actions';
import { globalCookiePolicy } from '../Endpoints';
import { processResponseDetails } from '../components/Services/ApiExplorer/Actions';
const requestAction = <T = any>(
url: string,
@ -18,7 +19,8 @@ const requestAction = <T = any>(
SUCCESS?: string,
ERROR?: string,
includeCredentials = true,
includeAdminHeaders = false
includeAdminHeaders = false,
requestTrackingId?: string
): Thunk<Promise<T>> => {
return (dispatch: any, getState: any) => {
const requestOptions = { ...options };
@ -35,10 +37,12 @@ const requestAction = <T = any>(
}
return new Promise((resolve, reject) => {
dispatch({ type: LOAD_REQUEST });
const startTime = new Date().getTime();
fetch(url, requestOptions).then(
response => {
const contentType = response.headers.get('Content-Type');
const isResponseJson = `${contentType}`.includes('application/json');
if (response.ok) {
if (!isResponseJson) {
return response.text().then(responseBody => {
@ -56,6 +60,21 @@ const requestAction = <T = any>(
dispatch({ type: SUCCESS, data: results });
}
dispatch({ type: DONE_REQUEST });
if (requestTrackingId) {
const endTime = new Date().getTime();
const responseTimeMs = endTime - startTime;
const isResponseCached = response.headers.has('Cache-Control');
const responseSize = JSON.stringify(results).length * 2;
dispatch(
processResponseDetails(
responseTimeMs,
responseSize,
isResponseCached,
requestTrackingId
)
);
}
resolve(results);
});
}