Skip to content

Commit

Permalink
Allow GraphiQL apps control over closing tabs (#3563)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
klippx and dimaMachina authored Aug 15, 2024
1 parent c4f0761 commit 4fb231f
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/empty-lobsters-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'graphiql': minor
---

Add new prop `confirmCloseTab` to allow control of closing tabs
24 changes: 24 additions & 0 deletions packages/graphiql/cypress/e2e/tabs.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,28 @@ describe('Tabs', () => {
response: { data: { id: 'abc123' } },
});
});

describe('confirmCloseTab()', () => {
it('should keep tab when `Cancel` was clicked', () => {
cy.on('window:confirm', () => false);
cy.visit('/?confirmCloseTab=true');

cy.get('.graphiql-tab-add').click();

cy.get('.graphiql-tab-button + .graphiql-tab-close').eq(1).click();

cy.get('.graphiql-tab-button').should('have.length', 2);
});

it('should close tab when `OK` was clicked', () => {
cy.on('window:confirm', () => true);
cy.visit('/?confirmCloseTab=true');

cy.get('.graphiql-tab-add').click();

cy.get('.graphiql-tab-button + .graphiql-tab-close').eq(1).click();

cy.get('.graphiql-tab-button').should('have.length', 0);
});
});
});
21 changes: 11 additions & 10 deletions packages/graphiql/resources/renderExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,14 @@
*
* It is used by:
* - the netlify demo
* - end to end tests
* - end-to-end tests
* - webpack dev server
*/

// Parse the search string to get url parameters.
const parameters = {};
for (const entry of window.location.search.slice(1).split('&')) {
const eq = entry.indexOf('=');
if (eq >= 0) {
parameters[decodeURIComponent(entry.slice(0, eq))] = decodeURIComponent(
entry.slice(eq + 1),
);
}
}
const parameters = Object.fromEntries(
new URLSearchParams(location.search).entries(),
);

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

function confirmCloseTab(index) {
// eslint-disable-next-line no-alert
return confirm(`Are you sure you want to close tab with index ${index}?`);
}

function updateURL() {
const newSearch = Object.entries(parameters)
.filter(([_key, value]) => value)
Expand Down Expand Up @@ -91,6 +90,8 @@ root.render(
isHeadersEditorEnabled: true,
shouldPersistHeaders: true,
inputValueDeprecation: GraphQLVersion.includes('15.5') ? undefined : true,
confirmCloseTab:
parameters.confirmCloseTab === 'true' ? confirmCloseTab : undefined,
onTabChange,
forcedTheme: parameters.forcedTheme,
}),
Expand Down
86 changes: 64 additions & 22 deletions packages/graphiql/src/components/GraphiQL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ export type GraphiQLProps = Omit<GraphiQLProviderProps, 'children'> &
*
* @see https://github.com/graphql/graphiql#usage
*/

