Skip to content

Commit 4fb231f

Browse files
klippxdimaMachina
andauthored
Allow GraphiQL apps control over closing tabs (#3563)
* Allow GraphiQL apps control over closing tabs * Add changeset * Code review + lint * Adds e2e spec for controlling closing of tabs * Apply suggestions from code review * some changes from my git stash * rollback * cspell * fix * rm some previous code * fix tests --------- Co-authored-by: Dimitri POSTOLOV <[email protected]>
1 parent c4f0761 commit 4fb231f

File tree

5 files changed

+105
-32
lines changed

5 files changed

+105
-32
lines changed

.changeset/empty-lobsters-breathe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'graphiql': minor
3+
---
4+
5+
Add new prop `confirmCloseTab` to allow control of closing tabs

packages/graphiql/cypress/e2e/tabs.cy.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,28 @@ describe('Tabs', () => {
7878
response: { data: { id: 'abc123' } },
7979
});
8080
});
81+
82+
describe('confirmCloseTab()', () => {
83+
it('should keep tab when `Cancel` was clicked', () => {
84+
cy.on('window:confirm', () => false);
85+
cy.visit('/?confirmCloseTab=true');
86+
87+
cy.get('.graphiql-tab-add').click();
88+
89+
cy.get('.graphiql-tab-button + .graphiql-tab-close').eq(1).click();
90+
91+
cy.get('.graphiql-tab-button').should('have.length', 2);
92+
});
93+
94+
it('should close tab when `OK` was clicked', () => {
95+
cy.on('window:confirm', () => true);
96+
cy.visit('/?confirmCloseTab=true');
97+
98+
cy.get('.graphiql-tab-add').click();
99+
100+
cy.get('.graphiql-tab-button + .graphiql-tab-close').eq(1).click();
101+
102+
cy.get('.graphiql-tab-button').should('have.length', 0);
103+
});
104+
});
81105
});

packages/graphiql/resources/renderExample.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,14 @@
88
*
99
* It is used by:
1010
* - the netlify demo
11-
* - end to end tests
11+
* - end-to-end tests
1212
* - webpack dev server
1313
*/
1414

1515
// Parse the search string to get url parameters.
16-
const parameters = {};
17-
for (const entry of window.location.search.slice(1).split('&')) {
18-
const eq = entry.indexOf('=');
19-
if (eq >= 0) {
20-
parameters[decodeURIComponent(entry.slice(0, eq))] = decodeURIComponent(
21-
entry.slice(eq + 1),
22-
);
23-
}
24-
}
16+
const parameters = Object.fromEntries(
17+
new URLSearchParams(location.search).entries(),
18+
);
2519

2620
// When the query and variables string is edited, update the URL bar so
2721
// that it can be easily shared.
@@ -48,6 +42,11 @@ function onTabChange(tabsState) {
4842
updateURL();
4943
}
5044

45+
function confirmCloseTab(index) {
46+
// eslint-disable-next-line no-alert
47+
return confirm(`Are you sure you want to close tab with index ${index}?`);
48+
}
49+
5150
function updateURL() {
5251
const newSearch = Object.entries(parameters)
5352
.filter(([_key, value]) => value)
@@ -91,6 +90,8 @@ root.render(
9190
isHeadersEditorEnabled: true,
9291
shouldPersistHeaders: true,
9392
inputValueDeprecation: GraphQLVersion.includes('15.5') ? undefined : true,
93+
confirmCloseTab:
94+
parameters.confirmCloseTab === 'true' ? confirmCloseTab : undefined,
9495
onTabChange,
9596
forcedTheme: parameters.forcedTheme,
9697
}),

