feat: graphql support (#65)

This commit is contained in:
Anoop M D 2022-11-07 02:56:58 +05:30
parent 530af1f929
commit 2aef7c61a4
22 changed files with 306 additions and 171 deletions

View File

@ -92,8 +92,6 @@ export default class QueryEditor extends React.Component {
} }
if (this.props.theme !== prevProps.theme && this.editor) { if (this.props.theme !== prevProps.theme && this.editor) {
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default'); this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
} }
this.ignoreChangeEvent = false; this.ignoreChangeEvent = false;

View File

@ -1,22 +1,14 @@
import styled from 'styled-components'; import styled from 'styled-components';
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
.react-tabs__tab-list { div.tabs {
border-bottom: none !important; div.tab {
padding-top: 0;
padding-left: 0 !important;
display: flex;
align-items: center;
margin: 0;
.react-tabs__tab {
padding: 6px 0px; padding: 6px 0px;
border: none; border: none;
user-select: none;
border-bottom: solid 2px transparent; border-bottom: solid 2px transparent;
margin-right: 20px; margin-right: 1.25rem;
color: rgb(125 125 125); color: var(--color-tab-inactive);
outline: none !important; cursor: pointer;
&:focus, &:focus,
&:active, &:active,
@ -27,36 +19,12 @@ const StyledWrapper = styled.div`
box-shadow: none !important; box-shadow: none !important;
} }
&:after { &.active {
display: none !important; color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
} }
} }
} }
.react-tabs__tab--selected {
border: none;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
border-color: var(--color-tab-active-border) !important;
background: inherit;
outline: none !important;
box-shadow: none !important;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
border: none;
outline: none !important;
box-shadow: none !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
border-color: var(--color-tab-active-border) !important;
background: inherit;
outline: none !important;
box-shadow: none !important;
}
}
`; `;
export default StyledWrapper; export default StyledWrapper;

View File

@ -1,26 +1,115 @@
import React from 'react'; import React from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; import find from 'lodash/find';
import get from 'lodash/get';
import classnames from 'classnames';
import { IconRefresh, IconLoader2, IconBook, IconDownload } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryEditor from 'components/RequestPane/QueryEditor'; import QueryEditor from 'components/RequestPane/QueryEditor';
import RequestHeaders from 'components/RequestPane/RequestHeaders'; import RequestHeaders from 'components/RequestPane/RequestHeaders';
import { useTheme } from 'providers/Theme';
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import useGraphqlSchema from './useGraphqlSchema';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const GraphQLRequestPane = ({ onRunQuery, schema, leftPaneWidth, value, onQueryChange }) => { const GraphQLRequestPane = ({ item, collection, leftPaneWidth }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const query = item.draft ? get(item, 'draft.request.body.graphql.query') : get(item, 'request.body.graphql.query');
const url = item.draft ? get(item, 'draft.request.url') : get(item, 'request.url');
const {
storedTheme
} = useTheme();
let {
schema,
loadSchema,
isLoading: isSchemaLoading,
error: schemaError
} = useGraphqlSchema(url);
const loadGqlSchema = () => {
if(!isSchemaLoading) {
loadSchema();
}
};
const onQueryChange = (value) => {
dispatch(
updateRequestGraphqlQuery({
query: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
const getTabPanel = (tab) => {
switch (tab) {
case 'query': {
return <QueryEditor theme={storedTheme} schema={schema} width={leftPaneWidth} onSave={onSave} value={query} onRun={onRun} onEdit={onQueryChange} />;
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occured!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
return ( return (
<StyledWrapper className="h-full"> <StyledWrapper className="flex flex-col h-full relative">
<Tabs className="react-tabs mt-1 flex flex-grow flex-col h-full" forceRenderTabPanel> <div className="flex items-center tabs" role="tablist">
<TabList> <div className={getTabClassname('query')} role="tab" onClick={() => selectTab('query')}>
<Tab tabIndex="-1">Query</Tab> Query
<Tab tabIndex="-1">Headers</Tab> </div>
</TabList> <div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
<TabPanel> Headers
<div className="mt-4"> </div>
<QueryEditor schema={schema} width={leftPaneWidth} value={value} onRunQuery={onRunQuery} onEdit={onQueryChange} /> <div className="flex flex-grow justify-end items-center" style={{fontSize: 13}}>
<div className='flex items-center cursor-pointer hover:underline' onClick={loadGqlSchema}>
{isSchemaLoading ? (
<IconLoader2 className="animate-spin" size={18} strokeWidth={1.5}/>
) : null}
{!isSchemaLoading && !schema ? <IconDownload size={18} strokeWidth={1.5}/> : null }
{!isSchemaLoading && schema ? <IconRefresh size={18} strokeWidth={1.5}/> : null }
<span className='ml-1'>{schema ? 'Schema' : 'Load Schema'}</span>
</div> </div>
</TabPanel> {/* <div className='flex items-center cursor-pointer hover:underline ml-2'>
<TabPanel> <IconBook size={18} strokeWidth={1.5} /><span className='ml-1'>Docs</span>
<RequestHeaders /> </div> */}
</TabPanel> </div>
</Tabs> </div>
<section className="flex w-full mt-5">{getTabPanel(focusedTab.requestPaneTab)}</section>
</StyledWrapper> </StyledWrapper>
); );
}; };

View File

@ -0,0 +1,70 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { getIntrospectionQuery, buildClientSchema } from 'graphql';
import { simpleHash } from 'utils/common';
const schemaHashPrefix = 'bruno.graphqlSchema';
const fetchSchema = (endpoint) => {
const introspectionQuery = getIntrospectionQuery();
const queryParams = {
query: introspectionQuery
};
return fetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(queryParams)
});
}
const useGraphqlSchema = (endpoint) => {
const localStorageKey = `${schemaHashPrefix}.${simpleHash(endpoint)}`;
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [schema, setSchema] = useState(() => {
try {
const saved = localStorage.getItem(localStorageKey);
if(!saved) {
return null;
}
return buildClientSchema(JSON.parse(saved));
} catch {
localStorage.setItem(localStorageKey, null);
return null;
}
});
const loadSchema = () => {
setIsLoading(true);
fetchSchema(endpoint)
.then((res) => res.json())
.then((s) => {
if (s && s.data) {
setSchema(buildClientSchema(s.data));
setIsLoading(false);
localStorage.setItem(localStorageKey, JSON.stringify(s.data));
toast.success('Graphql Schema loaded successfully');
} else {
return Promise.reject(new Error('An error occurred while introspecting schema'));
}
})
.catch((err) => {
setIsLoading(false);
setError(err);
toast.error('Error occured while loading Graphql Schema');
});
};
return {
isLoading,
schema,
loadSchema,
error
};
};
export default useGraphqlSchema;

View File

@ -5,12 +5,30 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.codemirror.bg}; background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border}; border: solid 1px ${(props) => props.theme.codemirror.border};
/* todo: find a better way */ /* todo: find a better way */
height: calc(100vh - 250px); height: calc(100vh - 220px);
} }
textarea.cm-editor { textarea.cm-editor {
position: relative; position: relative;
} }
// Todo: dark mode temporary fix
// Clean this
.cm-s-monokai span.cm-property, .cm-s-monokai span.cm-attribute {
color: #9cdcfe !important;
}
.cm-s-monokai span.cm-string {
color: #ce9178 !important;
}
.cm-s-monokai span.cm-number{
color: #b5cea8 !important;
}
.cm-s-monokai span.cm-atom{
color: #569cd6 !important;
}
`; `;
export default StyledWrapper; export default StyledWrapper;

View File

@ -38,6 +38,7 @@ export default class QueryEditor extends React.Component {
tabSize: 2, tabSize: 2,
mode: 'graphql', mode: 'graphql',
theme: this.props.editorTheme || 'graphiql', theme: this.props.editorTheme || 'graphiql',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
keyMap: 'sublime', keyMap: 'sublime',
autoCloseBrackets: true, autoCloseBrackets: true,
matchBrackets: true, matchBrackets: true,
@ -75,54 +76,51 @@ export default class QueryEditor extends React.Component {
'Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }), 'Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
'Shift-Space': () => editor.showHint({ completeSingle: true, container: this._node }), 'Shift-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
'Shift-Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }), 'Shift-Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
'Cmd-Enter': () => { 'Cmd-Enter': () => {
if (this.props.onRunQuery) { if (this.props.onRun) {
this.props.onRunQuery(); this.props.onRun();
} }
}, },
'Ctrl-Enter': () => { 'Ctrl-Enter': () => {
if (this.props.onRunQuery) { if (this.props.onRun) {
this.props.onRunQuery(); this.props.onRun();
} }
}, },
'Shift-Ctrl-C': () => { 'Shift-Ctrl-C': () => {
if (this.props.onCopyQuery) { if (this.props.onCopyQuery) {
this.props.onCopyQuery(); this.props.onCopyQuery();
} }
}, },
'Shift-Ctrl-P': () => { 'Shift-Ctrl-P': () => {
if (this.props.onPrettifyQuery) { if (this.props.onPrettifyQuery) {
this.props.onPrettifyQuery(); this.props.onPrettifyQuery();
} }
}, },
/* Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Pretiffy */ /* Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Pretiffy */
'Shift-Ctrl-F': () => { 'Shift-Ctrl-F': () => {
if (this.props.onPrettifyQuery) { if (this.props.onPrettifyQuery) {
this.props.onPrettifyQuery(); this.props.onPrettifyQuery();
} }
}, },
'Shift-Ctrl-M': () => { 'Shift-Ctrl-M': () => {
if (this.props.onMergeQuery) { if (this.props.onMergeQuery) {
this.props.onMergeQuery(); this.props.onMergeQuery();
} }
}, },
'Cmd-S': () => { 'Cmd-S': () => {
if (this.props.onRunQuery) { if (this.props.onSave) {
// empty this.props.onSave();
return false;
} }
}, },
'Ctrl-S': () => { 'Ctrl-S': () => {
if (this.props.onRunQuery) { if (this.props.onSave) {
// empty this.props.onSave();
return false;
} }
} },
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent'
} }
})); }));
if (editor) { if (editor) {
@ -149,6 +147,10 @@ export default class QueryEditor extends React.Component {
this.cachedValue = this.props.value; this.cachedValue = this.props.value;
this.editor.setValue(this.props.value); this.editor.setValue(this.props.value);
} }
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
this.ignoreChangeEvent = false; this.ignoreChangeEvent = false;
} }
@ -164,7 +166,7 @@ export default class QueryEditor extends React.Component {
render() { render() {
return ( return (
<StyledWrapper <StyledWrapper
className="h-full" className="h-full w-full"
aria-label="Query Editor" aria-label="Query Editor"
ref={(node) => { ref={(node) => {
this._node = node; this._node = node;
@ -173,8 +175,11 @@ export default class QueryEditor extends React.Component {
); );
} }
_onKeyUp = (_cm, event) => { _onKeyUp = (_cm, e) => {
if (AUTO_COMPLETE_AFTER_KEY.test(event.key) && this.editor) { if (e.metaKey || e.ctrlKey || e.altKey) {
return;
}
if (AUTO_COMPLETE_AFTER_KEY.test(e.key) && this.editor) {
this.editor.execCommand('autocomplete'); this.editor.execCommand('autocomplete');
} }
}; };

View File

@ -11,7 +11,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const dispatch = useDispatch(); const dispatch = useDispatch();
const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method'); const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
let url = item.draft ? get(item, 'draft.request.url') : get(item, 'request.url'); const url = item.draft ? get(item, 'draft.request.url') : get(item, 'request.url');
const onUrlChange = (value) => { const onUrlChange = (value) => {
dispatch( dispatch(

View File

@ -12,7 +12,6 @@ import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound'; import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl'; import QueryUrl from 'components/RequestPane/QueryUrl';
import NetworkError from 'components/ResponsePane/NetworkError'; import NetworkError from 'components/ResponsePane/NetworkError';
import useGraphqlSchema from '../../hooks/useGraphqlSchema';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -65,11 +64,6 @@ const RequestTabPanel = () => {
setDragging(true); setDragging(true);
}; };
let schema = null;
// let {
// schema
// } = useGraphqlSchema('https://api.spacex.land/graphql');
useEffect(() => { useEffect(() => {
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
@ -105,8 +99,6 @@ const RequestTabPanel = () => {
}) })
); );
}; };
const onGraphqlQueryChange = (value) => {};
const runQuery = async () => {};
return ( return (
<StyledWrapper className={`flex flex-col flex-grow ${dragging ? 'dragging' : ''}`}> <StyledWrapper className={`flex flex-col flex-grow ${dragging ? 'dragging' : ''}`}>
@ -118,11 +110,9 @@ const RequestTabPanel = () => {
<div className="px-4" style={{ width: `${leftPaneWidth}px`, height: 'calc(100% - 5px)' }}> <div className="px-4" style={{ width: `${leftPaneWidth}px`, height: 'calc(100% - 5px)' }}>
{item.type === 'graphql-request' ? ( {item.type === 'graphql-request' ? (
<GraphQLRequestPane <GraphQLRequestPane
onRunQuery={runQuery} item={item}
schema={schema} collection={collection}
leftPaneWidth={leftPaneWidth} leftPaneWidth={leftPaneWidth}
value={item.request.body.graphql.query}
onQueryChange={onGraphqlQueryChange}
/> />
) : null} ) : null}

View File

@ -15,6 +15,7 @@ import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem'; import DeleteCollectionItem from './DeleteCollectionItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs'; import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app'; import { hideHomePage } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -69,7 +70,8 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch( dispatch(
addTab({ addTab({
uid: item.uid, uid: item.uid,
collectionUid: collection.uid collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
}) })
); );
} }

View File

@ -9,6 +9,7 @@ import { newEphermalHttpRequest } from 'providers/ReduxStore/slices/collections'
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions'; import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs'; import { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector'; import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphermal, onClose }) => { const NewRequest = ({ collection, item, isEphermal, onClose }) => {
@ -42,7 +43,8 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
dispatch( dispatch(
addTab({ addTab({
uid: uid, uid: uid,
collectionUid: collection.uid collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab({type: values.requestType})
}) })
); );
onClose(); onClose();
@ -77,27 +79,27 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
<StyledWrapper> <StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="hidden"> <div>
<label htmlFor="requestName" className="block font-semibold"> <label htmlFor="requestName" className="block font-semibold">
Type Type
</label> </label>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<input <input
id="http" id="http-request"
className="cursor-pointer" className="cursor-pointer"
type="radio" type="radio"
name="requestType" name="requestType"
onChange={formik.handleChange} onChange={formik.handleChange}
value="http" value="http-request"
checked={formik.values.requestType === 'http-request'} checked={formik.values.requestType === 'http-request'}
/> />
<label htmlFor="http" className="ml-1 cursor-pointer select-none"> <label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
Http Http
</label> </label>
<input <input
id="graphql" id="graphql-request"
className="ml-4 cursor-pointer" className="ml-4 cursor-pointer"
type="radio" type="radio"
name="requestType" name="requestType"
@ -105,16 +107,16 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
formik.setFieldValue('requestMethod', 'POST'); formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event); formik.handleChange(event);
}} }}
value="graphql" value="graphql-request"
checked={formik.values.requestType === 'graphql-request'} checked={formik.values.requestType === 'graphql-request'}
/> />
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none"> <label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
Graphql Graphql
</label> </label>
</div> </div>
</div> </div>
<div> <div className="mt-4">
<label htmlFor="requestName" className="block font-semibold"> <label htmlFor="requestName" className="block font-semibold">
Name Name
</label> </label>

View File

@ -54,7 +54,7 @@ const Wrapper = styled.div`
&:hover div.drag-request-border { &:hover div.drag-request-border {
width: 2px; width: 2px;
height: 100%; height: 100%;
border-left: solid 1px var(--color-request-dragbar-background-active); border-left: solid 1px ${(props) => props.theme.sidebar.dragbar};
} }
} }
`; `;

View File

@ -22,6 +22,10 @@ const GlobalStyle = createGlobalStyle`
padding: .215rem .6rem .215rem .6rem; padding: .215rem .6rem .215rem .6rem;
} }
.btn-xs {
padding: .2rem .4rem .2rem .4rem;
}
.btn-md { .btn-md {
padding: .4rem 1.1rem; padding: .4rem 1.1rem;
line-height: 1.47; line-height: 1.47;

View File

@ -1,44 +0,0 @@
import { useState, useEffect } from 'react';
import { getIntrospectionQuery, buildClientSchema } from 'graphql';
const useGraphqlSchema = (endpoint) => {
const [isLoaded, setIsLoaded] = useState(false);
const [schema, setSchema] = useState(null);
const [error, setError] = useState(null);
const introspectionQuery = getIntrospectionQuery();
const queryParams = {
query: introspectionQuery
};
useEffect(() => {
fetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(queryParams)
})
.then((res) => res.json())
.then((s) => {
if (s && s.data) {
setSchema(buildClientSchema(s.data));
setIsLoaded(true);
} else {
return Promise.reject(new Error('An error occurred while introspecting schema'));
}
})
.catch((err) => {
setError(err);
});
}, []);
return {
isLoaded,
schema,
error
};
};
export default useGraphqlSchema;

View File

@ -3,8 +3,8 @@ import { createSlice } from '@reduxjs/toolkit';
const initialState = { const initialState = {
isDragging: false, isDragging: false,
idbConnectionReady: false, idbConnectionReady: false,
leftSidebarWidth: 270, leftSidebarWidth: 222,
leftMenuBarOpen: true, leftMenuBarOpen: false,
screenWidth: 500, screenWidth: 500,
showHomePage: false showHomePage: false
}; };

View File

@ -16,7 +16,8 @@ import {
findEnvironmentInCollection, findEnvironmentInCollection,
isItemAFolder, isItemAFolder,
refreshUidsInItem, refreshUidsInItem,
interpolateEnvironmentVars interpolateEnvironmentVars,
getDefaultRequestPaneTab
} from 'utils/collections'; } from 'utils/collections';
import { collectionSchema, itemSchema, environmentsSchema } from '@usebruno/schema'; import { collectionSchema, itemSchema, environmentsSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common'; import { waitForNextTick } from 'utils/common';
@ -108,7 +109,8 @@ export const createCollection = (collectionName) => (dispatch, getState) => {
dispatch( dispatch(
addTab({ addTab({
uid: requestItem.uid, uid: requestItem.uid,
collectionUid: newCollection.uid collectionUid: newCollection.uid,
requestPaneTab: getDefaultRequestPaneTab(requestItem)
}) })
) )
) )
@ -635,7 +637,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
dispatch( dispatch(
addTab({ addTab({
uid: item.uid, uid: item.uid,
collectionUid: collection.uid collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
}) })
); );
}) })

View File

@ -606,6 +606,22 @@ export const collectionsSlice = createSlice({
} }
} }
}, },
updateRequestGraphqlQuery: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.body.mode = 'graphql';
item.draft.request.body.graphql = item.draft.request.body.graphql || {};
item.draft.request.body.graphql.query = action.payload.query;
}
}
},
updateRequestMethod: (state, action) => { updateRequestMethod: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -791,6 +807,7 @@ export const {
deleteMultipartFormParam, deleteMultipartFormParam,
updateRequestBodyMode, updateRequestBodyMode,
updateRequestBody, updateRequestBody,
updateRequestGraphqlQuery,
updateRequestMethod, updateRequestMethod,
localCollectionAddFileEvent, localCollectionAddFileEvent,
localCollectionAddDirectoryEvent, localCollectionAddDirectoryEvent,

View File

@ -23,7 +23,7 @@ export const tabsSlice = createSlice({
uid: action.payload.uid, uid: action.payload.uid,
collectionUid: action.payload.collectionUid, collectionUid: action.payload.collectionUid,
requestPaneWidth: null, requestPaneWidth: null,
requestPaneTab: 'params', requestPaneTab: action.payload.requestPaneTab || 'params',
responsePaneTab: 'response' responsePaneTab: 'response'
}); });
state.activeTabUid = action.payload.uid; state.activeTabUid = action.payload.uid;

View File

@ -22,6 +22,7 @@ const darkTheme = {
color: '#ccc', color: '#ccc',
muted: '#9d9d9d', muted: '#9d9d9d',
bg: '#252526', bg: '#252526',
dragbar: '#8a8a8a',
workspace: { workspace: {
bg: '#3D3D3D' bg: '#3D3D3D'

View File

@ -22,6 +22,7 @@ const lightTheme = {
color: 'rgb(52, 52, 52)', color: 'rgb(52, 52, 52)',
muted: '#4b5563', muted: '#4b5563',
bg: '#F3F3F3', bg: '#F3F3F3',
dragbar: 'rgb(200, 200, 200)',
workspace: { workspace: {
bg: '#e1e1e1' bg: '#e1e1e1'

View File

@ -196,7 +196,7 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
json: si.draft.request.body.json, json: si.draft.request.body.json,
text: si.draft.request.body.text, text: si.draft.request.body.text,
xml: si.draft.request.body.xml, xml: si.draft.request.body.xml,
multipartForm: si.draft.request.body.multipartForm, graphql: si.draft.request.body.graphql,
formUrlEncoded: copyFormUrlEncodedParams(si.draft.request.body.formUrlEncoded), formUrlEncoded: copyFormUrlEncodedParams(si.draft.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm) multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm)
} }
@ -214,6 +214,7 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
json: si.request.body.json, json: si.request.body.json,
text: si.request.body.text, text: si.request.body.text,
xml: si.request.body.xml, xml: si.request.body.xml,
graphql: si.request.body.graphql,
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded), formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm) multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
} }
@ -410,3 +411,13 @@ export const interpolateEnvironmentVars = (item, variables) => {
return request; return request;
}; };
export const getDefaultRequestPaneTab = (item) => {
if(item.type === 'http-request') {
return 'params';
}
if(item.type === 'graphql-request') {
return 'query';
}
};

View File

@ -1,14 +1,14 @@
import get from 'lodash/get';
import each from 'lodash/each'; import each from 'lodash/each';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import qs from 'qs'; import qs from 'qs';
import { rawRequest, gql } from 'graphql';
import { sendHttpRequestInBrowser } from './browser'; import { sendHttpRequestInBrowser } from './browser';
import { isElectron } from 'utils/common/platform'; import { isElectron } from 'utils/common/platform';
import cancelTokens, { deleteCancelToken } from 'utils/network/cancelTokens'; import cancelTokens, { deleteCancelToken } from 'utils/network/cancelTokens';
export const sendNetworkRequest = async (item, options) => { export const sendNetworkRequest = async (item, options) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (item.type === 'http-request') { if (['http-request', 'graphql-request'].includes(item.type)) {
const timeStart = Date.now(); const timeStart = Date.now();
sendHttpRequest(item.draft ? item.draft.request : item.request, options) sendHttpRequest(item.draft ? item.draft.request : item.request, options)
.then((response) => { .then((response) => {
@ -79,6 +79,15 @@ const sendHttpRequest = async (request, options) => {
axiosRequest.data = params; axiosRequest.data = params;
} }
if (request.body.mode === 'graphql') {
const graphqlQuery = {
query: get(request, 'body.graphql.query'),
variables: JSON.parse(get(request, 'body.graphql.variables') || '{}')
};
axiosRequest.headers['content-type'] = 'application/json';
axiosRequest.data = graphqlQuery;
}
console.log('>>> Sending Request'); console.log('>>> Sending Request');
console.log(axiosRequest); console.log(axiosRequest);
@ -90,21 +99,6 @@ const sendHttpRequest = async (request, options) => {
}); });
}; };
const sendGraphqlRequest = async (request) => {
const query = gql`
${request.request.body.graphql.query}
`;
const { data, errors, extensions, headers, status } = await rawRequest(request.request.url, query);
return {
data,
headers,
data,
errors
};
};
export const cancelNetworkRequest = async (cancelTokenUid) => { export const cancelNetworkRequest = async (cancelTokenUid) => {
if (isElectron()) { if (isElectron()) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@ -29,13 +29,19 @@ const keyValueSchema = Yup.object({
const requestUrlSchema = Yup.string().min(0).max(2048, 'name must be 2048 characters or less').defined(); const requestUrlSchema = Yup.string().min(0).max(2048, 'name must be 2048 characters or less').defined();
const requestMethodSchema = Yup.string().oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']).required('method is required'); const requestMethodSchema = Yup.string().oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']).required('method is required');
const graphqlBodySchema = Yup.object({
query: Yup.string().max(10240, 'json must be 10240 characters or less').nullable(),
variables: Yup.string().max(10240, 'text must be 10240 characters or less').nullable(),
}).noUnknown(true).strict();
const requestBodySchema = Yup.object({ const requestBodySchema = Yup.object({
mode: Yup.string().oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm']).required('mode is required'), mode: Yup.string().oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql']).required('mode is required'),
json: Yup.string().max(10240, 'json must be 10240 characters or less').nullable(), json: Yup.string().max(10240, 'json must be 10240 characters or less').nullable(),
text: Yup.string().max(10240, 'text must be 10240 characters or less').nullable(), text: Yup.string().max(10240, 'text must be 10240 characters or less').nullable(),
xml: Yup.string().max(10240, 'xml must be 10240 characters or less').nullable(), xml: Yup.string().max(10240, 'xml must be 10240 characters or less').nullable(),
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(), formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
multipartForm: Yup.array().of(keyValueSchema).nullable(), multipartForm: Yup.array().of(keyValueSchema).nullable(),
graphql: graphqlBodySchema.nullable(),
}).noUnknown(true).strict(); }).noUnknown(true).strict();
// Right now, the request schema is very tightly coupled with http request // Right now, the request schema is very tightly coupled with http request