From 3137a6c4333dad8db8a0eb980d6c6464c7292946 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Sun, 27 Mar 2022 09:54:37 +0200 Subject: [PATCH] feat: tabs (#2197) Co-authored-by: Rikki Schulte Co-authored-by: timsuchanek --- .changeset/lemon-eagles-knock.md | 6 + package.json | 3 +- packages/graphiql/resources/renderExample.js | 12 + packages/graphiql/src/components/GraphiQL.tsx | 424 +++++++++++++++++- packages/graphiql/src/components/Tabs.tsx | 74 +++ .../components/__tests__/GraphiQL.spec.tsx | 49 ++ packages/graphiql/src/css/app.css | 119 +++++ .../fuzzyExtractOperationTitle.spec.ts | 32 ++ .../src/utility/fuzzyExtractOperationTitle.ts | 9 + packages/graphiql/src/utility/guid.ts | 10 + .../src/utility/id-from-tab-contents.ts | 11 + 11 files changed, 729 insertions(+), 20 deletions(-) create mode 100644 .changeset/lemon-eagles-knock.md create mode 100644 packages/graphiql/src/components/Tabs.tsx create mode 100644 packages/graphiql/src/utility/__tests__/fuzzyExtractOperationTitle.spec.ts create mode 100644 packages/graphiql/src/utility/fuzzyExtractOperationTitle.ts create mode 100644 packages/graphiql/src/utility/guid.ts create mode 100644 packages/graphiql/src/utility/id-from-tab-contents.ts diff --git a/.changeset/lemon-eagles-knock.md b/.changeset/lemon-eagles-knock.md new file mode 100644 index 00000000000..a58f6a56512 --- /dev/null +++ b/.changeset/lemon-eagles-knock.md @@ -0,0 +1,6 @@ +--- +"graphiql": minor +--- + +Now featuring: tabs! 🥳 🍾 just opt-in with new prop ``. +You can also both opt-in and provide a handler via ``! diff --git a/package.json b/package.json index 19dda9ac423..d53cc6fad9c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "lint-staged": { "*.{js,ts,jsx,tsx}": [ "eslint --fix", - "prettier --write" + "prettier --write", + "jest" ], "*.{md,html,json,css}": [ "prettier --write" diff --git a/packages/graphiql/resources/renderExample.js b/packages/graphiql/resources/renderExample.js index ab1e8438a60..b0cb0246d7e 100644 --- a/packages/graphiql/resources/renderExample.js +++ b/packages/graphiql/resources/renderExample.js @@ -75,6 +75,15 @@ function onEditOperationName(newOperationName) { updateURL(); } +function onTabChange(tabsState) { + const activeTab = tabsState.tabs[tabsState.activeTabIndex]; + parameters.query = activeTab.query; + parameters.variables = activeTab.variables; + parameters.headers = activeTab.headers; + parameters.operationName = activeTab.operationName; + updateURL(); +} + function updateURL() { var newSearch = '?' + @@ -127,6 +136,9 @@ ReactDOM.render( headerEditorEnabled: true, shouldPersistHeaders: true, inputValueDeprecation: true, + tabs: { + onTabChange: onTabChange, + }, }), document.getElementById('graphiql'), ); diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index 32d17feca42..63ce1d65858 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -70,6 +70,10 @@ import type { import HistoryStore from '../utility/HistoryStore'; import { validateSchema } from 'graphql'; +import { Tab, TabAddButton, Tabs } from './Tabs'; +import { fuzzyExtractOperationTitle } from '../utility/fuzzyExtractOperationTitle'; +import { idFromTabContents } from '../utility/id-from-tab-contents'; +import { guid } from '../utility/guid'; const DEFAULT_DOC_EXPLORER_WIDTH = 350; @@ -292,6 +296,19 @@ export type GraphiQLProps = { * Content to place before the top bar (logo). */ beforeTopBarContent?: React.ReactElement | null; + + /** + * Whether tabs should be enabled. + * default: false + */ + tabs?: + | boolean + | { + /** + * Callback that is invoked onTabChange. + */ + onTabChange?: (tab: TabsState) => void; + }; }; export type GraphiQLState = { @@ -318,6 +335,7 @@ export type GraphiQLState = { operations?: OperationDefinitionNode[]; documentAST?: DocumentNode; maxHistoryLength: number; + tabs: TabsState; }; const stringify = (obj: unknown): string => JSON.stringify(obj, null, 2); @@ -343,6 +361,22 @@ const handleSingleError = ( return error; }; +type TabState = { + id: string; + hash: string; + title: string; + query: string | undefined; + variables: string | undefined; + headers: string | undefined; + operationName: string | undefined; + response: string | undefined; +}; + +type TabsState = { + activeTabIndex: number; + tabs: Array; +}; + /** * The top-level React component for GraphiQL, intended to encompass the entire * browser viewport. @@ -493,16 +527,83 @@ export class GraphiQL extends React.Component { '', ); - // Initialize state - this.state = { - schema, + const initialTabHash = idFromTabContents({ + query, + variables: variables as string, + headers: headers as string, + }); + + const initialTab: TabState = { + id: guid(), + hash: initialTabHash, + title: operationName ?? '', query, variables: variables as string, headers: headers as string, operationName, + response: undefined, + }; + + let rawTabState: string | null = null; + // only load tab state if tabs are enabled + if (this.props.tabs) { + rawTabState = this._storage.get('tabState'); + } + + let tabsState: TabsState; + if (rawTabState === null) { + tabsState = { + activeTabIndex: 0, + tabs: [initialTab], + }; + } else { + tabsState = JSON.parse(rawTabState); + let queryParameterOperationIsWithinTabs = false; + for (const tab of tabsState.tabs) { + // ensure property is present + tab.query = tab.query!; + tab.variables = tab.variables!; + tab.headers = shouldPersistHeaders ? tab.headers! : undefined; + tab.response = undefined; + tab.operationName = undefined; + + tab.id = guid(); + + tab.hash = idFromTabContents(tab); + + if (tab.hash === initialTabHash) { + queryParameterOperationIsWithinTabs = true; + } + } + + if (queryParameterOperationIsWithinTabs === false) { + tabsState.tabs.push(initialTab); + tabsState.activeTabIndex = tabsState.tabs.length - 1; + } + } + + let activeTab = tabsState.tabs[0]; + let index = 0; + for (const tab of tabsState.tabs) { + if (tab.hash === initialTabHash) { + tabsState.activeTabIndex = index; + activeTab = tab; + break; + } + index++; + } + + // Initialize state + this.state = { + tabs: tabsState, + schema, + query: activeTab?.query, + variables: activeTab?.variables, + headers: activeTab?.headers, + operationName: activeTab?.operationName, + response: activeTab?.response ?? response, docExplorerOpen, schemaErrors, - response, editorFlex: Number(this._storage.get('editorFlex')) || 1, secondaryEditorOpen, secondaryEditorHeight: @@ -656,6 +757,45 @@ export class GraphiQL extends React.Component { this.componentIsMounted && this.setState(nextState, callback); }; + private persistTabsState = () => { + if (this.props.tabs) { + this._storage.set( + 'tabState', + JSON.stringify(this.state.tabs, (key, value) => + key === 'response' || + (this.state.shouldPersistHeaders && key === 'headers') + ? undefined + : value, + ), + ); + if (typeof this.props.tabs === 'object') { + this.props.tabs.onTabChange?.(this.state.tabs); + } + } + }; + + private makeHandleOnSelectTab = (index: number) => () => { + this.handleStopQuery(); + this.setState( + state => stateOnSelectTabReducer(index, state), + this.persistTabsState, + ); + }; + + private makeHandleOnCloseTab = (index: number) => () => { + if (this.state.tabs.activeTabIndex === index) { + this.handleStopQuery(); + } + this.setState( + state => stateOnCloseTabReducer(index, state), + this.persistTabsState, + ); + }; + + private handleOnAddTab = () => { + this.setState(state => stateOnTabAddReducer(state), this.persistTabsState); + }; + render() { const children = React.Children.toArray(this.props.children); @@ -722,6 +862,7 @@ export class GraphiQL extends React.Component { ? this.state.secondaryEditorHeight : undefined, }; + const tabsState = this.state.tabs; return (
{ )}
+ {this.props.tabs ? ( + + {tabsState.tabs.map((tab, index) => ( + 1} + onSelect={this.makeHandleOnSelectTab(index)} + onClose={this.makeHandleOnCloseTab(index)} + tabProps={{ + 'aria-controls': 'sessionWrap', + id: `session-tab-${index}`, + }} + /> + ))} + + + ) : null}
{ this.editorBarComponent = n; }} + role="tabpanel" + id="sessionWrap" className="editorBar" + aria-labelledby={`session-tab-${tabsState.activeTabIndex}`} onDoubleClick={this.handleResetResize} onMouseDown={this.handleResizeStart}>
@@ -1052,11 +1218,18 @@ export class GraphiQL extends React.Component { shouldPersistHeaders: Boolean(this.props.shouldPersistHeaders), documentAST: this.state.documentAST, }; - if (this.state.headers && this.state.headers.trim().length > 2) { - fetcherOpts.headers = JSON.parse(this.state.headers); - // if state is not present, but props are - } else if (this.props.headers) { - fetcherOpts.headers = JSON.parse(this.props.headers); + try { + if (this.state.headers && this.state.headers.trim().length > 2) { + fetcherOpts.headers = JSON.parse(this.state.headers); + // if state is not present, but props are + } else if (this.props.headers) { + fetcherOpts.headers = JSON.parse(this.props.headers); + } + } catch (err) { + this.setState({ + response: 'Introspection failed as headers are invalid.', + }); + return; } const fetch = fetcherReturnToPromise( @@ -1412,10 +1585,27 @@ export class GraphiQL extends React.Component { response: GraphiQL.formatResult(fullResponse), }); } else { - this.setState({ - isWaitingForResponse: false, - response: GraphiQL.formatResult(result), - }); + const response = GraphiQL.formatResult(result); + this.setState( + state => ({ + ...state, + tabs: { + ...state.tabs, + tabs: state.tabs.tabs.map((tab, index) => { + if (index !== state.tabs.activeTabIndex) { + return tab; + } + return { + ...tab, + response, + }; + }), + }, + isWaitingForResponse: false, + response, + }), + this.persistTabsState, + ); } } }, @@ -1534,10 +1724,16 @@ export class GraphiQL extends React.Component { this.state.operations, this.state.schema, ); - this.setState({ - query: value, - ...queryFacts, - }); + + this.setState( + state => ({ + ...state, + query: value, + ...queryFacts, + tabs: tabsStateEditQueryReducer(value, state.tabs), + }), + this.persistTabsState, + ); this._storage.set('query', value); if (this.props.onEditQuery) { return this.props.onEditQuery(value, queryFacts?.documentAST); @@ -1592,7 +1788,14 @@ export class GraphiQL extends React.Component { }; handleEditVariables = (value: string) => { - this.setState({ variables: value }); + this.setState( + state => ({ + ...state, + variables: value, + tabs: tabsStateEditVariablesReducer(value, state.tabs), + }), + this.persistTabsState, + ); debounce(500, () => this._storage.set('variables', value))(); if (this.props.onEditVariables) { this.props.onEditVariables(value); @@ -1600,7 +1803,14 @@ export class GraphiQL extends React.Component { }; handleEditHeaders = (value: string) => { - this.setState({ headers: value }); + this.setState( + state => ({ + ...state, + headers: value, + tabs: tabsStateEditHeadersReducer(value, state.tabs), + }), + this.persistTabsState, + ); this.props.shouldPersistHeaders && debounce(500, () => this._storage.set('headers', value))(); if (this.props.onEditHeaders) { @@ -2084,3 +2294,179 @@ function isChildComponentType( return child.type === component; } + +function tabsStateEditHeadersReducer( + value: string, + state: TabsState, +): TabsState { + return { + ...state, + tabs: state.tabs.map((tab, index) => { + if (index !== state.activeTabIndex) { + return tab; + } + return { + ...tab, + headers: value, + hash: idFromTabContents({ + query: tab.query, + headers: value, + variables: tab.variables, + }), + }; + }), + }; +} + +function tabsStateEditVariablesReducer( + value: string, + state: TabsState, +): TabsState { + return { + ...state, + tabs: state.tabs.map((tab, index) => { + if (index !== state.activeTabIndex) { + return tab; + } + return { + ...tab, + variables: value, + hash: idFromTabContents({ + query: tab.query, + headers: tab.headers, + variables: value, + }), + }; + }), + }; +} + +function tabsStateEditQueryReducer(value: string, state: TabsState): TabsState { + return { + ...state, + tabs: state.tabs.map((tab, index) => { + if (index !== state.activeTabIndex) { + return tab; + } + return { + ...tab, + title: fuzzyExtractOperationTitle(value), + query: value, + hash: idFromTabContents({ + query: value, + headers: tab.headers, + variables: tab.variables, + }), + }; + }), + }; +} + +function stateOnSelectTabReducer( + index: number, + state: GraphiQLState, +): GraphiQLState { + const oldActiveTabIndex = state.tabs.activeTabIndex; + const tabs = state.tabs.tabs.map((currentTab, tabIndex) => { + if (tabIndex !== oldActiveTabIndex) { + return currentTab; + } + + return { + ...currentTab, + query: state.query, + variables: state.variables, + operationName: state.operationName, + headers: state.headers, + response: state.response, + hash: idFromTabContents({ + query: state.query, + variables: state.variables, + headers: state.headers, + }), + }; + }); + + const newActiveTab = state.tabs.tabs[index]; + + return { + ...state, + query: newActiveTab.query, + variables: newActiveTab.variables, + operationName: newActiveTab.operationName, + headers: newActiveTab.headers, + response: newActiveTab.response, + tabs: { ...state.tabs, tabs, activeTabIndex: index }, + }; +} + +function stateOnCloseTabReducer( + index: number, + state: GraphiQLState, +): GraphiQLState { + const newActiveTabIndex = + state.tabs.activeTabIndex > 0 ? state.tabs.activeTabIndex - 1 : 0; + const newTabsState = { + ...state.tabs, + activeTabIndex: newActiveTabIndex, + tabs: state.tabs.tabs.filter((_tab, i) => index !== i), + }; + const activeTab = newTabsState.tabs[newActiveTabIndex]; + return { + ...state, + query: activeTab.query, + variables: activeTab.variables, + operationName: activeTab.operationName, + headers: activeTab.headers, + response: activeTab.response, + tabs: newTabsState, + }; +} + +function stateOnTabAddReducer(state: GraphiQLState): GraphiQLState { + const oldActiveTabIndex = state.tabs.activeTabIndex; + + const newTab: TabState = { + id: guid(), + title: '', + headers: '', + variables: '', + query: '', + operationName: '', + response: '', + hash: idFromTabContents({ + query: '', + variables: '', + headers: '', + }), + }; + + const tabs = state.tabs.tabs.map((tab, index) => { + if (index !== oldActiveTabIndex) { + return tab; + } + + return { + ...tab, + headers: state.headers, + variables: state.variables, + query: state.query, + operationName: state.operationName, + response: state.response, + }; + }); + + return { + ...state, + headers: newTab.headers, + variables: newTab.variables, + query: newTab.query, + operationName: newTab.operationName, + response: newTab.response, + tabs: { + ...state.tabs, + activeTabIndex: state.tabs.tabs.length, + tabs: [...tabs, newTab], + }, + }; +} diff --git a/packages/graphiql/src/components/Tabs.tsx b/packages/graphiql/src/components/Tabs.tsx new file mode 100644 index 00000000000..5c8ee94b2dc --- /dev/null +++ b/packages/graphiql/src/components/Tabs.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +/** + * TODO: extract with other components to @graphiql/react + */ + +function TabCloseButton(props: { onClick: () => void }) { + return ( +
{ + ev.stopPropagation(); + props.onClick(); + }} + /> + ); +} + +export type TabProps = { + isActive: boolean; + title: string; + isCloseable: boolean; + onSelect: () => void; + onClose: () => void; + tabProps?: React.ButtonHTMLAttributes<{}>; +}; + +/** + * Generic tab component that implements wai-aria tab spec + */ +export function Tab(props: TabProps): React.ReactElement { + return ( + + ); +} + +export function TabAddButton(props: { onClick: () => void }) { + return ( + + ); +} + +export type TabsProps = { + children: Array; + tabsProps?: React.HTMLAttributes<{}>; +}; +/** + * Generic tablist component that implements wai-aria tab spec + */ +export function Tabs(props: TabsProps) { + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx index 0293c337c1a..25fb74eea19 100644 --- a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx +++ b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx @@ -549,4 +549,53 @@ describe('GraphiQL', () => { spy.mockRestore(); }); + + describe('Tabs', () => { + it('not enabled by default', () => { + const { container } = render(); + expect(container.querySelector('.tabs')).not.toBeInTheDocument(); + }); + it('enable tabs via "tabs" property boolean', () => { + const { container } = render(); + expect(container.querySelector('.tabs')).toBeInTheDocument(); + }); + it('enable tabs via "tabs" property object', () => { + const { container } = render( + , + ); + expect(container.querySelector('.tabs')).toBeInTheDocument(); + }); + it('only one tab is open by default', () => { + const { container } = render(); + expect(container.querySelectorAll('.tabs .tab')).toHaveLength(1); + }); + it('single tab has no close button', () => { + const { container } = render(); + expect(container.querySelector('.tab .close')).not.toBeInTheDocument(); + }); + it('open multiple tabs', () => { + const { container } = render(); + expect(container.querySelectorAll('.tabs .tab')).toHaveLength(1); + fireEvent.click(container.querySelector('.tab-add')); + expect(container.querySelectorAll('.tabs .tab')).toHaveLength(2); + fireEvent.click(container.querySelector('.tab-add')); + expect(container.querySelectorAll('.tabs .tab')).toHaveLength(3); + }); + it('each tab has a close button when multiple tabs are open', () => { + const { container } = render(); + expect(container.querySelectorAll('.tab .close')).toHaveLength(0); + fireEvent.click(container.querySelector('.tab-add')); + expect(container.querySelectorAll('.tab .close')).toHaveLength(2); + fireEvent.click(container.querySelector('.tab-add')); + expect(container.querySelectorAll('.tab .close')).toHaveLength(3); + }); + it('close button removes a tab', () => { + const { container } = render(); + fireEvent.click(container.querySelector('.tab-add')); + expect(container.querySelectorAll('.tab .close')).toHaveLength(2); + fireEvent.click(container.querySelector('.tab .close')); + expect(container.querySelectorAll('.tabs .tab')).toHaveLength(1); + expect(container.querySelectorAll('.tab .close')).toHaveLength(0); + }); + }); }); diff --git a/packages/graphiql/src/css/app.css b/packages/graphiql/src/css/app.css index c972d3b534c..4f133fc7d7f 100644 --- a/packages/graphiql/src/css/app.css +++ b/packages/graphiql/src/css/app.css @@ -488,6 +488,125 @@ div.CodeMirror-lint-tooltip > * + * { color: #000; } +.graphiql-container .tabs { + height: 42px; + background-image: linear-gradient(#f7f7f7, #e2e2e2); + display: flex; + align-items: center; +} + +.graphiql-container .tab { + position: relative; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding-top: 0; + padding-right: 6px; + padding-left: 14px; + height: 100%; + color: rgba(0, 0, 0, 0.6); + + border-left: 1px solid lightgray; + /* + Needed for `button` components. + */ + border-top-style: none; + border-bottom-style: none; + border-right-style: none; +} + +/* + If it's only one tab, we don't have the X button, so we want more padding. + In the .tabs container, we have one more child - the plus button. + So if this tab is first child and the second last at the same time, + this is the case we want to target. +*/ + +.graphiql-container .tab:first-child:nth-last-child(2) { + padding-right: 14px; +} + +.graphiql-container .tab:hover { + background-image: linear-gradient( + rgba(245, 245, 245, 0.7), + rgba(215, 215, 215, 1) + ); + color: rgba(0, 0, 0, 0.8); +} + +.graphiql-container .tab.active { + background-image: linear-gradient( + rgba(233, 233, 233, 0.7), + rgba(205, 205, 205, 1) + ); + color: black; +} + +/* { + background-image: linear-gradient( + rgba(223, 223, 223, 0.5), + rgba(196, 196, 196, 1) + ); +} */ + +.graphiql-container .tab .close { + display: inline-block; + cursor: pointer; + border: none; + background: transparent; + margin-left: 6px; + padding: 3px 6px; + border-radius: 4px; +} + +.graphiql-container .tab:hover .close, +.graphiql-container .tab.active .close { + opacity: 1; +} + +.graphiql-container .tab .close::before { + content: '✕'; + display: inline-block; + font-weight: bold; + font-size: 12px; + color: rgba(0, 0, 0, 0.7); + height: 14px; +} + +.graphiql-container .tab .close:hover { + background: rgba(0, 0, 0, 0.08); +} + +.graphiql-container .tab .close:active { + background: rgba(0, 0, 0, 0.12); +} + +.graphiql-container .tab-add { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + line-height: 1; + font-size: 26px; + padding: 0 8px; + height: 30px; + border-radius: 4px; + color: rgba(0, 0, 0, 0.5); + padding-bottom: 3px; + margin-left: 6px; + cursor: pointer; +} + +.graphiql-container .tab-add:hover { + background: rgba(0, 0, 0, 0.06); +} + +.graphiql-container .tab-add:active { + background: rgba(0, 0, 0, 0.1); +} + /* COLORS */ .graphiql-container .CodeMirror-foldmarker { diff --git a/packages/graphiql/src/utility/__tests__/fuzzyExtractOperationTitle.spec.ts b/packages/graphiql/src/utility/__tests__/fuzzyExtractOperationTitle.spec.ts new file mode 100644 index 00000000000..5ce2769a604 --- /dev/null +++ b/packages/graphiql/src/utility/__tests__/fuzzyExtractOperationTitle.spec.ts @@ -0,0 +1,32 @@ +import { fuzzyExtractOperationTitle } from '../fuzzyExtractOperationTitle'; + +describe('fuzzyExtractionOperationTitle', () => { + it('should extract query names', () => { + expect(fuzzyExtractOperationTitle(' query MyExampleQuery() {}')).toEqual( + 'MyExampleQuery', + ); + }); + it('should extract query names with special characters', () => { + expect(fuzzyExtractOperationTitle(' query My_ExampleQuery() {}')).toEqual( + 'My_ExampleQuery', + ); + }); + it('should extract query names with numbers', () => { + expect(fuzzyExtractOperationTitle(' query My_3xampleQuery() {}')).toEqual( + 'My_3xampleQuery', + ); + }); + it('should extract mutation names with numbers', () => { + expect( + fuzzyExtractOperationTitle(' mutation My_3xampleQuery() {}'), + ).toEqual('My_3xampleQuery'); + }); + it('should return null for anonymous queries', () => { + expect(fuzzyExtractOperationTitle('{}')).toEqual(''); + }); + it('should not extract query names with comments', () => { + expect(fuzzyExtractOperationTitle('# query My_3xampleQuery() {}')).toEqual( + '', + ); + }); +}); diff --git a/packages/graphiql/src/utility/fuzzyExtractOperationTitle.ts b/packages/graphiql/src/utility/fuzzyExtractOperationTitle.ts new file mode 100644 index 00000000000..e7c18c07795 --- /dev/null +++ b/packages/graphiql/src/utility/fuzzyExtractOperationTitle.ts @@ -0,0 +1,9 @@ +/** + * Very simple and quick way of extracting the operation title from a document string (compared to parsing and traversing the whole AST). + */ +export function fuzzyExtractOperationTitle(str: string): string { + const regex = /^[^#](query|subscription|mutation) ([a-zA-Z0-9_]+)/; + const match = regex.exec(str); + + return match?.[2] ?? ''; +} diff --git a/packages/graphiql/src/utility/guid.ts b/packages/graphiql/src/utility/guid.ts new file mode 100644 index 00000000000..cfaa80c4200 --- /dev/null +++ b/packages/graphiql/src/utility/guid.ts @@ -0,0 +1,10 @@ +export function guid() { + const s4 = () => { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + }; + // return id of format 'aaaaaaaa'-'aaaa'-'aaaa'-'aaaa'-'aaaaaaaaaaaa' + // prettier-ignore + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); +} diff --git a/packages/graphiql/src/utility/id-from-tab-contents.ts b/packages/graphiql/src/utility/id-from-tab-contents.ts new file mode 100644 index 00000000000..95adb510431 --- /dev/null +++ b/packages/graphiql/src/utility/id-from-tab-contents.ts @@ -0,0 +1,11 @@ +export function idFromTabContents(params: { + query: string | undefined; + variables: string | undefined; + headers: string | undefined; +}) { + return [ + params.query ?? '', + params.variables ?? '', + params.headers ?? '', + ].join('|'); +}