export function GraphiQL({
dangerouslyAssumeSchemaIsValid,
confirmCloseTab,
defaultQuery,
defaultTabs,
externalFragments,
Expand Down Expand Up @@ -168,6 +168,7 @@ export function GraphiQL({
variables={variables}
>
<GraphiQLInterface
confirmCloseTab={confirmCloseTab}
showPersistHeadersSettings={shouldPersistHeaders !== false}
disableTabs={props.disableTabs ?? false}
forcedTheme={props.forcedTheme}
Expand Down Expand Up @@ -220,19 +221,29 @@ export type GraphiQLInterfaceProps = WriteableEditorProps &
showPersistHeadersSettings?: boolean;
disableTabs?: boolean;
/**
* forcedTheme allows enforcement of a specific theme for GraphiQL.
* `forcedTheme` allows enforcement of a specific theme for GraphiQL.
* This is useful when you want to make sure that GraphiQL is always
* rendered with a specific theme
* rendered with a specific theme.
*/
forcedTheme?: (typeof THEMES)[number];
/**
* Additional class names which will be appended to the container element.
*/
className?: string;
/**
* When the user clicks a close tab button, this function is invoked with
* the index of the tab that is about to be closed. It can return a promise
* that should resolve to `true` (meaning the tab may be closed) or `false`
* (meaning the tab may not be closed).
* @param index The index of the tab that should be closed.
*/
confirmCloseTab?(index: number): Promise<boolean> | boolean;
};

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

const TAB_CLASS_PREFIX = 'graphiql-session-tab-';

export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
const isHeadersEditorEnabled = props.isHeadersEditorEnabled ?? true;
const editorContext = useEditorContext({ nonNull: true });
Expand Down Expand Up @@ -408,9 +419,9 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
);

const handlePluginClick: MouseEventHandler<HTMLButtonElement> = useCallback(
e => {
event => {
const context = pluginContext!;
const pluginIndex = Number(e.currentTarget.dataset.index!);
const pluginIndex = Number(event.currentTarget.dataset.index!);
const plugin = context.plugins.find((_, index) => pluginIndex === index)!;
const isVisible = plugin === context.visiblePlugin;
if (isVisible) {
Expand Down Expand Up @@ -470,6 +481,48 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
);

const className = props.className ? ` ${props.className}` : '';
const confirmClose = props.confirmCloseTab;

const handleTabClose: MouseEventHandler<HTMLButtonElement> = useCallback(
async event => {
const tabButton = event.currentTarget
.previousSibling as HTMLButtonElement;
const index = Number(tabButton.id.replace(TAB_CLASS_PREFIX, ''));

/** TODO:
* Move everything after into `editorContext.closeTab` once zustand will be used instead of
* React context, since now we can't use execution context inside editor context, since editor
* context is used in execution context.
*/
const shouldCloseTab = confirmClose ? await confirmClose(index) : true;

if (!shouldCloseTab) {
return;
}

if (editorContext.activeTabIndex === index) {
executionContext.stop();
}
editorContext.closeTab(index);
},
[confirmClose, editorContext, executionContext],
);

const handleTabClick: MouseEventHandler<HTMLButtonElement> = useCallback(
event => {
const index = Number(
event.currentTarget.id.replace(TAB_CLASS_PREFIX, ''),
);
/** TODO:
* Move everything after into `editorContext.changeTab` once zustand will be used instead of
* React context, since now we can't use execution context inside editor context, since editor
* context is used in execution context.
*/
executionContext.stop();
editorContext.changeTab(index);
},
[editorContext, executionContext],
);

return (
<Tooltip.Provider>
Expand All @@ -482,7 +535,6 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
{pluginContext?.plugins.map((plugin, index) => {
const isVisible = plugin === pluginContext.visiblePlugin;
const label = `${isVisible ? 'Hide' : 'Show'} ${plugin.title}`;
const Icon = plugin.icon;
return (
<Tooltip key={plugin.title} label={label}>
<UnStyledButton
Expand All @@ -492,7 +544,7 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
data-index={index}
aria-label={label}
>
<Icon aria-hidden="true" />
<plugin.icon aria-hidden="true" />
</UnStyledButton>
</Tooltip>
);
Expand Down Expand Up @@ -571,22 +623,12 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
>
<Tab.Button
aria-controls="graphiql-session"
id={`graphiql-session-tab-${index}`}
onClick={() => {
executionContext.stop();
editorContext.changeTab(index);
}}
id={`${TAB_CLASS_PREFIX}${index}`}
onClick={handleTabClick}
>
{tab.title}
</Tab.Button>
<Tab.Close
onClick={() => {
if (editorContext.activeTabIndex === index) {
executionContext.stop();
}
editorContext.closeTab(index);
}}
/>
<Tab.Close onClick={handleTabClose} />
</Tab>
))}
{addTab}
Expand All @@ -601,9 +643,9 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
</div>
<div
role="tabpanel"
id="graphiql-session"
id="graphiql-session" // used by aria-controls="graphiql-session"
className="graphiql-session"
aria-labelledby={`graphiql-session-tab-${editorContext.activeTabIndex}`}
aria-labelledby={`${TAB_CLASS_PREFIX}${editorContext.activeTabIndex}`}
>
<div ref={editorResize.firstRef}>
<div
Expand Down
1 change: 1 addition & 0 deletions resources/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ wgutils
wincent
wonka
yoshiakis
zustand
zdravo
Здорово
أهلاً
Expand Down

0 comments on commit 4fb231f

Please sign in to comment.