Compare commits

..

31 Commits

Author SHA1 Message Date
Anoop M D
e6265db353 chore: backstage catalog 2022-12-16 19:18:16 +05:30
Vinod Godti
cffef31f97 RequestPane body form input text color visibility fix in dark mode (#70) 2022-11-10 18:29:17 +05:30
Nash
b93be5a846 Set default theme to the user's browser theme (#69) 2022-11-09 22:55:30 +05:30
Anoop M D
544765af3e fix: fixed graphql docs height overflow (#65) 2022-11-08 03:45:30 +05:30
Anoop M D
2393092248 feat: graphql docs explorer (#65) 2022-11-08 03:35:58 +05:30
Anoop M D
dcdeb78995 release: bruno-graphql-docs@0.1.0 2022-11-08 03:34:25 +05:30
Ankur Singh Chauhan
e16650d4a7 Bugfix/split line (#66)
* Issue:62,fix Split line should have max squeeze limit
* added limit to right pane also
2022-11-07 18:22:09 +05:30
Anoop M D
62ed489847 chore: bumped bruno-schema in bruno-app 2022-11-07 03:14:48 +05:30
Anoop M D
2930eb29ec chore: release bruno-schema 0.2.0 2022-11-07 03:13:22 +05:30
Anoop M D
eecb60f5cf Merge branch 'main' of github.com:usebruno/bruno into main 2022-11-07 03:06:18 +05:30
Ankur Singh Chauhan
82fb2819c2 Issue:62,fix Split line should have max squeeze limit (#64)
Co-authored-by: Ankur Singh Chauhan <anx450z@gmail.com>
2022-11-07 03:03:35 +05:30
Anoop M D
2aef7c61a4 feat: graphql support (#65) 2022-11-07 02:56:58 +05:30
Anoop M D
530af1f929 chore: fix peer deps issue for rollup 2022-11-06 14:41:10 +05:30
Anoop M D
3753fd1e20 feat: standalone graphiql docs explorer 2022-11-06 01:04:30 +05:30
Anoop M D
a59ae75809 fix: fixed postman import url issue 2022-11-05 01:00:21 +05:30
Anoop M D
4c18c27406 chore: allow legacy peer deps while while running playwright tests 2022-11-05 00:21:15 +05:30
Anoop M D
5d25fdcf7a chore: bumped rollup version 2022-11-05 00:16:54 +05:30
Anoop M D
8cfdb3ebcb Merge branch 'main' of github.com:usebruno/bruno 2022-11-04 23:42:43 +05:30
Anoop M D
9e64ea5439 feat: package init for graphql-docs 2022-11-04 23:42:34 +05:30
shash68i
23c8044973 chore: prettier script added in root bruno/package.json (#63) 2022-11-02 15:22:48 +05:30
shash68i
46ac15dd81 Collection items whole div made as action button and cursor pointer added to the items (#61) 2022-11-01 00:50:51 +05:30
depa panjie purnama
5ad9be4f6b feat: Create New Request e2e test (#52)
* add selector ID
* add createNewRequest flow
* selector update
2022-10-31 16:50:57 +05:30
shash68i
f46625c689 Added missing <br /> between Website and Discord link in readme.md (#60) 2022-10-31 16:49:27 +05:30
Anoop M D
c0e1bf6bc2 chore: added discord invite link in readme 2022-10-31 03:14:29 +05:30
Anoop M D
874ca07f39 chore: added discord link 2022-10-31 00:58:36 +05:30
Anoop M D
a291e7f345 fix: fixed mac electron build issues 2022-10-31 00:58:20 +05:30
Anoop M D
c9cabfde35 release: bumped version 2022-10-30 20:07:11 +05:30
Anoop M D
fde15d7c31 Merge branch 'main' of github.com:usebruno/bruno into main 2022-10-30 19:58:06 +05:30
Anoop M D
11defe18ca chore: disable telemetry during playwright test execution 2022-10-30 19:56:54 +05:30
anusreesubash
54b14a005d fix(#53): fix for response editor search issue (#55) 2022-10-30 17:50:38 +05:30
Anoop M D
f283df2a1b feat: posthog telemetry 2022-10-30 17:48:36 +05:30
69 changed files with 2207 additions and 231 deletions

View File

@@ -14,7 +14,7 @@ jobs:
with:
node-version: 16
- name: Install dependencies
run: npm i
run: npm i --legacy-peer-deps
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests

2
.nvmrc
View File

@@ -1 +1 @@
v14.17.0
v14.18.0

16
catalog-info.yaml Normal file
View File

@@ -0,0 +1,16 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: "bruno"
links:
- url: https://example.com/user
title: Examples Users
icon: user
- url: https://example.com/group
title: Example Group
icon: group
spec:
type: component
lifecycle: production
owner: anoop
system: tech-docs

View File

@@ -6,7 +6,8 @@
"packages/bruno-electron",
"packages/bruno-tauri",
"packages/bruno-schema",
"packages/bruno-testbench"
"packages/bruno-testbench",
"packages/bruno-graphql-docs"
],
"devDependencies": {
"@faker-js/faker": "^7.6.0",
@@ -17,10 +18,15 @@
"scripts": {
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
"build:chrome-extension": "./scripts/build-chrome-extension.sh",
"build:electron": "./scripts/build-electron.sh",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report"
},
"overrides": {
"rollup": "3.2.5"
}
}

View File

@@ -1,5 +1,9 @@
module.exports = {
reactStrictMode: true,
publicRuntimeConfig: {
CI: process.env.CI,
PLAYWRIGHT: process.env.PLAYWRIGHT
},
webpack: (config, { isServer }) => {
// Fixes npm packages that depend on `fs` module
if (!isServer) {

View File

@@ -15,7 +15,8 @@
"@reduxjs/toolkit": "^1.8.0",
"@tabler/icons": "^1.46.0",
"@tippyjs/react": "^4.2.6",
"@usebruno/schema": "0.1.0",
"@usebruno/schema": "0.2.0",
"@usebruno/graphql-docs": "0.1.0",
"axios": "^0.26.0",
"classnames": "^2.3.1",
"codemirror": "^5.65.2",
@@ -30,10 +31,13 @@
"idb": "^7.0.0",
"immer": "^9.0.15",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"mousetrap": "^1.6.5",
"nanoid": "3.3.4",
"next": "12.3.1",
"path": "^0.12.7",
"platform": "^1.3.6",
"posthog-node": "^2.1.0",
"qs": "^6.11.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@@ -1,6 +1,6 @@
import React from 'react';
import Modal from 'components/Modal/index';
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub } from '@tabler/icons';
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const BrunoSupport = ({ onClose }) => {
@@ -14,6 +14,12 @@ const BrunoSupport = ({ onClose }) => {
<span className="label ml-2">Report Issues</span>
</a>
</div>
<div className="mt-2">
<a href="https://discord.com/invite/KgcZUncpjq" target="_blank" className="flex items-end">
<IconBrandDiscord size={18} strokeWidth={2} />
<span className="label ml-2">Discord</span>
</a>
</div>
<div className="mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-end">
<IconBrandGithub size={18} strokeWidth={2} />

View File

@@ -92,8 +92,6 @@ export default class QueryEditor extends React.Component {
}
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.ignoreChangeEvent = false;

View File

@@ -12,7 +12,7 @@ const Wrapper = styled.div`
}
thead {
color: ${(props) => props.theme.table.thead.color};;
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
@@ -29,6 +29,7 @@ const Wrapper = styled.div`
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
&:focus {
outline: none !important;

View File

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

View File

@@ -1,26 +1,133 @@
import React from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import React, { useEffect } from 'react';
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 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';
const GraphQLRequestPane = ({ onRunQuery, schema, leftPaneWidth, value, onQueryChange }) => {
const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
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();
}
};
useEffect(() => {
if(onSchemaLoad) {
onSchemaLoad(schema);
}
}, [schema]);
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}
onClickReference={handleGqlClickReference}
/>;
}
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 (
<StyledWrapper className="h-full">
<Tabs className="react-tabs mt-1 flex flex-grow flex-col h-full" forceRenderTabPanel>
<TabList>
<Tab tabIndex="-1">Query</Tab>
<Tab tabIndex="-1">Headers</Tab>
</TabList>
<TabPanel>
<div className="mt-4">
<QueryEditor schema={schema} width={leftPaneWidth} value={value} onRunQuery={onRunQuery} onEdit={onQueryChange} />
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center tabs" role="tablist">
<div className={getTabClassname('query')} role="tab" onClick={() => selectTab('query')}>
Query
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<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>
</TabPanel>
<TabPanel>
<RequestHeaders />
</TabPanel>
</Tabs>
<div
className='flex items-center cursor-pointer hover:underline ml-2'
onClick={toggleDocs}
>
<IconBook size={18} strokeWidth={1.5} /><span className='ml-1'>Docs</span>
</div>
</div>
</div>
<section className="flex w-full mt-5">{getTabPanel(focusedTab.requestPaneTab)}</section>
</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

@@ -12,7 +12,7 @@ const Wrapper = styled.div`
}
thead {
color: ${(props) => props.theme.table.thead.color};;
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
@@ -29,6 +29,7 @@ const Wrapper = styled.div`
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
&:focus {
outline: none !important;

View File

@@ -5,12 +5,30 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
/* todo: find a better way */
height: calc(100vh - 250px);
height: calc(100vh - 220px);
}
textarea.cm-editor {
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;

View File

@@ -38,6 +38,7 @@ export default class QueryEditor extends React.Component {
tabSize: 2,
mode: 'graphql',
theme: this.props.editorTheme || 'graphiql',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
@@ -75,54 +76,51 @@ export default class QueryEditor extends React.Component {
'Alt-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 }),
'Cmd-Enter': () => {
if (this.props.onRunQuery) {
this.props.onRunQuery();
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRunQuery) {
this.props.onRunQuery();
if (this.props.onRun) {
this.props.onRun();
}
},
'Shift-Ctrl-C': () => {
if (this.props.onCopyQuery) {
this.props.onCopyQuery();
}
},
'Shift-Ctrl-P': () => {
if (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-F': () => {
if (this.props.onPrettifyQuery) {
this.props.onPrettifyQuery();
}
},
'Shift-Ctrl-M': () => {
if (this.props.onMergeQuery) {
this.props.onMergeQuery();
}
},
'Cmd-S': () => {
if (this.props.onRunQuery) {
// empty
if (this.props.onSave) {
this.props.onSave();
return false;
}
},
'Ctrl-S': () => {
if (this.props.onRunQuery) {
// empty
if (this.props.onSave) {
this.props.onSave();
return false;
}
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent'
}
}));
if (editor) {
@@ -149,6 +147,10 @@ export default class QueryEditor extends React.Component {
this.cachedValue = 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;
}
@@ -164,7 +166,7 @@ export default class QueryEditor extends React.Component {
render() {
return (
<StyledWrapper
className="h-full"
className="h-full w-full"
aria-label="Query Editor"
ref={(node) => {
this._node = node;
@@ -173,8 +175,11 @@ export default class QueryEditor extends React.Component {
);
}
_onKeyUp = (_cm, event) => {
if (AUTO_COMPLETE_AFTER_KEY.test(event.key) && this.editor) {
_onKeyUp = (_cm, e) => {
if (e.metaKey || e.ctrlKey || e.altKey) {
return;
}
if (AUTO_COMPLETE_AFTER_KEY.test(e.key) && this.editor) {
this.editor.execCommand('autocomplete');
}
};

View File

@@ -10,7 +10,7 @@ const HttpMethodSelector = ({ method, onMethodSelect }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex w-full items-center pl-3 py-1 select-none uppercase">
<div className="flex-grow font-medium">{method}</div>
<div className="flex-grow font-medium" id="create-new-request-method">{method}</div>
<div>
<IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
</div>

View File

@@ -11,7 +11,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const { theme } = useTheme();
const dispatch = useDispatch();
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) => {
dispatch(

View File

@@ -25,6 +25,25 @@ const StyledWrapper = styled.div`
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
div.graphql-docs-explorer-container {
background: white;
outline: none;
box-shadow: rgb(0 0 0 / 15%) 0px 0px 8px;
position: absolute;
right: 0px;
z-index: 2000;
width: 350px;
height: 100%;
div.doc-explorer-title {
text-align: left;
}
div.doc-explorer-rhs {
display: flex;
}
}
`;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import find from 'lodash/find';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
@@ -12,10 +12,14 @@ import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl';
import NetworkError from 'components/ResponsePane/NetworkError';
import useGraphqlSchema from '../../hooks/useGraphqlSchema';
import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
const DEFAULT_PADDING = 5;
const RequestTabPanel = () => {
if (typeof window == 'undefined') {
return <div></div>;
@@ -29,23 +33,43 @@ const RequestTabPanel = () => {
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const [leftPaneWidth, setLeftPaneWidth] = useState(focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2); // 2.2 so that request pane is relatively smaller
const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - 5);
const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
const [dragging, setDragging] = useState(false);
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
const docExplorerRef = useRef(null);
const [schema, setSchema] = useState(null);
const [showGqlDocs, setShowGqlDocs] = useState(false);
const onSchemaLoad = (schema) => setSchema(schema);
const toggleDocs = () => setShowGqlDocs((showGqlDocs) => !showGqlDocs);
const handleGqlClickReference = (reference) => {
if(docExplorerRef.current) {
docExplorerRef.current.showDocForReference(reference);
}
if(!showGqlDocs) {
setShowGqlDocs(true);
}
};
useEffect(() => {
const leftPaneWidth = (screenWidth - asideWidth) / 2.2;
setLeftPaneWidth(leftPaneWidth);
}, [screenWidth]);
useEffect(() => {
setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - 5);
setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
}, [screenWidth, asideWidth, leftPaneWidth]);
const handleMouseMove = (e) => {
if (dragging) {
e.preventDefault();
setLeftPaneWidth(e.clientX - asideWidth - 5);
setRightPaneWidth(screenWidth - e.clientX - 5);
let leftPaneXPosition = e.clientX + 2;
if (leftPaneXPosition < (asideWidth+ DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH) || leftPaneXPosition > (screenWidth - MIN_RIGHT_PANE_WIDTH )) {
return;
}
setLeftPaneWidth(leftPaneXPosition- asideWidth);
setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING);
}
};
const handleMouseUp = (e) => {
@@ -55,7 +79,7 @@ const RequestTabPanel = () => {
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - asideWidth - 5
requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING
})
);
}
@@ -65,11 +89,6 @@ const RequestTabPanel = () => {
setDragging(true);
};
let schema = null;
// let {
// schema
// } = useGraphqlSchema('https://api.spacex.land/graphql');
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
@@ -105,24 +124,23 @@ const RequestTabPanel = () => {
})
);
};
const onGraphqlQueryChange = (value) => {};
const runQuery = async () => {};
return (
<StyledWrapper className={`flex flex-col flex-grow ${dragging ? 'dragging' : ''}`}>
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''}`}>
<div className="pt-4 pb-3 px-4">
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
</div>
<section className="main flex flex-grow pb-4">
<section className="main flex flex-grow pb-4 relative">
<section className="request-pane">
<div className="px-4" style={{ width: `${leftPaneWidth}px`, height: 'calc(100% - 5px)' }}>
<div className="px-4" style={{ width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`, height: `calc(100% - ${DEFAULT_PADDING}px)` }}>
{item.type === 'graphql-request' ? (
<GraphQLRequestPane
onRunQuery={runQuery}
schema={schema}
item={item}
collection={collection}
leftPaneWidth={leftPaneWidth}
value={item.request.body.graphql.query}
onQueryChange={onGraphqlQueryChange}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
/>
) : null}
@@ -138,8 +156,22 @@ const RequestTabPanel = () => {
<ResponsePane item={item} collection={collection} rightPaneWidth={rightPaneWidth} response={item.response} />
</section>
</section>
{item.type === 'graphql-request' ? (
<div className={`graphql-docs-explorer-container ${showGqlDocs ? '' : 'hidden'}`}>
<DocExplorer schema={schema} ref={(r) => docExplorerRef.current = r}>
<button
className='mr-2'
onClick={toggleDocs}
aria-label="Close Documentation Explorer"
>
{'\u2715'}
</button>
</DocExplorer>
</div>
): null}
</StyledWrapper>
);
};
export default RequestTabPanel;
export default RequestTabPanel;

View File

@@ -119,7 +119,7 @@ const RequestTabs = () => {
</div>
</li>
) : null}
<li className="select-none short-tab" onClick={createNewTab}>
<li className="select-none short-tab" id="create-new-tab" onClick={createNewTab}>
<div className="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />

View File

@@ -15,6 +15,7 @@ import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
@@ -69,7 +70,8 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
addTab({
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 { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphermal, onClose }) => {
@@ -42,7 +43,8 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
dispatch(
addTab({
uid: uid,
collectionUid: collection.uid
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab({type: values.requestType})
})
);
onClose();
@@ -77,27 +79,27 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
<StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="hidden">
<div>
<label htmlFor="requestName" className="block font-semibold">
Type
</label>
<div className="flex items-center mt-2">
<input
id="http"
id="http-request"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="http"
value="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
</label>
<input
id="graphql"
id="graphql-request"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
@@ -105,16 +107,16 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="graphql"
value="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
</label>
</div>
</div>
<div>
<div className="mt-4">
<label htmlFor="requestName" className="block font-semibold">
Name
</label>

View File

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

View File

@@ -124,7 +124,7 @@ const Sidebar = () => {
title="GitHub"
></iframe>
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.2.0</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.3.0</div>
</div>
</div>
</div>

View File

@@ -11,13 +11,14 @@ const StyledWrapper = styled.div`
}
.collection-options {
cursor: pointer;
svg {
position: relative;
top: -1px;
}
.label {
cursor: pointer;
&:hover {
text-decoration: underline;
}

View File

@@ -65,15 +65,15 @@ const Welcome = () => {
<div className="uppercase font-semibold heading mt-10">Collections</div>
<div className="mt-4 flex items-center collection-options select-none">
<div className="flex items-center">
<div className="flex items-center" onClick={() => setCreateCollectionModalOpen(true)}>
<IconPlus size={18} strokeWidth={2} />
<span className="label ml-2" id="create-collection" onClick={() => setCreateCollectionModalOpen(true)}>
<span className="label ml-2" id="create-collection">
Create Collection
</span>
</div>
<div className="flex items-center ml-6">
<div className="flex items-center ml-6" onClick={() => setAddCollectionToWSModalOpen(true)}>
<IconFiles size={18} strokeWidth={2} />
<span className="label ml-2" id="add-collection" onClick={() => setAddCollectionToWSModalOpen(true)}>
<span className="label ml-2" id="add-collection">
Add Collection to Workspace
</span>
</div>

View File

@@ -22,6 +22,10 @@ const GlobalStyle = createGlobalStyle`
padding: .215rem .6rem .215rem .6rem;
}
.btn-xs {
padding: .2rem .4rem .2rem .4rem;
}
.btn-md {
padding: .4rem 1.1rem;
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

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import useIdb from './useIdb';
import useTelemetry from './useTelemetry';
import useLocalCollectionTreeSync from './useLocalCollectionTreeSync';
import { useDispatch } from 'react-redux';
import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
@@ -9,6 +10,7 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => {
useIdb();
useTelemetry();
useLocalCollectionTreeSync();
const dispatch = useDispatch();

View File

@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import getConfig from 'next/config';
import { PostHog } from 'posthog-node';
import platformLib from 'platform';
import { uuid } from 'utils/common';
import { isElectron } from 'utils/common/platform';
const { publicRuntimeConfig } = getConfig();
const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
let posthogClient = null;
const isPlaywrightTestRunning = () => {
return publicRuntimeConfig.PLAYWRIGHT ? true : false;
};
// Todo support chrome and firefox extension
const getPlatform = () => {
return isElectron() ? 'electron' : 'web';
};
const getPosthogClient = () => {
if(posthogClient) {
return posthogClient;
}
posthogClient = new PostHog(posthogApiKey);
return posthogClient;
};
const getAnonymousTrackingId = () => {
let id = localStorage.getItem('bruno.anonymousTrackingId');
if(!id || !id.length || id.length !== 21) {
id = uuid();
localStorage.setItem('bruno.anonymousTrackingId', id);
}
return id;
};
const trackStart = () => {
if(isPlaywrightTestRunning()) {
return;
}
const trackingId = getAnonymousTrackingId();
const platform = getPlatform();
const client = getPosthogClient();
client.capture({
distinctId: trackingId,
event: 'start',
properties: {
platform: platform,
os: platformLib.os.family
}
});
};
const useTelemetry = () => {
useEffect(() => {
trackStart();
setInterval(trackStart , 24 * 60 * 60 * 1000);
}, []);
};
export default useTelemetry;

View File

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

View File

@@ -16,7 +16,8 @@ import {
findEnvironmentInCollection,
isItemAFolder,
refreshUidsInItem,
interpolateEnvironmentVars
interpolateEnvironmentVars,
getDefaultRequestPaneTab
} from 'utils/collections';
import { collectionSchema, itemSchema, environmentsSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common';
@@ -108,7 +109,8 @@ export const createCollection = (collectionName) => (dispatch, getState) => {
dispatch(
addTab({
uid: requestItem.uid,
collectionUid: newCollection.uid
collectionUid: newCollection.uid,
requestPaneTab: getDefaultRequestPaneTab(requestItem)
})
)
)
@@ -635,7 +637,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
dispatch(
addTab({
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) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -791,6 +807,7 @@ export const {
deleteMultipartFormParam,
updateRequestBodyMode,
updateRequestBody,
updateRequestGraphqlQuery,
updateRequestMethod,
localCollectionAddFileEvent,
localCollectionAddDirectoryEvent,

View File

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

View File

@@ -6,7 +6,8 @@ import { ThemeProvider as SCThemeProvider } from 'styled-components';
export const ThemeContext = createContext();
export const ThemeProvider = (props) => {
const [storedTheme, setStoredTheme] = useLocalStorage('bruno.theme', 'light');
const isBrowserThemeLight = window.matchMedia("(prefers-color-scheme: light)").matches;
const [storedTheme, setStoredTheme] = useLocalStorage('bruno.theme', isBrowserThemeLight ? 'light' : 'dark');
const theme = themes[storedTheme];
const themeOptions = Object.keys(themes);

View File

@@ -15,13 +15,14 @@ const darkTheme = {
},
menubar: {
bg: '#333333',
bg: '#333333'
},
sidebar: {
color: '#ccc',
muted: '#9d9d9d',
bg: '#252526',
dragbar: '#8a8a8a',
workspace: {
bg: '#3D3D3D'
@@ -54,8 +55,8 @@ const darkTheme = {
},
dropdown: {
color: "rgb(204, 204, 204)",
iconColor: "rgb(204, 204, 204)",
color: 'rgb(204, 204, 204)',
iconColor: 'rgb(204, 204, 204)',
bg: 'rgb(48, 48, 49)',
hoverBg: '#185387',
shadow: 'rgb(0 0 0 / 36%) 0px 2px 8px',
@@ -106,7 +107,7 @@ const darkTheme = {
active: {
bg: 'transparent',
hoverBg: 'transparent'
},
}
},
gridBorder: '#4f4f4f'
}
@@ -121,9 +122,9 @@ const darkTheme = {
},
body: {
color: '#ccc',
bg: 'rgb(48, 48, 49)',
bg: 'rgb(48, 48, 49)'
},
input : {
input: {
bg: 'rgb(65, 65, 65)',
border: 'rgb(65, 65, 65)',
focusBorder: 'rgb(65, 65, 65)'
@@ -190,10 +191,13 @@ const darkTheme = {
table: {
border: '#333',
thead : {
thead: {
color: 'rgb(204, 204, 204)'
},
striped: '#2A2D2F'
striped: '#2A2D2F',
input: {
color: '#555555'
}
},
plainGrid: {

View File

@@ -7,7 +7,7 @@ const lightTheme = {
colors: {
text: {
danger: 'rgb(185, 28, 28)',
muted: '#4b5563',
muted: '#4b5563'
},
bg: {
danger: '#dc3545'
@@ -15,13 +15,14 @@ const lightTheme = {
},
menubar: {
bg: 'rgb(44, 44, 44)',
bg: 'rgb(44, 44, 44)'
},
sidebar: {
color: 'rgb(52, 52, 52)',
muted: '#4b5563',
bg: '#F3F3F3',
dragbar: 'rgb(200, 200, 200)',
workspace: {
bg: '#e1e1e1'
@@ -54,8 +55,8 @@ const lightTheme = {
},
dropdown: {
color: "rgb(48 48 48)",
iconColor: "rgb(75, 85, 99)",
color: 'rgb(48 48 48)',
iconColor: 'rgb(75, 85, 99)',
bg: '#fff',
hoverBg: '#e9e9e9',
shadow: 'rgb(50 50 93 / 25%) 0px 6px 12px -2px, rgb(0 0 0 / 30%) 0px 3px 7px -3px',
@@ -106,7 +107,7 @@ const lightTheme = {
active: {
bg: '#dcdcdc',
hoverBg: '#dcdcdc'
},
}
},
gridBorder: '#f4f4f4'
}
@@ -125,9 +126,9 @@ const lightTheme = {
},
body: {
color: 'rgb(52, 52, 52)',
bg: 'white',
bg: 'white'
},
input : {
input: {
bg: 'white',
border: '#ccc',
focusBorder: '#8b8b8b'
@@ -194,10 +195,13 @@ const lightTheme = {
table: {
border: '#efefef',
thead : {
thead: {
color: '#616161'
},
striped: '#f3f3f3'
striped: '#f3f3f3',
input: {
color: '#000000'
}
},
plainGrid: {

View File

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

View File

@@ -36,12 +36,19 @@ const importPostmanV2CollectionItem = (brunoParent, item) => {
}
} else {
if(i.request) {
let url = '';
if(typeof i.request.url === 'string') {
url = i.request.url;
} else {
url = get(i, 'request.url.raw') || '';
}
const brunoRequestItem = {
uid: uuid(),
name: i.name,
type: 'http-request',
request: {
url: get(i, 'request.url.raw'),
url: url,
method: i.request.method,
headers: [],
params: [],

View File

@@ -1,14 +1,14 @@
import get from 'lodash/get';
import each from 'lodash/each';
import filter from 'lodash/filter';
import qs from 'qs';
import { rawRequest, gql } from 'graphql';
import { sendHttpRequestInBrowser } from './browser';
import { isElectron } from 'utils/common/platform';
import cancelTokens, { deleteCancelToken } from 'utils/network/cancelTokens';
export const sendNetworkRequest = async (item, options) => {
return new Promise((resolve, reject) => {
if (item.type === 'http-request') {
if (['http-request', 'graphql-request'].includes(item.type)) {
const timeStart = Date.now();
sendHttpRequest(item.draft ? item.draft.request : item.request, options)
.then((response) => {
@@ -79,6 +79,15 @@ const sendHttpRequest = async (request, options) => {
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(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) => {
if (isElectron()) {
return new Promise((resolve, reject) => {

View File

@@ -1,5 +1,5 @@
{
"version": "0.2.0",
"version": "0.3.0",
"name": "bruno",
"description": "Opensource API Client",
"homepage": "https://www.usebruno.com",
@@ -30,7 +30,7 @@
},
"devDependencies": {
"electron": "^21.1.1",
"electron-builder": "23.0.2",
"electron-builder": "23.3.3",
"electron-icon-maker": "^0.0.5"
}
}

22
packages/bruno-graphql-docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# dependencies
node_modules
yarn.lock
pnpm-lock.yaml
package-lock.json
.pnp
.pnp.js
# testing
coverage
# production
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,39 @@
{
"name": "@usebruno/graphql-docs",
"version": "0.1.0",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"files": [
"dist",
"package.json"
],
"scripts": {
"build": "rollup -c"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"@types/markdown-it": "^12.2.3",
"@types/react": "^18.0.25",
"graphql": "^16.6.0",
"markdown-it": "^13.0.1",
"postcss": "^8.4.18",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"rollup": "3.2.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"peerDependencies": {
"graphql": "^16.6.0",
"markdown-it": "^13.0.1",
"react": "^17.0.2"
},
"overrides": {
"rollup": "3.2.5"
}
}

View File

@@ -0,0 +1,8 @@
# bruno-graphql-docs
Standalone graphql docs explorer module forked from [graphiql](https://github.com/graphql/graphiql)
### Publish to Npm Registry
```bash
npm publish --access=public
```

View File

@@ -0,0 +1,46 @@
const { nodeResolve } = require("@rollup/plugin-node-resolve");
const commonjs = require("@rollup/plugin-commonjs");
const typescript = require("@rollup/plugin-typescript");
const dts = require("rollup-plugin-dts");
const postcss = require("rollup-plugin-postcss");
const { terser } = require("rollup-plugin-terser");
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const packageJson = require("./package.json");
module.exports = [
{
input: "src/index.ts",
output: [
{
file: packageJson.main,
format: "cjs",
sourcemap: true,
},
{
file: packageJson.module,
format: "esm",
sourcemap: true,
},
],
plugins: [
postcss({
minimize: true,
extensions: ['.css']
}),
peerDepsExternal(),
nodeResolve({
extensions: ['.css']
}),
commonjs(),
typescript({ tsconfig: "./tsconfig.json" }),
terser()
],
external: ["react", "react-dom", "index.css"]
},
{
input: "dist/esm/index.d.ts",
output: [{ file: "dist/index.d.ts", format: "esm" }],
plugins: [dts.default()],
}
];

View File

@@ -0,0 +1,233 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, { ReactNode } from 'react';
import { GraphQLSchema, isType, GraphQLNamedType, GraphQLError } from 'graphql';
import { FieldType } from './DocExplorer/types';
import FieldDoc from './DocExplorer/FieldDoc';
import SchemaDoc from './DocExplorer/SchemaDoc';
import SearchBox from './DocExplorer/SearchBox';
import SearchResults from './DocExplorer/SearchResults';
import TypeDoc from './DocExplorer/TypeDoc';
type NavStackItem = {
name: string;
title?: string;
search?: string;
def?: GraphQLNamedType | FieldType;
};
const initialNav: NavStackItem = {
name: 'Schema',
title: 'Documentation Explorer',
};
type DocExplorerProps = {
schema?: GraphQLSchema | null;
schemaErrors?: readonly GraphQLError[];
children?: ReactNode | null;
};
type DocExplorerState = {
navStack: NavStackItem[];
};
/**
* DocExplorer
*
* Shows documentations for GraphQL definitions from the schema.
*
* Props:
*
* - schema: A required GraphQLSchema instance that provides GraphQL document
* definitions.
*
* Children:
*
* - Any provided children will be positioned in the right-hand-side of the
* top bar. Typically this will be a "close" button for temporary explorer.
*
*/
export class DocExplorer extends React.Component<
DocExplorerProps,
DocExplorerState
> {
// handleClickTypeOrField: OnClickTypeFunction | OnClickFieldFunction
constructor(props: DocExplorerProps) {
super(props);
this.state = { navStack: [initialNav] };
}
shouldComponentUpdate(
nextProps: DocExplorerProps,
nextState: DocExplorerState,
) {
return (
this.props.schema !== nextProps.schema ||
this.state.navStack !== nextState.navStack ||
this.props.schemaErrors !== nextProps.schemaErrors
);
}
render() {
const { schema, schemaErrors } = this.props;
const navStack = this.state.navStack;
const navItem = navStack[navStack.length - 1];
let content;
if (schemaErrors) {
content = (
<div className="error-container">{'Error fetching schema'}</div>
);
} else if (schema === undefined) {
// Schema is undefined when it is being loaded via introspection.
content = (
<div className="spinner-container">
<div className="spinner" />
</div>
);
} else if (!schema) {
// Schema is null when it explicitly does not exist, typically due to
// an error during introspection.
content = <div className="error-container">{'No Schema Available'}</div>;
} else if (navItem.search) {
content = (
<SearchResults
searchValue={navItem.search}
withinType={navItem.def as GraphQLNamedType}
schema={schema}
onClickType={this.handleClickType}
onClickField={this.handleClickField}
/>
);
} else if (navStack.length === 1) {
content = (
<SchemaDoc schema={schema} onClickType={this.handleClickType} />
);
} else if (isType(navItem.def)) {
content = (
<TypeDoc
schema={schema}
type={navItem.def}
onClickType={this.handleClickType}
onClickField={this.handleClickField}
/>
);
} else {
content = (
<FieldDoc
field={navItem.def as FieldType}
onClickType={this.handleClickType}
/>
);
}
const shouldSearchBoxAppear =
navStack.length === 1 ||
(isType(navItem.def) && 'getFields' in navItem.def);
let prevName;
if (navStack.length > 1) {
prevName = navStack[navStack.length - 2].name;
}
return (
<div className="graphql-docs-container">
<section
className="doc-explorer"
key={navItem.name}
aria-label="Documentation Explorer">
<div className="doc-explorer-title-bar">
{prevName && (
<button
className="doc-explorer-back"
onClick={this.handleNavBackClick}
aria-label={`Go back to ${prevName}`}>
{prevName}
</button>
)}
<div className="doc-explorer-title">
{navItem.title || navItem.name}
</div>
<div className="doc-explorer-rhs">{this.props.children}</div>
</div>
<div className="doc-explorer-contents">
{shouldSearchBoxAppear && (
<SearchBox
value={navItem.search}
placeholder={`Search ${navItem.name}...`}
onSearch={this.handleSearch}
/>
)}
{content}
</div>
</section>
</div>
);
}
// Public API
showDoc(typeOrField: GraphQLNamedType | FieldType) {
const navStack = this.state.navStack;
const topNav = navStack[navStack.length - 1];
if (topNav.def !== typeOrField) {
this.setState({
navStack: navStack.concat([
{
name: typeOrField.name,
def: typeOrField,
},
]),
});
}
}
// Public API
showDocForReference(reference: any) {
if (reference && reference.kind === 'Type') {
this.showDoc(reference.type);
} else if (reference.kind === 'Field') {
this.showDoc(reference.field);
} else if (reference.kind === 'Argument' && reference.field) {
this.showDoc(reference.field);
} else if (reference.kind === 'EnumValue' && reference.type) {
this.showDoc(reference.type);
}
}
// Public API
showSearch(search: string) {
const navStack = this.state.navStack.slice();
const topNav = navStack[navStack.length - 1];
navStack[navStack.length - 1] = { ...topNav, search };
this.setState({ navStack });
}
reset() {
this.setState({ navStack: [initialNav] });
}
handleNavBackClick = () => {
if (this.state.navStack.length > 1) {
this.setState({ navStack: this.state.navStack.slice(0, -1) });
}
};
handleClickType = (type: GraphQLNamedType) => {
this.showDoc(type);
};
handleClickField = (field: FieldType) => {
this.showDoc(field);
};
handleSearch = (value: string) => {
this.showSearch(value);
};
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import { GraphQLArgument } from 'graphql';
import TypeLink from './TypeLink';
import DefaultValue from './DefaultValue';
import { OnClickTypeFunction } from './types';
type ArgumentProps = {
arg: GraphQLArgument;
onClickType: OnClickTypeFunction;
showDefaultValue?: boolean;
};
export default function Argument({
arg,
onClickType,
showDefaultValue,
}: ArgumentProps) {
return (
<span className="arg">
<span className="arg-name">{arg.name}</span>
{': '}
<TypeLink type={arg.type} onClick={onClickType} />
{showDefaultValue !== false && <DefaultValue field={arg} />}
</span>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import { astFromValue, print, ValueNode } from 'graphql';
import { FieldType } from './types';
const printDefault = (ast?: ValueNode | null): string => {
if (!ast) {
return '';
}
return print(ast);
};
type DefaultValueProps = {
field: FieldType;
};
export default function DefaultValue({ field }: DefaultValueProps) {
// field.defaultValue could be null or false, so be careful here!
if ('defaultValue' in field && field.defaultValue !== undefined) {
return (
<span>
{' = '}
<span className="arg-default-value">
{printDefault(astFromValue(field.defaultValue, field.type))}
</span>
</span>
);
}
return null;
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import { DirectiveNode } from 'graphql';
type DirectiveProps = {
directive: DirectiveNode;
};
export default function Directive({ directive }: DirectiveProps) {
return (
<span className="doc-category-item" id={directive.name.value}>
{'@'}
{directive.name.value}
</span>
);
}

View File

@@ -0,0 +1,129 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Argument from './Argument';
import Directive from './Directive';
import MarkdownContent from './MarkdownContent';
import TypeLink from './TypeLink';
import { GraphQLArgument, DirectiveNode } from 'graphql';
import { OnClickTypeFunction, FieldType } from './types';
type FieldDocProps = {
field?: FieldType;
onClickType: OnClickTypeFunction;
};
export default function FieldDoc({ field, onClickType }: FieldDocProps) {
const [showDeprecated, handleShowDeprecated] = React.useState(false);
let argsDef;
let deprecatedArgsDef;
if (field && 'args' in field && field.args.length > 0) {
argsDef = (
<div id="doc-args" className="doc-category">
<div className="doc-category-title">{'arguments'}</div>
{field.args
.filter(arg => !arg.deprecationReason)
.map((arg: GraphQLArgument) => (
<div key={arg.name} className="doc-category-item">
<div>
<Argument arg={arg} onClickType={onClickType} />
</div>
<MarkdownContent
className="doc-value-description"
markdown={arg.description}
/>
{arg && 'deprecationReason' in arg && (
<MarkdownContent
className="doc-deprecation"
markdown={arg?.deprecationReason}
/>
)}
</div>
))}
</div>
);
const deprecatedArgs = field.args.filter(arg =>
Boolean(arg.deprecationReason),
);
if (deprecatedArgs.length > 0) {
deprecatedArgsDef = (
<div id="doc-deprecated-args" className="doc-category">
<div className="doc-category-title">{'deprecated arguments'}</div>
{!showDeprecated ? (
<button
className="show-btn"
onClick={() => handleShowDeprecated(!showDeprecated)}>
{'Show deprecated arguments...'}
</button>
) : (
deprecatedArgs.map((arg, i) => (
<div key={i}>
<div>
<Argument arg={arg} onClickType={onClickType} />
</div>
<MarkdownContent
className="doc-value-description"
markdown={arg.description}
/>
{arg && 'deprecationReason' in arg && (
<MarkdownContent
className="doc-deprecation"
markdown={arg?.deprecationReason}
/>
)}
</div>
))
)}
</div>
);
}
}
let directivesDef;
if (
field &&
field.astNode &&
field.astNode.directives &&
field.astNode.directives.length > 0
) {
directivesDef = (
<div id="doc-directives" className="doc-category">
<div className="doc-category-title">{'directives'}</div>
{field.astNode.directives.map((directive: DirectiveNode) => (
<div key={directive.name.value} className="doc-category-item">
<div>
<Directive directive={directive} />
</div>
</div>
))}
</div>
);
}
return (
<div>
<MarkdownContent
className="doc-type-description"
markdown={field?.description || 'No Description'}
/>
{field && 'deprecationReason' in field && (
<MarkdownContent
className="doc-deprecation"
markdown={field?.deprecationReason}
/>
)}
<div className="doc-category">
<div className="doc-category-title">{'type'}</div>
<TypeLink type={field?.type} onClick={onClickType} />
</div>
{argsDef}
{directivesDef}
{deprecatedArgsDef}
</div>
);
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import MD from 'markdown-it';
type Maybe<T> = T | null | undefined;
const md = new MD({
// render urls as links, à la github-flavored markdown
linkify: true,
});
type MarkdownContentProps = {
markdown?: Maybe<string>;
className?: string;
};
export default function MarkdownContent({
markdown,
className,
}: MarkdownContentProps) {
if (!markdown) {
return <div />;
}
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: md.render(markdown) }}
/>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import TypeLink from './TypeLink';
import MarkdownContent from './MarkdownContent';
import { GraphQLSchema } from 'graphql';
import { OnClickTypeFunction } from './types';
type SchemaDocProps = {
schema: GraphQLSchema;
onClickType: OnClickTypeFunction;
};
// Render the top level Schema
export default function SchemaDoc({ schema, onClickType }: SchemaDocProps) {
const queryType = schema.getQueryType();
const mutationType = schema.getMutationType && schema.getMutationType();
const subscriptionType =
schema.getSubscriptionType && schema.getSubscriptionType();
return (
<div>
<MarkdownContent
className="doc-type-description"
markdown={
schema.description ||
'A GraphQL schema provides a root type for each kind of operation.'
}
/>
<div className="doc-category">
<div className="doc-category-title">{'root types'}</div>
<div className="doc-category-item">
<span className="keyword">{'query'}</span>
{': '}
<TypeLink type={queryType} onClick={onClickType} />
</div>
{mutationType && (
<div className="doc-category-item">
<span className="keyword">{'mutation'}</span>
{': '}
<TypeLink type={mutationType} onClick={onClickType} />
</div>
)}
{subscriptionType && (
<div className="doc-category-item">
<span className="keyword">{'subscription'}</span>
{': '}
<TypeLink type={subscriptionType} onClick={onClickType} />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, { ChangeEventHandler } from 'react';
import debounce from '../../utility/debounce';
type OnSearchFn = (value: string) => void;
type SearchBoxProps = {
value?: string;
placeholder: string;
onSearch: OnSearchFn;
};
type SearchBoxState = {
value: string;
};
export default class SearchBox extends React.Component<
SearchBoxProps,
SearchBoxState
> {
debouncedOnSearch: OnSearchFn;
constructor(props: SearchBoxProps) {
super(props);
this.state = { value: props.value || '' };
this.debouncedOnSearch = debounce(200, this.props.onSearch);
}
render() {
return (
<label className="search-box">
<div className="search-box-icon" aria-hidden="true">
{'\u26b2'}
</div>
<input
value={this.state.value}
onChange={this.handleChange}
type="text"
placeholder={this.props.placeholder}
aria-label={this.props.placeholder}
/>
{this.state.value && (
<button
className="search-box-clear"
onClick={this.handleClear}
aria-label="Clear search input">
{'\u2715'}
</button>
)}
</label>
);
}
handleChange: ChangeEventHandler<HTMLInputElement> = event => {
const value = event.currentTarget.value;
this.setState({ value });
this.debouncedOnSearch(value);
};
handleClear = () => {
this.setState({ value: '' });
this.props.onSearch('');
};
}

View File

@@ -0,0 +1,164 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, { ReactNode } from 'react';
import { GraphQLSchema, GraphQLNamedType } from 'graphql';
import Argument from './Argument';
import TypeLink from './TypeLink';
import { OnClickFieldFunction, OnClickTypeFunction } from './types';
type SearchResultsProps = {
schema: GraphQLSchema;
withinType?: GraphQLNamedType;
searchValue: string;
onClickType: OnClickTypeFunction;
onClickField: OnClickFieldFunction;
};
export default class SearchResults extends React.Component<
SearchResultsProps,
{}
> {
shouldComponentUpdate(nextProps: SearchResultsProps) {
return (
this.props.schema !== nextProps.schema ||
this.props.searchValue !== nextProps.searchValue
);
}
render() {
const searchValue = this.props.searchValue;
const withinType = this.props.withinType;
const schema = this.props.schema;
const onClickType = this.props.onClickType;
const onClickField = this.props.onClickField;
const matchedWithin: ReactNode[] = [];
const matchedTypes: ReactNode[] = [];
const matchedFields: ReactNode[] = [];
const typeMap = schema.getTypeMap();
let typeNames = Object.keys(typeMap);
// Move the within type name to be the first searched.
if (withinType) {
typeNames = typeNames.filter(n => n !== withinType.name);
typeNames.unshift(withinType.name);
}
for (const typeName of typeNames) {
if (
matchedWithin.length + matchedTypes.length + matchedFields.length >=
100
) {
break;
}
const type = typeMap[typeName];
if (withinType !== type && isMatch(typeName, searchValue)) {
matchedTypes.push(
<div className="doc-category-item" key={typeName}>
<TypeLink type={type} onClick={onClickType} />
</div>,
);
}
if (type && 'getFields' in type) {
const fields = type.getFields();
Object.keys(fields).forEach(fieldName => {
const field = fields[fieldName];
let matchingArgs;
if (!isMatch(fieldName, searchValue)) {
if ('args' in field && field.args.length) {
matchingArgs = field.args.filter(arg =>
isMatch(arg.name, searchValue),
);
if (matchingArgs.length === 0) {
return;
}
} else {
return;
}
}
const match = (
<div className="doc-category-item" key={typeName + '.' + fieldName}>
{withinType !== type && [
<TypeLink key="type" type={type} onClick={onClickType} />,
'.',
]}
<a
className="field-name"
onClick={event => onClickField(field, type, event)}>
{field.name}
</a>
{matchingArgs && [
'(',
<span key="args">
{matchingArgs.map(arg => (
<Argument
key={arg.name}
arg={arg}
onClickType={onClickType}
showDefaultValue={false}
/>
))}
</span>,
')',
]}
</div>
);
if (withinType === type) {
matchedWithin.push(match);
} else {
matchedFields.push(match);
}
});
}
}
if (
matchedWithin.length + matchedTypes.length + matchedFields.length ===
0
) {
return <span className="doc-alert-text">{'No results found.'}</span>;
}
if (withinType && matchedTypes.length + matchedFields.length > 0) {
return (
<div>
{matchedWithin}
<div className="doc-category">
<div className="doc-category-title">{'other results'}</div>
{matchedTypes}
{matchedFields}
</div>
</div>
);
}
return (
<div className="doc-search-items">
{matchedWithin}
{matchedTypes}
{matchedFields}
</div>
);
}
}
function isMatch(sourceText: string, searchValue: string) {
try {
const escaped = searchValue.replace(/[^_0-9A-Za-z]/g, ch => '\\' + ch);
return sourceText.search(new RegExp(escaped, 'i')) !== -1;
} catch (e) {
return sourceText.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1;
}
}

View File

@@ -0,0 +1,260 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, { ReactNode } from 'react';
import {
GraphQLSchema,
GraphQLObjectType,
GraphQLInterfaceType,
GraphQLUnionType,
GraphQLEnumType,
GraphQLType,
GraphQLEnumValue,
} from 'graphql';
import Argument from './Argument';
import MarkdownContent from './MarkdownContent';
import TypeLink from './TypeLink';
import DefaultValue from './DefaultValue';
import { FieldType, OnClickTypeFunction, OnClickFieldFunction } from './types';
type TypeDocProps = {
schema: GraphQLSchema;
type: GraphQLType;
onClickType: OnClickTypeFunction;
onClickField: OnClickFieldFunction;
};
type TypeDocState = {
showDeprecated: boolean;
};
export default class TypeDoc extends React.Component<
TypeDocProps,
TypeDocState
> {
constructor(props: TypeDocProps) {
super(props);
this.state = { showDeprecated: false };
}
shouldComponentUpdate(nextProps: TypeDocProps, nextState: TypeDocState) {
return (
this.props.type !== nextProps.type ||
this.props.schema !== nextProps.schema ||
this.state.showDeprecated !== nextState.showDeprecated
);
}
render() {
const schema = this.props.schema;
const type = this.props.type;
const onClickType = this.props.onClickType;
const onClickField = this.props.onClickField;
let typesTitle: string | null = null;
let types: readonly (GraphQLObjectType | GraphQLInterfaceType)[] = [];
if (type instanceof GraphQLUnionType) {
typesTitle = 'possible types';
types = schema.getPossibleTypes(type);
} else if (type instanceof GraphQLInterfaceType) {
typesTitle = 'implementations';
types = schema.getPossibleTypes(type);
} else if (type instanceof GraphQLObjectType) {
typesTitle = 'implements';
types = type.getInterfaces();
}
let typesDef;
if (types && types.length > 0) {
typesDef = (
<div id="doc-types" className="doc-category">
<div className="doc-category-title">{typesTitle}</div>
{types.map(subtype => (
<div key={subtype.name} className="doc-category-item">
<TypeLink type={subtype} onClick={onClickType} />
</div>
))}
</div>
);
}
// InputObject and Object
let fieldsDef;
let deprecatedFieldsDef;
if (type && 'getFields' in type) {
const fieldMap = type.getFields();
const fields = Object.keys(fieldMap).map(name => fieldMap[name]);
fieldsDef = (
<div id="doc-fields" className="doc-category">
<div className="doc-category-title">{'fields'}</div>
{fields
.filter(field => !field.deprecationReason)
.map(field => (
<Field
key={field.name}
type={type}
field={field}
onClickType={onClickType}
onClickField={onClickField}
/>
))}
</div>
);
const deprecatedFields = fields.filter(field =>
Boolean(field.deprecationReason),
);
if (deprecatedFields.length > 0) {
deprecatedFieldsDef = (
<div id="doc-deprecated-fields" className="doc-category">
<div className="doc-category-title">{'deprecated fields'}</div>
{!this.state.showDeprecated ? (
<button className="show-btn" onClick={this.handleShowDeprecated}>
{'Show deprecated fields...'}
</button>
) : (
deprecatedFields.map(field => (
<Field
key={field.name}
type={type}
field={field}
onClickType={onClickType}
onClickField={onClickField}
/>
))
)}
</div>
);
}
}
let valuesDef: ReactNode;
let deprecatedValuesDef: ReactNode;
if (type instanceof GraphQLEnumType) {
const values = type.getValues();
valuesDef = (
<div className="doc-category">
<div className="doc-category-title">{'values'}</div>
{values
.filter(value => Boolean(!value.deprecationReason))
.map(value => (
<EnumValue key={value.name} value={value} />
))}
</div>
);
const deprecatedValues = values.filter(value =>
Boolean(value.deprecationReason),
);
if (deprecatedValues.length > 0) {
deprecatedValuesDef = (
<div className="doc-category">
<div className="doc-category-title">{'deprecated values'}</div>
{!this.state.showDeprecated ? (
<button className="show-btn" onClick={this.handleShowDeprecated}>
{'Show deprecated values...'}
</button>
) : (
deprecatedValues.map(value => (
<EnumValue key={value.name} value={value} />
))
)}
</div>
);
}
}
return (
<div>
<MarkdownContent
className="doc-type-description"
markdown={
('description' in type && type.description) || 'No Description'
}
/>
{type instanceof GraphQLObjectType && typesDef}
{fieldsDef}
{deprecatedFieldsDef}
{valuesDef}
{deprecatedValuesDef}
{!(type instanceof GraphQLObjectType) && typesDef}
</div>
);
}
handleShowDeprecated = () => this.setState({ showDeprecated: true });
}
type FieldProps = {
type: GraphQLType;
field: FieldType;
onClickType: OnClickTypeFunction;
onClickField: OnClickFieldFunction;
};
function Field({ type, field, onClickType, onClickField }: FieldProps) {
return (
<div className="doc-category-item">
<a
className="field-name"
onClick={event => onClickField(field, type, event)}>
{field.name}
</a>
{'args' in field &&
field.args &&
field.args.length > 0 && [
'(',
<span key="args">
{field.args
.filter(arg => !arg.deprecationReason)
.map(arg => (
<Argument key={arg.name} arg={arg} onClickType={onClickType} />
))}
</span>,
')',
]}
{': '}
<TypeLink type={field.type} onClick={onClickType} />
<DefaultValue field={field} />
{field.description && (
<MarkdownContent
className="field-short-description"
markdown={field.description}
/>
)}
{'deprecationReason' in field && field.deprecationReason && (
<MarkdownContent
className="doc-deprecation"
markdown={field.deprecationReason}
/>
)}
</div>
);
}
type EnumValue = {
value: GraphQLEnumValue;
};
function EnumValue({ value }: EnumValue) {
return (
<div className="doc-category-item">
<div className="enum-value">{value.name}</div>
<MarkdownContent
className="doc-value-description"
markdown={value.description}
/>
{value.deprecationReason && (
<MarkdownContent
className="doc-deprecation"
markdown={value.deprecationReason}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import {
GraphQLList,
GraphQLNonNull,
GraphQLType,
GraphQLNamedType,
} from 'graphql';
import { OnClickTypeFunction } from './types';
type Maybe<T> = T | null | undefined;
type TypeLinkProps = {
type?: Maybe<GraphQLType>;
onClick?: OnClickTypeFunction;
};
export default function TypeLink(props: TypeLinkProps) {
const onClick = props.onClick ? props.onClick : () => null;
return renderType(props.type, onClick);
}
function renderType(type: Maybe<GraphQLType>, onClick: OnClickTypeFunction) {
if (type instanceof GraphQLNonNull) {
return (
<span>
{renderType(type.ofType, onClick)}
{'!'}
</span>
);
}
if (type instanceof GraphQLList) {
return (
<span>
{'['}
{renderType(type.ofType, onClick)}
{']'}
</span>
);
}
return (
<a
className="type-name"
onClick={event => {
event.preventDefault();
onClick(type as GraphQLNamedType, event);
}}
href="#">
{type?.name}
</a>
);
}

View File

@@ -0,0 +1,35 @@
import { MouseEvent } from 'react';
import {
GraphQLField,
GraphQLInputField,
GraphQLArgument,
GraphQLObjectType,
GraphQLInterfaceType,
GraphQLInputObjectType,
GraphQLType,
GraphQLNamedType,
} from 'graphql';
export type FieldType =
| GraphQLField<{}, {}, {}>
| GraphQLInputField
| GraphQLArgument;
export type OnClickFieldFunction = (
field: FieldType,
type?:
| GraphQLObjectType
| GraphQLInterfaceType
| GraphQLInputObjectType
| GraphQLType,
event?: MouseEvent,
) => void;
export type OnClickTypeFunction = (
type: GraphQLNamedType,
event?: MouseEvent<HTMLAnchorElement>,
) => void;
export type OnClickFieldOrTypeFunction =
| OnClickFieldFunction
| OnClickTypeFunction;

View File

@@ -0,0 +1,297 @@
.graphql-docs-container .doc-explorer {
background: white;
}
.graphql-docs-container .doc-explorer-title-bar,
.graphql-docs-container .history-title-bar {
cursor: default;
display: flex;
line-height: 14px;
padding: 8px 8px 5px;
position: relative;
user-select: none;
}
.graphql-docs-container .doc-explorer-title,
.graphql-docs-container .history-title {
flex: 1;
font-weight: bold;
overflow-x: hidden;
padding: 10px 0 10px 10px;
text-align: center;
text-overflow: ellipsis;
user-select: text;
white-space: nowrap;
}
.graphql-docs-container .doc-explorer-back {
color: #3B5998;
cursor: pointer;
margin: -7px 0 -6px -8px;
overflow-x: hidden;
padding: 17px 12px 16px 16px;
text-overflow: ellipsis;
white-space: nowrap;
background: 0;
border: 0;
line-height: 14px;
}
.doc-explorer-narrow .doc-explorer-back {
width: 0;
}
.graphql-docs-container .doc-explorer-back:before {
border-left: 2px solid #3B5998;
border-top: 2px solid #3B5998;
content: '';
display: inline-block;
height: 9px;
margin: 0 3px -1px 0;
position: relative;
transform: rotate(-45deg);
width: 9px;
}
.graphql-docs-container .doc-explorer-rhs {
position: relative;
}
.graphql-docs-container .doc-explorer-contents,
.graphql-docs-container .history-contents {
background-color: #ffffff;
border-top: 1px solid #d6d6d6;
bottom: 0;
left: 0;
overflow-y: auto;
padding: 20px 15px;
position: absolute;
right: 0;
top: 47px;
}
.graphql-docs-container .doc-explorer-contents {
min-width: 300px;
}
.graphql-docs-container .doc-type-description p:first-child ,
.graphql-docs-container .doc-type-description blockquote:first-child {
margin-top: 0;
}
.graphql-docs-container .doc-explorer-contents a {
cursor: pointer;
text-decoration: none;
}
.graphql-docs-container .doc-explorer-contents a:hover {
text-decoration: underline;
}
.graphql-docs-container .doc-value-description > :first-child {
margin-top: 4px;
}
.graphql-docs-container .doc-value-description > :last-child {
margin-bottom: 4px;
}
.graphql-docs-container .doc-type-description code,
.graphql-docs-container .doc-type-description pre,
.graphql-docs-container .doc-category code,
.graphql-docs-container .doc-category pre {
--saf-0: rgba(var(--sk_foreground_low,29,28,29),0.13);
font-size: 12px;
line-height: 1.50001;
font-variant-ligatures: none;
white-space: pre;
white-space: pre-wrap;
word-wrap: break-word;
word-break: normal;
-webkit-tab-size: 4;
-moz-tab-size: 4;
tab-size: 4;
}
.graphql-docs-container .doc-type-description code,
.graphql-docs-container .doc-category code {
padding: 2px 3px 1px;
border: 1px solid var(--saf-0);
border-radius: 3px;
background-color: rgba(var(--sk_foreground_min,29,28,29),.04);
color: #e01e5a;
background-color: white;
}
.graphql-docs-container .doc-category {
margin: 20px 0;
}
.graphql-docs-container .doc-category-title {
border-bottom: 1px solid #e0e0e0;
color: #777;
cursor: default;
font-size: 14px;
font-variant: small-caps;
font-weight: bold;
letter-spacing: 1px;
margin: 0 -15px 10px 0;
padding: 10px 0;
user-select: none;
}
.graphql-docs-container .doc-category-item {
margin: 12px 0;
color: #555;
}
.graphql-docs-container .keyword {
color: #B11A04;
}
.graphql-docs-container .type-name {
color: #CA9800;
}
.graphql-docs-container .field-name {
color: #1F61A0;
}
.graphql-docs-container .field-short-description {
color: #666;
margin-left: 5px;
overflow: hidden;
text-overflow: ellipsis;
}
.graphql-docs-container .enum-value {
color: #0B7FC7;
}
.graphql-docs-container .arg-name {
color: #8B2BB9;
}
.graphql-docs-container .arg {
display: block;
margin-left: 1em;
}
.graphql-docs-container .arg:first-child:last-child,
.graphql-docs-container .arg:first-child:nth-last-child(2),
.graphql-docs-container .arg:first-child:nth-last-child(2) ~ .arg {
display: inherit;
margin: inherit;
}
.graphql-docs-container .arg:first-child:nth-last-child(2):after {
content: ', ';
}
.graphql-docs-container .arg-default-value {
color: #43A047;
}
.graphql-docs-container .doc-deprecation {
background: #fffae8;
box-shadow: inset 0 0 1px #bfb063;
color: #867F70;
line-height: 16px;
margin: 8px -8px;
max-height: 80px;
overflow: hidden;
padding: 8px;
border-radius: 3px;
}
.graphql-docs-container .doc-deprecation:before {
content: 'Deprecated:';
color: #c79b2e;
cursor: default;
display: block;
font-size: 9px;
font-weight: bold;
letter-spacing: 1px;
line-height: 1;
padding-bottom: 5px;
text-transform: uppercase;
user-select: none;
}
.graphql-docs-container .doc-deprecation > :first-child {
margin-top: 0;
}
.graphql-docs-container .doc-deprecation > :last-child {
margin-bottom: 0;
}
.graphql-docs-container .show-btn {
-webkit-appearance: initial;
display: block;
border-radius: 3px;
border: solid 1px #ccc;
text-align: center;
padding: 8px 12px 10px;
width: 100%;
box-sizing: border-box;
background: #fbfcfc;
color: #555;
cursor: pointer;
}
.graphql-docs-container .search-box {
border-bottom: 1px solid #d3d6db;
display: flex;
align-items: center;
font-size: 14px;
margin: -15px -15px 12px 0;
position: relative;
}
.graphql-docs-container .search-box-icon {
cursor: pointer;
display: block;
font-size: 24px;
transform: rotate(-45deg);
user-select: none;
}
.graphql-docs-container .search-box .search-box-clear {
background-color: #d0d0d0;
border-radius: 12px;
color: #fff;
cursor: pointer;
font-size: 11px;
padding: 1px 5px 2px;
position: absolute;
right: 3px;
user-select: none;
border: 0;
}
.graphql-docs-container .search-box .search-box-clear:hover {
background-color: #b9b9b9;
}
.graphql-docs-container .search-box > input {
border: none;
box-sizing: border-box;
font-size: 14px;
outline: none;
padding: 6px 24px 8px 20px;
width: 100%;
}
.graphql-docs-container .error-container {
font-weight: bold;
left: 0;
letter-spacing: 1px;
opacity: 0.5;
position: absolute;
right: 0;
text-align: center;
text-transform: uppercase;
top: 50%;
transform: translate(0, -50%);
}

View File

@@ -0,0 +1,8 @@
import { DocExplorer } from "./components/DocExplorer";
// Todo: Rollup throws error
import './index.css';
export {
DocExplorer
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* Provided a duration and a function, returns a new function which is called
* `duration` milliseconds after the last call.
*/
export default function debounce<F extends (...args: any[]) => any>(
duration: number,
fn: F,
) {
let timeout: number | null;
return function (this: any, ...args: Parameters<F>) {
if (timeout) {
window.clearTimeout(timeout);
}
timeout = window.setTimeout(() => {
timeout = null;
fn.apply(this, args);
}, duration);
};
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",
"declaration": true,
"declarationDir": "types",
"sourceMap": true,
"outDir": "dist",
"moduleResolution": "node",
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"exclude": [
"dist",
"node_modules",
"src/**/*.test.tsx"
],
}

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/schema",
"version": "0.1.0",
"version": "0.2.0",
"main": "src/index.js",
"files": [
"src",

View File

@@ -2,6 +2,11 @@
The schema definition for collections
### Publish to Npm Registry
```bash
npm publish --access=public
```
### Collection schema
```bash
id Unique id (when persisted to a db)

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 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({
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(),
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(),
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
multipartForm: Yup.array().of(keyValueSchema).nullable(),
graphql: graphqlBodySchema.nullable(),
}).noUnknown(true).strict();
// Right now, the request schema is very tightly coupled with http request

View File

@@ -7,6 +7,7 @@ const { devices } = require('@playwright/test');
*/
// require('dotenv').config();
process.env.PLAYWRIGHT = "1";
/**
* @see https://playwright.dev/docs/test-configuration

View File

@@ -48,8 +48,8 @@ Woof! If you like project, hit that ⭐ button !!
### Stay in touch 🌐
[Twitter](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com)
[Website](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq)
### License 📄
[MIT](license.md)

View File

@@ -1,6 +1,6 @@
const { test, expect } = require('@playwright/test');
const { faker } = require('@faker-js/faker');
const { HomePage } = require('../tests/pages/home.page');
import * as faker from './utils/data-faker';
test.describe('bruno e2e test', () => {
let homePage;
@@ -13,9 +13,22 @@ test.describe('bruno e2e test', () => {
await expect(page).toHaveTitle(/bruno/);
});
test('user should be able to create new collection & new request', async () => {
await homePage.createNewCollection(faker.randomWords);
await expect(homePage.createNewCollectionSuccessToast).toBeVisible();
// using fake data to simulate negative case
await homePage.createNewRequest(faker.randomVerb, faker.randomHttpMethod, faker.randomUrl);
await expect(homePage.networkErrorToast).toBeVisible();
// using real data to simulate positive case
await homePage.createNewRequest('Single User', 'GET', 'https://reqres.in/api/users/2');
await expect(homePage.statusRequestSuccess).toBeVisible();
});
test('user should be able to load & use sample collection', async () => {
await homePage.loadSampleCollection();
await expect(homePage.loadSampleCollectionToastSuccess).toBeVisible();
await expect(homePage.loadSampleCollectionSuccessToast).toBeVisible();
await homePage.getUsers();
await expect(homePage.statusRequestSuccess).toBeVisible();
@@ -33,9 +46,4 @@ test.describe('bruno e2e test', () => {
await expect(homePage.statusRequestSuccess).toBeVisible();
});
test('user should be able to create new collection', async () => {
await homePage.createCollection(faker.random.words());
await expect(homePage.createCollectionToastSuccess).toBeVisible();
})
});

View File

@@ -9,7 +9,7 @@ exports.HomePage = class HomePage {
this.loadSampleCollectionSelector = page.locator('#load-sample-collection');
// sample collection
this.loadSampleCollectionToastSuccess = page.getByText('Sample Collection loaded successfully');
this.loadSampleCollectionSuccessToast = page.getByText('Sample Collection loaded successfully');
this.sampeCollectionSelector = page.locator('#sidebar-collection-name');
this.getUsersSelector = page.getByText('Users');
this.getSingleUserSelector = page.getByText('Single User');
@@ -26,7 +26,12 @@ exports.HomePage = class HomePage {
// create collection
this.collectionNameField = page.locator('#collection-name');
this.submitButton = page.locator(`button[type='submit']`);
this.createCollectionToastSuccess = page.getByText('Collection created');
this.createNewCollectionSuccessToast = page.getByText('Collection created');
this.createNewTab = page.locator('#create-new-tab');
this.requestNameField = page.locator('input[name="requestName"]');
this.methodName = page.locator('#create-new-request-method').first();
this.requestUrlField = page.locator('#request-url');
this.networkErrorToast = page.getByText('Network Error');
}
async open() {
@@ -63,9 +68,19 @@ exports.HomePage = class HomePage {
await this.sendRequestButton.click();
}
async createCollection(collectionName) {
async createNewCollection(collectionName) {
await this.createCollectionSelector.click();
await this.collectionNameField.fill(collectionName);
await this.submitButton.click();
}
async createNewRequest(name, method, endpoint) {
await this.createNewTab.click();
await this.requestNameField.fill(name);
await this.methodName.click();
await this.page.click(`text=${method}`);
await this.requestUrlField.fill(endpoint);
await this.submitButton.click();
await this.sendRequestButton.click();
}
}

View File

@@ -0,0 +1,6 @@
const { faker } = require('@faker-js/faker');
export let randomWords = faker.random.words();
export let randomVerb = faker.hacker.verb();
export let randomHttpMethod = faker.internet.httpMethod();
export let randomUrl = faker.internet.url();