Skip to content

Commit ccba2f3

Browse files
Add user facing setting for persisting headers (#2895)
* feat: Add user facing setting for persisting headers * Only allow controlling setting if prop value is true/undefined * Clean up tab storage headers when persising headers turns off * add warning message * fix initial value * restore tabs store if toggling on * version changes as "minor" * Simplify logic to show persisting headers settings * sync persist header prop if it changes
1 parent 6652744 commit ccba2f3

7 files changed

Lines changed: 230 additions & 15 deletions

File tree

.changeset/sweet-foxes-drive.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'graphiql': minor
3+
'@graphiql/react': minor
4+
---
5+
6+
Add user facing setting for persisting headers

packages/graphiql-react/src/editor/__tests__/tabs.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { StorageAPI } from '@graphiql/toolkit';
12
import {
23
createTab,
34
fuzzyExtractOperationName,
45
getDefaultTabState,
6+
clearHeadersFromTabs,
7+
STORAGE_KEY,
58
} from '../tabs';
69

710
describe('createTab', () => {
@@ -141,3 +144,33 @@ describe('getDefaultTabState', () => {
141144
});
142145
});
143146
});
147+
148+
describe('clearHeadersFromTabs', () => {
149+
const createMockStorage = () => {
150+
const mockStorage = new Map();
151+
return mockStorage as unknown as StorageAPI;
152+
};
153+
154+
it('preserves tab state except for headers', () => {
155+
const storage = createMockStorage();
156+
const stateWithoutHeaders = {
157+
operationName: 'test',
158+
query: 'query test {\n test {\n id\n }\n}',
159+
test: {
160+
a: 'test',
161+
},
162+
};
163+
const stateWithHeaders = {
164+
...stateWithoutHeaders,
165+
headers: `{ "authorization": "secret" }`,
166+
};
167+
storage.set(STORAGE_KEY, JSON.stringify(stateWithHeaders));
168+
169+
clearHeadersFromTabs(storage);
170+
171+
expect(JSON.parse(storage.get(STORAGE_KEY)!)).toEqual({
172+
...stateWithoutHeaders,
173+
headers: null,
174+
});
175+
});
176+
});

packages/graphiql-react/src/editor/context.tsx

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import {
77
visit,
88
} from 'graphql';
99
import { VariableToType } from 'graphql-language-service';
10-
import { ReactNode, useCallback, useMemo, useState } from 'react';
10+
import {
11+
ReactNode,
12+
useCallback,
13+
useEffect,
14+
useMemo,
15+
useRef,
16+
useState,
17+
} from 'react';
1118

1219
import { useStorageContext } from '../storage';
1320
import { createContextHook, createNullableContext } from '../utility/context';
@@ -24,6 +31,9 @@ import {
2431
useSetEditorValues,
2532
useStoreTabs,
2633
useSynchronizeActiveTabValues,
34+
clearHeadersFromTabs,
35+
serializeTabState,
36+
STORAGE_KEY as STORAGE_KEY_TABS,
2737
} from './tabs';
2838
import { CodeMirrorEditor } from './types';
2939
import { STORAGE_KEY as STORAGE_KEY_VARIABLES } from './variable-editor';
@@ -139,6 +149,10 @@ export type EditorContextType = TabsState & {
139149
* If the contents of the headers editor are persisted in storage.
140150
*/
141151
shouldPersistHeaders: boolean;
152+
/**
153+
* Changes if headers should be persisted.
154+
*/
155+
setShouldPersistHeaders(persist: boolean): void;
142156
};
143157

144158
export const EditorContext =
@@ -260,14 +274,23 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
260274
null,
261275
);
262276

277+
const [shouldPersistHeaders, setShouldPersistHeadersInternal] = useState(
278+
() => {
279+
const isStored = storage?.get(PERSIST_HEADERS_STORAGE_KEY) !== null;
280+
return props.shouldPersistHeaders !== false && isStored
281+
? storage?.get(PERSIST_HEADERS_STORAGE_KEY) === 'true'
282+
: Boolean(props.shouldPersistHeaders);
283+
},
284+
);
285+
263286
useSynchronizeValue(headerEditor, props.headers);
264287
useSynchronizeValue(queryEditor, props.query);
265288
useSynchronizeValue(responseEditor, props.response);
266289
useSynchronizeValue(variableEditor, props.variables);
267290

268291
const storeTabs = useStoreTabs({
269292
storage,
270-
shouldPersistHeaders: props.shouldPersistHeaders,
293+
shouldPersistHeaders,
271294
});
272295

