Skip to content

Commit

Permalink
[redesign] implement new design for history plugin (#2571)
Browse files Browse the repository at this point in the history
* add icons

* don't invoce callback when programmatically change resizable element

* implement history in new design

* visually separate favourite history items

* add save button when editing history label

* add changeset

* add missing changesets in retrospect

* fix typos

* fix e2e tests

* remove input outline
  • Loading branch information
thomasheyenbrock committed Aug 2, 2022
1 parent 65c827a commit bd219ed
Show file tree
Hide file tree
Showing 33 changed files with 583 additions and 433 deletions.
5 changes: 5 additions & 0 deletions .changeset/fifty-elephants-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphiql/react': minor
---

Add a `Dropdown` ui component
5 changes: 5 additions & 0 deletions .changeset/fluffy-pianos-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphiql/react': minor
---

Add toolbar components (`ExecuteButton` and `ToolbarButton`)
5 changes: 5 additions & 0 deletions .changeset/mean-chefs-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphiql/react': minor
---

Add a component for rendering the history plugin
5 changes: 5 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ module.exports = {
],
env: {
test: {
presets: [
[require.resolve('@babel/preset-env'), envConfig],
[require.resolve('@babel/preset-react'), { runtime: 'automatic' }],
require.resolve('@babel/preset-typescript'),
],
plugins: [require.resolve('babel-plugin-macros')],
},
development: {
Expand Down
4 changes: 4 additions & 0 deletions packages/graphiql-react/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ const base = require('../../jest.config.base')(__dirname);

module.exports = {
...base,
moduleNameMapper: {
'\\.svg$': `${__dirname}/mocks/svg`,
...base.moduleNameMapper,
},
};
7 changes: 7 additions & 0 deletions packages/graphiql-react/mocks/svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = function MockedIcon(props) {
return (
<svg {...props}>
<title>mocked icon</title>
</svg>
);
};
1 change: 1 addition & 0 deletions packages/graphiql-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"set-value": "^4.1.0"
},
"devDependencies": {
"@testing-library/react": "9.4.1",
"@types/codemirror": "^5.60.5",
"@types/set-value": "^4.0.1",
"@vitejs/plugin-react": "^1.3.0",
Expand Down
131 changes: 131 additions & 0 deletions packages/graphiql-react/src/history/__tests__/components.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
// @ts-expect-error
fireEvent,
render,
} from '@testing-library/react';
import { ComponentProps } from 'react';
import { formatQuery, HistoryItem } from '../components';
import { HistoryContextProvider } from '../context';
import { useEditorContext } from '../../editor';

jest.mock('../../editor', () => {
const mockedSetQueryEditor = jest.fn();
const mockedSetVariableEditor = jest.fn();
const mockedSetHeaderEditor = jest.fn();
return {
useEditorContext() {
return {
queryEditor: { setValue: mockedSetQueryEditor },
variableEditor: { setValue: mockedSetVariableEditor },
headerEditor: { setValue: mockedSetHeaderEditor },
};
},
};
});

const mockQuery = /* GraphQL */ `
query Test($string: String) {
test {
hasArgs(string: $string)
}
}
`;

const mockVariables = JSON.stringify({ string: 'string' });

const mockHeaders = JSON.stringify({ foo: 'bar' });

const mockOperationName = 'Test';

type QueryHistoryItemProps = ComponentProps<typeof HistoryItem>;

function QueryHistoryItemWithContext(props: QueryHistoryItemProps) {
return (
<HistoryContextProvider>
<HistoryItem {...props} />
</HistoryContextProvider>
);
}

const baseMockProps: QueryHistoryItemProps = {
item: {
query: mockQuery,
variables: mockVariables,
headers: mockHeaders,
favorite: false,
},
};

function getMockProps(
customProps?: Partial<QueryHistoryItemProps>,
): QueryHistoryItemProps {
return {
...baseMockProps,
...customProps,
item: { ...baseMockProps.item, ...customProps?.item },
};
}

describe('QueryHistoryItem', () => {
const mockedSetQueryEditor = useEditorContext()?.queryEditor
?.setValue as jest.Mock;
const mockedSetVariableEditor = useEditorContext()?.variableEditor
?.setValue as jest.Mock;
const mockedSetHeaderEditor = useEditorContext()?.headerEditor
?.setValue as jest.Mock;
beforeEach(() => {
mockedSetQueryEditor.mockClear();
mockedSetVariableEditor.mockClear();
mockedSetHeaderEditor.mockClear();
});
it('renders operationName if label is not provided', () => {
const otherMockProps = { item: { operationName: mockOperationName } };
const props = getMockProps(otherMockProps);
const { container } = render(<QueryHistoryItemWithContext {...props} />);
expect(
container.querySelector('button.graphiql-history-item-label')!
.textContent,
).toBe(mockOperationName);
});

it('renders a string version of the query if label or operation name are not provided', () => {
const { container } = render(
<QueryHistoryItemWithContext {...getMockProps()} />,
);
expect(
container.querySelector('button.graphiql-history-item-label')!
.textContent,
).toBe(formatQuery(mockQuery));
});

it('selects the item when history label button is clicked', () => {
const otherMockProps = { item: { operationName: mockOperationName } };
const mockProps = getMockProps(otherMockProps);
const { container } = render(
<QueryHistoryItemWithContext {...mockProps} />,
);
fireEvent.click(
container.querySelector('button.graphiql-history-item-label')!,
);
expect(mockedSetQueryEditor).toHaveBeenCalledTimes(1);
expect(mockedSetQueryEditor).toHaveBeenCalledWith(mockProps.item.query);
expect(mockedSetVariableEditor).toHaveBeenCalledTimes(1);
expect(mockedSetVariableEditor).toHaveBeenCalledWith(
mockProps.item.variables,
);
expect(mockedSetHeaderEditor).toHaveBeenCalledTimes(1);
expect(mockedSetHeaderEditor).toHaveBeenCalledWith(mockProps.item.headers);
});

it('renders label input if the edit label button is clicked', () => {
const { container, getByTitle } = render(
<QueryHistoryItemWithContext {...getMockProps()} />,
);
fireEvent.click(getByTitle('Edit label'));
expect(container.querySelectorAll('li.editable').length).toBe(1);
expect(container.querySelectorAll('input').length).toBe(1);
expect(
container.querySelectorAll('button.graphiql-history-item-label').length,
).toBe(0);
});
});
147 changes: 147 additions & 0 deletions packages/graphiql-react/src/history/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { QueryStoreItem } from '@graphiql/toolkit';
import { Fragment, useEffect, useRef, useState } from 'react';

import { useEditorContext } from '../editor';
import { CloseIcon, PenIcon, StarFilledIcon, StarIcon } from '../icons';
import { UnStyledButton } from '../ui';
import { useHistoryContext } from './context';

import './style.css';

export function History() {
const { items } = useHistoryContext({ nonNull: true });
const reversedItems = items.slice().reverse();
return (
<section aria-label="History" className="graphiql-history">
<div className="graphiql-history-header">History</div>
<ul className="graphiql-history-items">
{reversedItems.map((item, i) => {
return (
<Fragment key={`${i}:${item.label || item.query}`}>
<HistoryItem item={item} />
{/**
* The (reversed) items are ordered in a way that all favorites
* come first, so if the next item is not a favorite anymore we
* place a spacer between them to separate these groups.
*/}
{item.favorite &&
reversedItems[i + 1] &&
!reversedItems[i + 1].favorite ? (
<div className="graphiql-history-item-spacer" />
) : null}
</Fragment>
);
})}
</ul>
</section>
);
}

type QueryHistoryItemProps = {
item: QueryStoreItem;
};

export function HistoryItem(props: QueryHistoryItemProps) {
const { editLabel, toggleFavorite } = useHistoryContext({
nonNull: true,
caller: HistoryItem,
});
const { headerEditor, queryEditor, variableEditor } = useEditorContext({
nonNull: true,
caller: HistoryItem,
});
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [isEditable, setIsEditable] = useState(false);

useEffect(() => {
if (isEditable && inputRef.current) {
inputRef.current.focus();
}
}, [isEditable]);

const displayName =
props.item.label ||
props.item.operationName ||
formatQuery(props.item.query);

return (
<li className={'graphiql-history-item' + (isEditable ? ' editable' : '')}>
{isEditable ? (
<>
<input
type="text"
defaultValue={props.item.label}
ref={inputRef}
onKeyDown={e => {
if (e.keyCode === 27) {
// Escape
setIsEditable(false);
} else if (e.keyCode === 13) {
// Enter
setIsEditable(false);
editLabel({ ...props.item, label: e.currentTarget.value });
}
}}
placeholder="Type a label"
/>
<UnStyledButton
ref={buttonRef}
onClick={() => {
setIsEditable(false);
editLabel({ ...props.item, label: inputRef.current?.value });
}}>
Save
</UnStyledButton>
<UnStyledButton
ref={buttonRef}
onClick={() => {
setIsEditable(false);
}}>
<CloseIcon />
</UnStyledButton>
</>
) : (
<>
<UnStyledButton
className="graphiql-history-item-label"
onClick={() => {
queryEditor?.setValue(props.item.query ?? '');
variableEditor?.setValue(props.item.variables ?? '');
headerEditor?.setValue(props.item.headers ?? '');
}}>
{displayName}
</UnStyledButton>
<UnStyledButton
className="graphiql-history-item-action"
title="Edit label"
onClick={e => {
e.stopPropagation();
setIsEditable(true);
}}>
<PenIcon />
</UnStyledButton>
<UnStyledButton
className="graphiql-history-item-action"
onClick={e => {
e.stopPropagation();
toggleFavorite(props.item);
}}
title={props.item.favorite ? 'Remove favorite' : 'Add favorite'}>
{props.item.favorite ? <StarFilledIcon /> : <StarIcon />}
</UnStyledButton>
</>
)}
</li>
);
}

export function formatQuery(query?: string) {
return query
?.split('\n')
.map(line => line.replace(/#(.*)/, ''))
.join(' ')
.replace(/{/g, ' { ')
.replace(/}/g, ' } ')
.replace(/[\s]{2,}/g, ' ');
}
14 changes: 0 additions & 14 deletions packages/graphiql-react/src/history/hooks.ts

This file was deleted.

15 changes: 3 additions & 12 deletions packages/graphiql-react/src/history/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import {
HistoryContext,
HistoryContextProvider,
useHistoryContext,
} from './context';
import { useSelectHistoryItem } from './hooks';

import type { HistoryContextType } from './context';

export { History } from './components';
export {
HistoryContext,
HistoryContextProvider,
useHistoryContext,
useSelectHistoryItem,
};
} from './context';

export type { HistoryContextType };
export type { HistoryContextType } from './context';
Loading

0 comments on commit bd219ed

Please sign in to comment.