Skip to content

Commit

Permalink
Add user facing setting for persisting headers (#2895)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
TheMightyPenguin authored Jan 9, 2023
1 parent 6652744 commit ccba2f3
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 15 deletions.
6 changes: 6 additions & 0 deletions .changeset/sweet-foxes-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'graphiql': minor
'@graphiql/react': minor
---

Add user facing setting for persisting headers
33 changes: 33 additions & 0 deletions packages/graphiql-react/src/editor/__tests__/tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { StorageAPI } from '@graphiql/toolkit';
import {
createTab,
fuzzyExtractOperationName,
getDefaultTabState,
clearHeadersFromTabs,
STORAGE_KEY,
} from '../tabs';

describe('createTab', () => {
Expand Down Expand Up @@ -141,3 +144,33 @@ describe('getDefaultTabState', () => {
});
});
});

describe('clearHeadersFromTabs', () => {
const createMockStorage = () => {
const mockStorage = new Map();
return mockStorage as unknown as StorageAPI;
};

it('preserves tab state except for headers', () => {
const storage = createMockStorage();
const stateWithoutHeaders = {
operationName: 'test',
query: 'query test {\n test {\n id\n }\n}',
test: {
a: 'test',
},
};
const stateWithHeaders = {
...stateWithoutHeaders,
headers: `{ "authorization": "secret" }`,
};
storage.set(STORAGE_KEY, JSON.stringify(stateWithHeaders));

clearHeadersFromTabs(storage);

expect(JSON.parse(storage.get(STORAGE_KEY)!)).toEqual({
...stateWithoutHeaders,
headers: null,
});
});
});
60 changes: 56 additions & 4 deletions packages/graphiql-react/src/editor/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import {
visit,
} from 'graphql';
import { VariableToType } from 'graphql-language-service';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import { useStorageContext } from '../storage';
import { createContextHook, createNullableContext } from '../utility/context';
Expand All @@ -24,6 +31,9 @@ import {
useSetEditorValues,
useStoreTabs,
useSynchronizeActiveTabValues,
clearHeadersFromTabs,
serializeTabState,
STORAGE_KEY as STORAGE_KEY_TABS,
} from './tabs';
import { CodeMirrorEditor } from './types';
import { STORAGE_KEY as STORAGE_KEY_VARIABLES } from './variable-editor';
Expand Down Expand Up @@ -139,6 +149,10 @@ export type EditorContextType = TabsState & {
* If the contents of the headers editor are persisted in storage.
*/
shouldPersistHeaders: boolean;
/**
* Changes if headers should be persisted.
*/
setShouldPersistHeaders(persist: boolean): void;
};

export const EditorContext =
Expand Down Expand Up @@ -260,14 +274,23 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
null,
);

const [shouldPersistHeaders, setShouldPersistHeadersInternal] = useState(
() => {
const isStored = storage?.get(PERSIST_HEADERS_STORAGE_KEY) !== null;
return props.shouldPersistHeaders !== false && isStored
? storage?.get(PERSIST_HEADERS_STORAGE_KEY) === 'true'
: Boolean(props.shouldPersistHeaders);
},
);

useSynchronizeValue(headerEditor, props.headers);
useSynchronizeValue(queryEditor, props.query);
useSynchronizeValue(responseEditor, props.response);
useSynchronizeValue(variableEditor, props.variables);

const storeTabs = useStoreTabs({
storage,
shouldPersistHeaders: props.shouldPersistHeaders,
shouldPersistHeaders,
});

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

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

const setShouldPersistHeaders = useCallback(
(persist: boolean) => {
if (persist) {
storage?.set(STORAGE_KEY_HEADERS, headerEditor?.getValue() ?? '');
const serializedTabs = serializeTabState(tabState, true);
storage?.set(STORAGE_KEY_TABS, serializedTabs);
} else {
storage?.set(STORAGE_KEY_HEADERS, '');
clearHeadersFromTabs(storage);
}
setShouldPersistHeadersInternal(persist);
storage?.set(PERSIST_HEADERS_STORAGE_KEY, persist.toString());
},
[storage, tabState, headerEditor],
);

const lastShouldPersistHeadersProp = useRef<boolean | undefined>(undefined);
useEffect(() => {
const propValue = Boolean(props.shouldPersistHeaders);
if (lastShouldPersistHeadersProp.current !== propValue) {
setShouldPersistHeaders(propValue);
lastShouldPersistHeadersProp.current = propValue;
}
}, [props.shouldPersistHeaders, setShouldPersistHeaders]);

const synchronizeActiveTabValues = useSynchronizeActiveTabValues({
queryEditor,
variableEditor,
Expand Down Expand Up @@ -454,7 +502,8 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
externalFragments,
validationRules,

shouldPersistHeaders: props.shouldPersistHeaders || false,
shouldPersistHeaders,
setShouldPersistHeaders,
}),
[
tabState,
Expand All @@ -475,7 +524,8 @@ export function EditorContextProvider(props: EditorContextProviderProps) {
externalFragments,
validationRules,

props.shouldPersistHeaders,
shouldPersistHeaders,
setShouldPersistHeaders,
],
);

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

export const useEditorContext = createContextHook(EditorContext);

const PERSIST_HEADERS_STORAGE_KEY = 'shouldPersistHeaders';