273296
// We store this in state but never update it. By passing a function we only
@@ -304,6 +327,31 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
304327

305328
const [tabState, setTabState] = useState<TabsState>(initialState.tabState);
306329

330+
const setShouldPersistHeaders = useCallback(
331+
(persist: boolean) => {
332+
if (persist) {
333+
storage?.set(STORAGE_KEY_HEADERS, headerEditor?.getValue() ?? '');
334+
const serializedTabs = serializeTabState(tabState, true);
335+
storage?.set(STORAGE_KEY_TABS, serializedTabs);
336+
} else {
337+
storage?.set(STORAGE_KEY_HEADERS, '');
338+
clearHeadersFromTabs(storage);
339+
}
340+
setShouldPersistHeadersInternal(persist);
341+
storage?.set(PERSIST_HEADERS_STORAGE_KEY, persist.toString());
342+
},
343+
[storage, tabState, headerEditor],
344+
);
345+
346+
const lastShouldPersistHeadersProp = useRef<boolean | undefined>(undefined);
347+
useEffect(() => {
348+
const propValue = Boolean(props.shouldPersistHeaders);
349+
if (lastShouldPersistHeadersProp.current !== propValue) {
350+
setShouldPersistHeaders(propValue);
351+
lastShouldPersistHeadersProp.current = propValue;
352+
}
353+
}, [props.shouldPersistHeaders, setShouldPersistHeaders]);
354+
307355
const synchronizeActiveTabValues = useSynchronizeActiveTabValues({
308356
queryEditor,
309357
variableEditor,
@@ -454,7 +502,8 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
454502
externalFragments,
455503
validationRules,
456504

457-
shouldPersistHeaders: props.shouldPersistHeaders || false,
505+
shouldPersistHeaders,
506+
setShouldPersistHeaders,
458507
}),
459508
[
460509
tabState,
@@ -475,7 +524,8 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
475524
externalFragments,
476525
validationRules,
477526

478-
props.shouldPersistHeaders,
527+
shouldPersistHeaders,
528+
setShouldPersistHeaders,
479529
],
480530
);
481531

@@ -488,6 +538,8 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
488538

489539
export const useEditorContext = createContextHook(EditorContext);
490540

