-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[redesign] implement new design for history plugin (#2571)
* 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
1 parent
65c827a
commit bd219ed
Showing
33 changed files
with
583 additions
and
433 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@graphiql/react': minor | ||
--- | ||
|
||
Add a `Dropdown` ui component |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@graphiql/react': minor | ||
--- | ||
|
||
Add toolbar components (`ExecuteButton` and `ToolbarButton`) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
131 changes: 131 additions & 0 deletions
131
packages/graphiql-react/src/history/__tests__/components.spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, ' '); | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.