const DEFAULT_QUERY = `# Welcome to GraphiQL
#
# GraphiQL is an in-browser tool for writing, validating, and
Expand Down
38 changes: 28 additions & 10 deletions packages/graphiql-react/src/editor/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,19 @@ export function useSynchronizeActiveTabValues({
);
}

export function serializeTabState(
tabState: TabsState,
shouldPersistHeaders = false,
) {
return JSON.stringify(tabState, (key, value) =>
key === 'hash' ||
key === 'response' ||
(!shouldPersistHeaders && key === 'headers')
? null
: value,
);
}

export function useStoreTabs({
storage,
shouldPersistHeaders,
Expand All @@ -225,15 +238,7 @@ export function useStoreTabs({
);
return useCallback(
(currentState: TabsState) => {
store(
JSON.stringify(currentState, (key, value) =>
key === 'hash' ||
key === 'response' ||
(!shouldPersistHeaders && key === 'headers')
? null
: value,
),
);
store(serializeTabState(currentState, shouldPersistHeaders));
},
[shouldPersistHeaders, store],
);
Expand Down Expand Up @@ -338,6 +343,19 @@ export function fuzzyExtractOperationName(str: string): string | null {
return match?.[2] ?? null;
}

export function clearHeadersFromTabs(storage: StorageAPI | null) {
const persistedTabs = storage?.get(STORAGE_KEY);
if (persistedTabs) {
const parsedTabs = JSON.parse(persistedTabs);
storage?.set(
STORAGE_KEY,
JSON.stringify(parsedTabs, (key, value) =>
key === 'headers' ? null : value,
),
);
}
}

const DEFAULT_TITLE = '<untitled>';

const STORAGE_KEY = 'tabState';
export const STORAGE_KEY = 'tabState';
51 changes: 50 additions & 1 deletion packages/graphiql/src/components/GraphiQL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ export function GraphiQL({
validationRules={validationRules}
variables={variables}
>
<GraphiQLInterface {...props} />
<GraphiQLInterface
showPersistHeadersSettings={shouldPersistHeaders !== false}
{...props}
/>
</GraphiQLProvider>
);
}
Expand Down Expand Up @@ -198,6 +201,11 @@ export type GraphiQLInterfaceProps = WriteableEditorProps &
* editor.
*/
toolbar?: GraphiQLToolbarConfig;
/**
* Indicates if settings for persisting headers should appear in the
* settings modal.
*/
showPersistHeadersSettings?: boolean;
};

export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
Expand Down Expand Up @@ -749,6 +757,47 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
}}
/>
</div>
{props.showPersistHeadersSettings ? (
<div className="graphiql-dialog-section">
<div>
<div className="graphiql-dialog-section-title">
Persist headers
</div>
<div className="graphiql-dialog-section-caption">
Save headers upon reloading.{' '}
<span className="graphiql-warning-text">
Only enable if you trust this device.
</span>
</div>
</div>
<ButtonGroup>
<Button
type="button"
id="enable-persist-headers"
className={
editorContext.shouldPersistHeaders ? 'active' : undefined
}
onClick={() => {
editorContext.setShouldPersistHeaders(true);
}}
>
On
</Button>
<Button
type="button"
id="disable-persist-headers"
className={
!editorContext.shouldPersistHeaders ? 'active' : undefined
}
onClick={() => {
editorContext.setShouldPersistHeaders(false);
}}
>
Off
</Button>
</ButtonGroup>
</div>
) : null}
<div className="graphiql-dialog-section">
<div>
<div className="graphiql-dialog-section-title">Theme</div>
Expand Down
52 changes: 52 additions & 0 deletions packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,58 @@ describe('GraphiQL', () => {
});
}); // panel resizing

it('allows the user to control persisting headers if it is true', async () => {
const { container, findByText } = render(
<GraphiQL shouldPersistHeaders fetcher={noOpFetcher} />,
);

act(() => {
fireEvent.click(
container.querySelector('[aria-label="Open settings dialog"]')!,
);
});

const element = await findByText('Persist headers');
expect(element).toBeInTheDocument();
});

it('allows the user to control persisting headers if it is not passed in', async () => {
const { container, findByText } = render(
<GraphiQL fetcher={noOpFetcher} />,
);

act(() => {
fireEvent.click(
container.querySelector('[aria-label="Open settings dialog"]')!,
);
});

const element = await findByText('Persist headers');
expect(element).toBeInTheDocument();
});

it('does not allow the user to control persisting headers is false', async () => {
const { container, findByText } = render(
<GraphiQL shouldPersistHeaders={false} fetcher={noOpFetcher} />,
);

act(() => {
fireEvent.click(
container.querySelector('[aria-label="Open settings dialog"]')!,
);
});

const callback = async () => {
try {
await findByText('Persist headers');
} catch (e) {
// eslint-disable-next-line no-throw-literal
throw 'failed';
}
};
await expect(callback).rejects.toEqual('failed');
});

describe('Tabs', () => {
it('show tabs if there are more than one', async () => {
const { container } = render(<GraphiQL fetcher={noOpFetcher} />);
Expand Down
5 changes: 5 additions & 0 deletions packages/graphiql/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ reach-portal .graphiql-dialog-section-caption {
color: hsla(var(--color-neutral), var(--alpha-secondary));
}

reach-portal .graphiql-warning-text {
color: hsl(var(--color-warning));
font-weight: var(--font-weight-medium);
}

reach-portal .graphiql-table {
border-collapse: collapse;
width: 100%;
Expand Down

0 comments on commit ccba2f3

Please sign in to comment.