541+
const PERSIST_HEADERS_STORAGE_KEY = 'shouldPersistHeaders';
542+
491543
const DEFAULT_QUERY = `# Welcome to GraphiQL
492544
#
493545
# GraphiQL is an in-browser tool for writing, validating, and

packages/graphiql-react/src/editor/tabs.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,19 @@ export function useSynchronizeActiveTabValues({
209209
);
210210
}
211211

212+
export function serializeTabState(
213+
tabState: TabsState,
214+
shouldPersistHeaders = false,
215+
) {
216+
return JSON.stringify(tabState, (key, value) =>
217+
key === 'hash' ||
218+
key === 'response' ||
219+
(!shouldPersistHeaders && key === 'headers')
220+
? null
221+
: value,
222+
);
223+
}
224+
212225
export function useStoreTabs({
213226
storage,
214227
shouldPersistHeaders,
@@ -225,15 +238,7 @@ export function useStoreTabs({
225238
);
226239
return useCallback(
227240
(currentState: TabsState) => {
228-
store(
229-
JSON.stringify(currentState, (key, value) =>
230-
key === 'hash' ||
231-
key === 'response' ||
232-
(!shouldPersistHeaders && key === 'headers')
233-
? null
234-
: value,
235-
),
236-
);
241+
store(serializeTabState(currentState, shouldPersistHeaders));
237242
},
238243
[shouldPersistHeaders, store],
239244
);
@@ -338,6 +343,19 @@ export function fuzzyExtractOperationName(str: string): string | null {
338343
return match?.[2] ?? null;
339344
}
340345

346+
export function clearHeadersFromTabs(storage: StorageAPI | null) {
347+
const persistedTabs = storage?.get(STORAGE_KEY);
348+
if (persistedTabs) {
349+
const parsedTabs = JSON.parse(persistedTabs);
350+
storage?.set(
351+
STORAGE_KEY,
352+
JSON.stringify(parsedTabs, (key, value) =>
353+
key === 'headers' ? null : value,
354+
),
355+
);
356+
}
357+
}
358+
341359
const DEFAULT_TITLE = '<untitled>';
342360

343-
const STORAGE_KEY = 'tabState';
361+
export const STORAGE_KEY = 'tabState';

packages/graphiql/src/components/GraphiQL.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,10 @@ export function GraphiQL({
158158
validationRules={validationRules}
159159
variables={variables}
160160
>
161-
<GraphiQLInterface {...props} />
161+
<GraphiQLInterface
162+
showPersistHeadersSettings={shouldPersistHeaders !== false}
163+
{...props}
164+
/>
162165
</GraphiQLProvider>
163166
);
164167
}
@@ -198,6 +201,11 @@ export type GraphiQLInterfaceProps = WriteableEditorProps &
198201
* editor.
199202
*/
200203
toolbar?: GraphiQLToolbarConfig;
204+
/**
205+
* Indicates if settings for persisting headers should appear in the
206+
* settings modal.
207+
*/
208+
showPersistHeadersSettings?: boolean;
201209
};
202210

203211
export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
@@ -749,6 +757,47 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
749757
}}
750758
/>
751759
</div>
760+
{props.showPersistHeadersSettings ? (
761+
<div className="graphiql-dialog-section">
762+
<div>
763+
<div className="graphiql-dialog-section-title">
764+
Persist headers
765+
</div>
766+
<div className="graphiql-dialog-section-caption">
767+
Save headers upon reloading.{' '}
768+
<span className="graphiql-warning-text">
769+
Only enable if you trust this device.
770+
</span>
771+
</div>
772+
</div>
773+
<ButtonGroup>
774+
<Button
775+
type="button"
776+
id="enable-persist-headers"
777+
className={
778+
editorContext.shouldPersistHeaders ? 'active' : undefined
779+
}
780+
onClick={() => {
781+
editorContext.setShouldPersistHeaders(true);
782+
}}
783+
>
784+
On
785+
</Button>
786+
<Button
787+
type="button"
788+
id="disable-persist-headers"
789+
className={
790+
!editorContext.shouldPersistHeaders ? 'active' : undefined
791+
}
792+
onClick={() => {
793+
editorContext.setShouldPersistHeaders(false);
794+
}}
795+
>
796+
Off
797+
</Button>
798+
</ButtonGroup>
799+
</div>
800+
) : null}
752801
<div className="graphiql-dialog-section">
753802
<div>
754803
<div className="graphiql-dialog-section-title">Theme</div>

packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,58 @@ describe('GraphiQL', () => {
380380
});
381381
}); // panel resizing
382382

383+
it('allows the user to control persisting headers if it is true', async () => {
384+
const { container, findByText } = render(
385+
<GraphiQL shouldPersistHeaders fetcher={noOpFetcher} />,
386+
);
387+
388+
act(() => {
389+
fireEvent.click(
390+
container.querySelector('[aria-label="Open settings dialog"]')!,
391+
);
392+
});
393+
394+
const element = await findByText('Persist headers');
395+
expect(element).toBeInTheDocument();
396+
});
397+
398+
it('allows the user to control persisting headers if it is not passed in', async () => {
399+
const { container, findByText } = render(
400+
<GraphiQL fetcher={noOpFetcher} />,
401+
);
402+
403+
act(() => {
404+
fireEvent.click(
405+
container.querySelector('[aria-label="Open settings dialog"]')!,
406+
);
407+
});
408+
409+
const element = await findByText('Persist headers');
410+
expect(element).toBeInTheDocument();
411+
});
412+
413+
it('does not allow the user to control persisting headers is false', async () => {
414+
const { container, findByText } = render(
415+
<GraphiQL shouldPersistHeaders={false} fetcher={noOpFetcher} />,
416+
);
417+
418+
act(() => {
419+
fireEvent.click(
420+
container.querySelector('[aria-label="Open settings dialog"]')!,
421+
);
422+
});
423+
424+
const callback = async () => {
425+
try {
426+
await findByText('Persist headers');
427+
} catch (e) {
428+
// eslint-disable-next-line no-throw-literal
429+
throw 'failed';
430+
}
431+
};
432+
await expect(callback).rejects.toEqual('failed');
433+
});
434+
383435
describe('Tabs', () => {
384436
it('show tabs if there are more than one', async () => {
385437
const { container } = render(<GraphiQL fetcher={noOpFetcher} />);

packages/graphiql/src/style.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ reach-portal .graphiql-dialog-section-caption {
312312
color: hsla(var(--color-neutral), var(--alpha-secondary));
313313
}
314314

315+
reach-portal .graphiql-warning-text {
316+
color: hsl(var(--color-warning));
317+
font-weight: var(--font-weight-medium);
318+
}
319+
315320
reach-portal .graphiql-table {
316321
border-collapse: collapse;
317322
width: 100%;

0 commit comments

Comments
 (0)