packages/graphiql/src/components/GraphiQL.tsx

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,9 @@ export type GraphiQLProps = Omit<GraphiQLProviderProps, 'children'> &
101101
*
102102
* @see https://github.com/graphql/graphiql#usage
103103
*/
104-
105104
export function GraphiQL({
106105
dangerouslyAssumeSchemaIsValid,
106+
confirmCloseTab,
107107
defaultQuery,
108108
defaultTabs,
109109
externalFragments,
@@ -168,6 +168,7 @@ export function GraphiQL({
168168
variables={variables}
169169
>
170170
<GraphiQLInterface
171+
confirmCloseTab={confirmCloseTab}
171172
showPersistHeadersSettings={shouldPersistHeaders !== false}
172173
disableTabs={props.disableTabs ?? false}
173174
forcedTheme={props.forcedTheme}
@@ -220,19 +221,29 @@ export type GraphiQLInterfaceProps = WriteableEditorProps &
220221
showPersistHeadersSettings?: boolean;
221222
disableTabs?: boolean;
222223
/**
223-
* forcedTheme allows enforcement of a specific theme for GraphiQL.
224+
* `forcedTheme` allows enforcement of a specific theme for GraphiQL.
224225
* This is useful when you want to make sure that GraphiQL is always
225-
* rendered with a specific theme
226+
* rendered with a specific theme.
226227
*/
227228
forcedTheme?: (typeof THEMES)[number];
228229
/**
229230
* Additional class names which will be appended to the container element.
230231
*/
231232
className?: string;
233+
/**
234+
* When the user clicks a close tab button, this function is invoked with
235+
* the index of the tab that is about to be closed. It can return a promise
236+
* that should resolve to `true` (meaning the tab may be closed) or `false`
237+
* (meaning the tab may not be closed).
238+
* @param index The index of the tab that should be closed.
239+
*/
240+
confirmCloseTab?(index: number): Promise<boolean> | boolean;
232241
};
233242

234243
const THEMES = ['light', 'dark', 'system'] as const;
235244

245+
const TAB_CLASS_PREFIX = 'graphiql-session-tab-';
246+
236247
export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
237248
const isHeadersEditorEnabled = props.isHeadersEditorEnabled ?? true;
238249
const editorContext = useEditorContext({ nonNull: true });
@@ -408,9 +419,9 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
408419
);
409420

410421
const handlePluginClick: MouseEventHandler<HTMLButtonElement> = useCallback(
411-
e => {
422+
event => {
412423
const context = pluginContext!;
413-
const pluginIndex = Number(e.currentTarget.dataset.index!);
424+
const pluginIndex = Number(event.currentTarget.dataset.index!);
414425
const plugin = context.plugins.find((_, index) => pluginIndex === index)!;
415426
const isVisible = plugin === context.visiblePlugin;
416427
if (isVisible) {
@@ -470,6 +481,48 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
470481
);
471482

472483
const className = props.className ? ` ${props.className}` : '';
484+
const confirmClose = props.confirmCloseTab;
485+
486+
const handleTabClose: MouseEventHandler<HTMLButtonElement> = useCallback(
487+
async event => {
488+
const tabButton = event.currentTarget
489+
.previousSibling as HTMLButtonElement;
490+
const index = Number(tabButton.id.replace(TAB_CLASS_PREFIX, ''));
491+
492+
/** TODO:
493+
* Move everything after into `editorContext.closeTab` once zustand will be used instead of
494+
* React context, since now we can't use execution context inside editor context, since editor
495+
* context is used in execution context.
496+
*/
497+
const shouldCloseTab = confirmClose ? await confirmClose(index) : true;
498+
499+
if (!shouldCloseTab) {
500+
return;
501+
}
502+
503+
if (editorContext.activeTabIndex === index) {
504+
executionContext.stop();
505+
}
506+
editorContext.closeTab(index);
507+
},
508+
[confirmClose, editorContext, executionContext],
509+
);
510+
511+
const handleTabClick: MouseEventHandler<HTMLButtonElement> = useCallback(
512+
event => {
513+
const index = Number(
514+
event.currentTarget.id.replace(TAB_CLASS_PREFIX, ''),
515+
);
516+
/** TODO:
517+
* Move everything after into `editorContext.changeTab` once zustand will be used instead of
518+
* React context, since now we can't use execution context inside editor context, since editor
519+
* context is used in execution context.
520+
*/
521+
executionContext.stop();
522+
editorContext.changeTab(index);
523+
},
524+
[editorContext, executionContext],
525+
);
473526

474527
return (
475528
<Tooltip.Provider>
@@ -482,7 +535,6 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
482535
{pluginContext?.plugins.map((plugin, index) => {
483536
const isVisible = plugin === pluginContext.visiblePlugin;
484537
const label = `${isVisible ? 'Hide' : 'Show'} ${plugin.title}`;
485-
const Icon = plugin.icon;
486538
return (
487539
<Tooltip key={plugin.title} label={label}>
488540
<UnStyledButton
@@ -492,7 +544,7 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
492544
data-index={index}
493545
aria-label={label}
494546
>
495-
<Icon aria-hidden="true" />
547+
<plugin.icon aria-hidden="true" />
496548
</UnStyledButton>
497549
</Tooltip>
498550
);
@@ -571,22 +623,12 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
571623
>
572624
<Tab.Button
573625
aria-controls="graphiql-session"
574-
id={`graphiql-session-tab-${index}`}
575-
onClick={() => {
576-
executionContext.stop();
577-
editorContext.changeTab(index);
578-
}}
626+
id={`${TAB_CLASS_PREFIX}${index}`}
627+
onClick={handleTabClick}
579628
>
580629
{tab.title}
581630
</Tab.Button>
582-
<Tab.Close
583-
onClick={() => {
584-
if (editorContext.activeTabIndex === index) {
585-
executionContext.stop();
586-
}
587-
editorContext.closeTab(index);
588-
}}
589-
/>
631+
<Tab.Close onClick={handleTabClose} />
590632
</Tab>
591633
))}
592634
{addTab}
@@ -601,9 +643,9 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
601643
</div>
602644
<div
603645
role="tabpanel"
604-
id="graphiql-session"
646+
id="graphiql-session" // used by aria-controls="graphiql-session"
605647
className="graphiql-session"
606-
aria-labelledby={`graphiql-session-tab-${editorContext.activeTabIndex}`}
648+
aria-labelledby={`${TAB_CLASS_PREFIX}${editorContext.activeTabIndex}`}
607649
>
608650
<div ref={editorResize.firstRef}>
609651
<div

resources/custom-words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ wgutils
225225
wincent
226226
wonka
227227
yoshiakis
228+
zustand
228229
zdravo
229230
Здорово
230231
أهلاً

0 commit comments

Comments
 (0)