diff --git a/.changeset/fifty-elephants-divide.md b/.changeset/fifty-elephants-divide.md new file mode 100644 index 00000000000..1e65dc3f98d --- /dev/null +++ b/.changeset/fifty-elephants-divide.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a `Dropdown` ui component diff --git a/.changeset/fluffy-pianos-mix.md b/.changeset/fluffy-pianos-mix.md new file mode 100644 index 00000000000..fe497c4f21d --- /dev/null +++ b/.changeset/fluffy-pianos-mix.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add toolbar components (`ExecuteButton` and `ToolbarButton`) diff --git a/.changeset/mean-chefs-happen.md b/.changeset/mean-chefs-happen.md new file mode 100644 index 00000000000..de248921071 --- /dev/null +++ b/.changeset/mean-chefs-happen.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a component for rendering the history plugin diff --git a/babel.config.js b/babel.config.js index 6e20bb9257b..56fedcc08d2 100644 --- a/babel.config.js +++ b/babel.config.js @@ -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: { diff --git a/packages/graphiql-react/jest.config.js b/packages/graphiql-react/jest.config.js index d5f2ae82f35..d047842c0cf 100644 --- a/packages/graphiql-react/jest.config.js +++ b/packages/graphiql-react/jest.config.js @@ -2,4 +2,8 @@ const base = require('../../jest.config.base')(__dirname); module.exports = { ...base, + moduleNameMapper: { + '\\.svg$': `${__dirname}/mocks/svg`, + ...base.moduleNameMapper, + }, }; diff --git a/packages/graphiql-react/mocks/svg.js b/packages/graphiql-react/mocks/svg.js new file mode 100644 index 00000000000..1e8dd2e1098 --- /dev/null +++ b/packages/graphiql-react/mocks/svg.js @@ -0,0 +1,7 @@ +module.exports = function MockedIcon(props) { + return ( + + mocked icon + + ); +}; diff --git a/packages/graphiql-react/package.json b/packages/graphiql-react/package.json index eaabe899dab..112fd9c8169 100644 --- a/packages/graphiql-react/package.json +++ b/packages/graphiql-react/package.json @@ -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", diff --git a/packages/graphiql-react/src/history/__tests__/components.spec.tsx b/packages/graphiql-react/src/history/__tests__/components.spec.tsx new file mode 100644 index 00000000000..9366c4b67bc --- /dev/null +++ b/packages/graphiql-react/src/history/__tests__/components.spec.tsx @@ -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; + +function QueryHistoryItemWithContext(props: QueryHistoryItemProps) { + return ( + + + + ); +} + +const baseMockProps: QueryHistoryItemProps = { + item: { + query: mockQuery, + variables: mockVariables, + headers: mockHeaders, + favorite: false, + }, +}; + +function getMockProps( + customProps?: Partial, +): 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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); + }); +}); diff --git a/packages/graphiql-react/src/history/components.tsx b/packages/graphiql-react/src/history/components.tsx new file mode 100644 index 00000000000..921e0a68523 --- /dev/null +++ b/packages/graphiql-react/src/history/components.tsx @@ -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 ( +
+
History
+
    + {reversedItems.map((item, i) => { + return ( + + + {/** + * 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 ? ( +
    + ) : null} + + ); + })} +
+
+ ); +} + +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(null); + const buttonRef = useRef(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 ( +
  • + {isEditable ? ( + <> + { + 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" + /> + { + setIsEditable(false); + editLabel({ ...props.item, label: inputRef.current?.value }); + }}> + Save + + { + setIsEditable(false); + }}> + + + + ) : ( + <> + { + queryEditor?.setValue(props.item.query ?? ''); + variableEditor?.setValue(props.item.variables ?? ''); + headerEditor?.setValue(props.item.headers ?? ''); + }}> + {displayName} + + { + e.stopPropagation(); + setIsEditable(true); + }}> + + + { + e.stopPropagation(); + toggleFavorite(props.item); + }} + title={props.item.favorite ? 'Remove favorite' : 'Add favorite'}> + {props.item.favorite ? : } + + + )} +
  • + ); +} + +export function formatQuery(query?: string) { + return query + ?.split('\n') + .map(line => line.replace(/#(.*)/, '')) + .join(' ') + .replace(/{/g, ' { ') + .replace(/}/g, ' } ') + .replace(/[\s]{2,}/g, ' '); +} diff --git a/packages/graphiql-react/src/history/hooks.ts b/packages/graphiql-react/src/history/hooks.ts deleted file mode 100644 index b43e46cb454..00000000000 --- a/packages/graphiql-react/src/history/hooks.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { QueryStoreItem } from '@graphiql/toolkit'; -import { useEditorContext } from '../editor'; - -export function useSelectHistoryItem() { - const { headerEditor, queryEditor, variableEditor } = useEditorContext({ - nonNull: true, - caller: useSelectHistoryItem, - }); - return (item: QueryStoreItem) => { - queryEditor?.setValue(item.query ?? ''); - variableEditor?.setValue(item.variables ?? ''); - headerEditor?.setValue(item.headers ?? ''); - }; -} diff --git a/packages/graphiql-react/src/history/index.ts b/packages/graphiql-react/src/history/index.ts index 7aed49770c2..4542a1697d8 100644 --- a/packages/graphiql-react/src/history/index.ts +++ b/packages/graphiql-react/src/history/index.ts @@ -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'; diff --git a/packages/graphiql-react/src/history/style.css b/packages/graphiql-react/src/history/style.css new file mode 100644 index 00000000000..ac0ebe2c1c6 --- /dev/null +++ b/packages/graphiql-react/src/history/style.css @@ -0,0 +1,91 @@ +.graphiql-history-header { + font-size: var(--font-size-h2); +} + +.graphiql-history-items { + margin: var(--px-16) 0 0; + list-style: none; + padding: 0; +} + +.graphiql-history-item { + border-radius: var(--border-radius-4); + color: var(--color-neutral-60); + display: flex; + font-size: var(--font-size-inline-code); + font-family: var(--font-family-mono); + height: 34px; + + &:hover { + color: var(--color-neutral-100); + background-color: var(--color-neutral-7); + } + + &:not(:first-child) { + margin-top: var(--px-4); + } + + &.editable { + background-color: var(--color-pink-background); + + & > input { + background: transparent; + border: none; + flex: 1; + margin: 0; + outline: none; + padding: 0 var(--px-10); + width: 100%; + + &::placeholder { + color: var(--color-neutral-60); + } + } + + & > button { + color: var(--color-pink); + padding: 0 var(--px-10); + + &:active { + background-color: var(--color-pink-background-dark); + } + + &:focus { + outline: var(--color-pink) auto 1px; + } + + & > svg { + display: block; + } + } + } +} + +button.graphiql-history-item-label { + flex: 1; + padding: var(--px-8) var(--px-10); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +button.graphiql-history-item-action { + align-items: center; + color: var(--color-neutral-60); + display: flex; + padding: var(--px-8) var(--px-6); + + &:hover { + color: var(--color-neutral-100); + } + + & > svg { + height: 14px; + pointer-events: none; + width: 14px; + } +} + +.graphiql-history-item-spacer { + height: var(--px-16); +} diff --git a/packages/graphiql-react/src/icons/close.svg b/packages/graphiql-react/src/icons/close.svg new file mode 100644 index 00000000000..229315632b3 --- /dev/null +++ b/packages/graphiql-react/src/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/graphiql-react/src/icons/index.tsx b/packages/graphiql-react/src/icons/index.tsx index 182b6c0f379..bd8cc0ea406 100644 --- a/packages/graphiql-react/src/icons/index.tsx +++ b/packages/graphiql-react/src/icons/index.tsx @@ -1,14 +1,18 @@ import _ChevronDownIcon from './chevron-down.svg'; import _ChevronUpIcon from './chevron-up.svg'; +import _CloseIcon from './close.svg'; import _CopyIcon from './copy.svg'; import _DocsIcon from './docs.svg'; import _HistoryIcon from './history.svg'; import _KeyboardShortcutIcon from './keyboard-shortcut.svg'; import _MergeIcon from './merge.svg'; +import _PenIcon from './pen.svg'; import _PlayIcon from './play.svg'; import _PrettifyIcon from './prettify.svg'; import _ReloadIcon from './reload.svg'; import _SettingsIcon from './settings.svg'; +import _StarFilledIcon from './star-filled.svg'; +import _StarIcon from './star.svg'; import _StopIcon from './stop.svg'; export const ChevronDownIcon = generateIcon( @@ -16,6 +20,7 @@ export const ChevronDownIcon = generateIcon( 'chevron down icon', ); export const ChevronUpIcon = generateIcon(_ChevronUpIcon, 'chevron up icon'); +export const CloseIcon = generateIcon(_CloseIcon, 'close icon'); export const CopyIcon = generateIcon(_CopyIcon, 'copy icon'); export const DocsIcon = generateIcon(_DocsIcon, 'docs icon'); export const HistoryIcon = generateIcon(_HistoryIcon, 'history icon'); @@ -24,10 +29,13 @@ export const KeyboardShortcutIcon = generateIcon( 'keyboard shortcut icon', ); export const MergeIcon = generateIcon(_MergeIcon, 'merge icon'); +export const PenIcon = generateIcon(_PenIcon, 'pen icon'); export const PlayIcon = generateIcon(_PlayIcon, 'play icon'); export const PrettifyIcon = generateIcon(_PrettifyIcon, 'prettify icon'); export const ReloadIcon = generateIcon(_ReloadIcon, 'reload icon'); export const SettingsIcon = generateIcon(_SettingsIcon, 'settings icon'); +export const StarFilledIcon = generateIcon(_StarFilledIcon, 'filled star icon'); +export const StarIcon = generateIcon(_StarIcon, 'star icon'); export const StopIcon = generateIcon(_StopIcon, 'stop icon'); function generateIcon(RawComponent: any, title: string) { diff --git a/packages/graphiql-react/src/icons/pen.svg b/packages/graphiql-react/src/icons/pen.svg new file mode 100644 index 00000000000..365a3b2431a --- /dev/null +++ b/packages/graphiql-react/src/icons/pen.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/graphiql-react/src/icons/star-filled.svg b/packages/graphiql-react/src/icons/star-filled.svg new file mode 100644 index 00000000000..3c71764a882 --- /dev/null +++ b/packages/graphiql-react/src/icons/star-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/graphiql-react/src/icons/star.svg b/packages/graphiql-react/src/icons/star.svg new file mode 100644 index 00000000000..8399e72b0d7 --- /dev/null +++ b/packages/graphiql-react/src/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 4582b6c697d..37f1dbdb4bd 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -28,10 +28,10 @@ export { useExplorerContext, } from './explorer'; export { + History, HistoryContext, HistoryContextProvider, useHistoryContext, - useSelectHistoryItem, } from './history'; export * from './icons'; export { diff --git a/packages/graphiql-react/src/style/root.css b/packages/graphiql-react/src/style/root.css index 64c79c9dc7d..17fec5d4fec 100644 --- a/packages/graphiql-react/src/style/root.css +++ b/packages/graphiql-react/src/style/root.css @@ -18,6 +18,7 @@ --color-neutral-0: #ffffff; --color-pink-background: rgb(214, 6, 144, 0.1); + --color-pink-background-dark: rgb(214, 6, 144, 0.15); --color-pink-dark: #ab0573; --color-orche-background: rgba(211, 127, 0, 0.07); --color-orche-background-dark: rgba(211, 127, 0, 0.12); @@ -30,6 +31,7 @@ --font-size-inline-code: calc(13rem / 16); --font-size-body: calc(15rem / 16); --font-size-h4: calc(18rem / 16); + --font-size-h2: calc(29rem / 16); --font-weight-regular: 400; --font-weight-medium: 500; --line-height: 1.5; @@ -39,6 +41,7 @@ --px-4: 4px; --px-6: 6px; --px-8: 8px; + --px-10: 10px; --px-12: 12px; --px-16: 16px; @@ -61,10 +64,20 @@ .graphiql-container, .CodeMirror-info, .CodeMirror-lint-tooltip, -.graphiql-container button { +.graphiql-container:is(button) { color: var(--color-neutral-100); font-family: var(--font-family); font-size: var(--font-size-body); font-weight: var(----font-weight-regular); line-height: var(--line-height); } + +.graphiql-container input { + color: var(--color-neutral-100); + font-family: var(--font-family); + font-size: var(--font-size-caption); +} + +.graphiql-container input::placeholder { + color: var(--color-neutral-60); +} diff --git a/packages/graphiql-react/src/utility/resize.tsx b/packages/graphiql-react/src/utility/resize.tsx index 5fc6f0f087f..ca3d6f4ade8 100644 --- a/packages/graphiql-react/src/utility/resize.tsx +++ b/packages/graphiql-react/src/utility/resize.tsx @@ -42,7 +42,7 @@ export function useDragResize({ [storage, storageKey], ); - const [hiddenElement, _setHiddenElement] = useState( + const [hiddenElement, setHiddenElement] = useState( () => { const storedValue = storage && storageKey ? storage.get(storageKey) : null; @@ -56,12 +56,14 @@ export function useDragResize({ }, ); - const setHiddenElement = useCallback( + const setHiddenElementWithCallback = useCallback( (element: ResizableElement | null) => { - _setHiddenElement(element); - onHiddenElementChange?.(element); + if (element !== hiddenElement) { + setHiddenElement(element); + onHiddenElementChange?.(element); + } }, - [onHiddenElementChange], + [hiddenElement, onHiddenElementChange], ); const firstRef = useRef(null); @@ -210,16 +212,16 @@ export function useDragResize({ if (firstSize < sizeThresholdFirst) { // Hide the first display - setHiddenElement('first'); + setHiddenElementWithCallback('first'); store(HIDE_FIRST); } else if (secondSize < sizeThresholdSecond) { // Hide the second display - setHiddenElement('second'); + setHiddenElementWithCallback('second'); store(HIDE_SECOND); } else { // Show both and adjust the flex value of the first one (the flex // value for the second one is always `1`) - setHiddenElement(null); + setHiddenElementWithCallback(null); const newFlex = `${firstSize / secondSize}`; firstContainer.style.flex = newFlex; store(newFlex); @@ -242,7 +244,7 @@ export function useDragResize({ firstRef.current.style.flex = defaultFlexRef.current; } store(defaultFlexRef.current); - setHiddenElement(null); + setHiddenElementWithCallback(null); } dragBarContainer.addEventListener('dblclick', reset); @@ -253,7 +255,7 @@ export function useDragResize({ }; }, [ direction, - setHiddenElement, + setHiddenElementWithCallback, sizeThresholdFirst, sizeThresholdSecond, store, diff --git a/packages/graphiql/__mocks__/@graphiql/react.tsx b/packages/graphiql/__mocks__/@graphiql/react.tsx index 4cfafe1d0ed..78337dc0bd8 100644 --- a/packages/graphiql/__mocks__/@graphiql/react.tsx +++ b/packages/graphiql/__mocks__/@graphiql/react.tsx @@ -24,6 +24,7 @@ export { ExecutionContextProvider, ExplorerContext, ExplorerContextProvider, + History, HistoryContext, HistoryContextProvider, HistoryIcon, @@ -97,9 +98,8 @@ function useMockedEditor( ); const ref = useRef(null); - const context = useEditorContext({ nonNull: true }); const setEditor = - context[`set${name.slice(0, 1).toUpperCase()}${name.slice(1)}Editor`]; + editorContext[`set${name.slice(0, 1).toUpperCase()}${name.slice(1)}Editor`]; const getValueRef = useRef<() => string>(); useEffect(() => { diff --git a/packages/graphiql/cypress/integration/docs.spec.ts b/packages/graphiql/cypress/integration/docs.spec.ts index e6f608154ab..0a2a179c2f9 100644 --- a/packages/graphiql/cypress/integration/docs.spec.ts +++ b/packages/graphiql/cypress/integration/docs.spec.ts @@ -11,7 +11,7 @@ describe('GraphiQL DocExplorer - button', () => { it('Toggles doc pane back off', () => { // there are two components with .docExplorerHide, one in query history - cy.get('.docExplorerWrap button.docExplorerHide').click(); + cy.get('.graphiql-plugin button.docExplorerHide').click(); cy.get('.doc-explorer').should('not.be.visible'); }); }); diff --git a/packages/graphiql/cypress/integration/lint.spec.ts b/packages/graphiql/cypress/integration/lint.spec.ts index 31e5f76447c..f06eb85b825 100644 --- a/packages/graphiql/cypress/integration/lint.spec.ts +++ b/packages/graphiql/cypress/integration/lint.spec.ts @@ -1,26 +1,6 @@ import { version as graphqlVersion } from 'graphql/version'; describe('Linting', () => { - it('Marks GraphQL syntax errors as error', () => { - cy.visitWithOp({ - query: /* GraphQL */ ` - { - doesNotExist - test { - id - } - +++ - } - `, - }).assertLinterMarkWithMessage( - '+++', - 'error', - graphqlVersion.startsWith('15.') - ? 'Syntax Error: Cannot parse the unexpected character "+".' - : 'Syntax Error: Unexpected character: "+".', - ); - }); - it('Does not mark valid fields', () => { cy.visitWithOp({ query: /* GraphQL */ ` @@ -157,4 +137,24 @@ describe('Linting', () => { 'Type "TestInput" must be an Object.', ); }); + + it('Marks GraphQL syntax errors as error', () => { + cy.visitWithOp({ + query: /* GraphQL */ ` + { + doesNotExist + test { + id + } + +++ + } + `, + }).assertLinterMarkWithMessage( + '+++', + 'error', + graphqlVersion.startsWith('15.') + ? 'Syntax Error: Cannot parse the unexpected character "+".' + : 'Syntax Error: Unexpected character: "+".', + ); + }); }); diff --git a/packages/graphiql/cypress/support/commands.ts b/packages/graphiql/cypress/support/commands.ts index 7424c83d13f..5c98014e957 100644 --- a/packages/graphiql/cypress/support/commands.ts +++ b/packages/graphiql/cypress/support/commands.ts @@ -146,6 +146,7 @@ function normalizeWhitespace(str: string) { Cypress.Commands.add( 'assertLinterMarkWithMessage', (text, severity, message) => { + cy.wait(100); cy.contains(text) .should('have.class', 'CodeMirror-lint-mark') .and('have.class', `CodeMirror-lint-mark-${severity}`); diff --git a/packages/graphiql/src/cdn.ts b/packages/graphiql/src/cdn.ts index 4491e4a57bb..61f94499eb3 100644 --- a/packages/graphiql/src/cdn.ts +++ b/packages/graphiql/src/cdn.ts @@ -16,7 +16,6 @@ import './style.css'; // Legacy styles import './css/app.css'; import './css/doc-explorer.css'; -import './css/history.css'; import { GraphiQL } from './components/GraphiQL'; // add the static function here for CDN only. otherwise, doing this in the component could diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index b4822f85d2b..7b152a54834 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -32,6 +32,7 @@ import { ExecutionContextType, ExplorerContextProvider, HeaderEditor, + History, HistoryContextProvider, HistoryIcon, KeyboardShortcutIcon, @@ -71,7 +72,6 @@ import type { import { ToolbarMenu, ToolbarMenuItem } from './ToolbarMenu'; import { DocExplorer } from './DocExplorer'; -import { QueryHistory } from './QueryHistory'; import find from '../utility/find'; import { formatError, formatResult } from '@graphiql/toolkit'; @@ -538,15 +538,17 @@ const GraphiQLConsumeContexts = forwardRef< const merge = useMergeQuery(); const prettify = usePrettifyEditors(); - const docResize = useDragResize({ + const pluginResize = useDragResize({ defaultSizeRelation: 1 / 3, direction: 'horizontal', - initiallyHidden: explorerContext?.isVisible ? undefined : 'first', + initiallyHidden: + explorerContext?.isVisible || historyContext?.isVisible + ? undefined + : 'first', onHiddenElementChange: resizableElement => { if (resizableElement === 'first') { explorerContext?.hide(); - } else { - explorerContext?.show(); + historyContext?.hide(); } }, sizeThresholdSecond: 200, @@ -590,7 +592,7 @@ const GraphiQLConsumeContexts = forwardRef< copy={copy} merge={merge} prettify={prettify} - docResize={docResize} + pluginResize={pluginResize} editorResize={editorResize} editorToolsResize={editorToolsResize} ref={ref} @@ -614,7 +616,7 @@ type GraphiQLWithContextConsumerProps = Omit< merge(): void; prettify(): void; - docResize: ReturnType; + pluginResize: ReturnType; editorResize: ReturnType; editorToolsResize: ReturnType; }; @@ -691,10 +693,13 @@ class GraphiQLWithContext extends React.Component< onClick={() => { if (this.props.explorerContext?.isVisible) { this.props.explorerContext?.hide(); - this.props.docResize.setHiddenElement('first'); + this.props.pluginResize.setHiddenElement('first'); } else { this.props.explorerContext?.show(); - this.props.docResize.setHiddenElement(null); + this.props.pluginResize.setHiddenElement(null); + if (this.props.historyContext?.isVisible) { + this.props.historyContext.hide(); + } } }} title={ @@ -708,7 +713,20 @@ class GraphiQLWithContext extends React.Component< {this.props.historyContext ? ( this.props.historyContext?.toggle()} + onClick={() => { + if (!this.props.historyContext) { + return; + } + this.props.historyContext.toggle(); + if (this.props.historyContext.isVisible) { + this.props.pluginResize.setHiddenElement('first'); + } else { + this.props.pluginResize.setHiddenElement(null); + if (this.props.explorerContext?.isVisible) { + this.props.explorerContext.hide(); + } + } + }} title={ this.props.historyContext.isVisible ? 'Hide History' @@ -737,26 +755,31 @@ class GraphiQLWithContext extends React.Component<
    -
    -
    - this.props.docResize.setHiddenElement('first')} - /> +
    +
    + {this.props.explorerContext?.isVisible ? ( + + this.props.pluginResize.setHiddenElement('first') + } + /> + ) : null} + {this.props.historyContext?.isVisible ? : null}
    -
    - {this.props.explorerContext?.isVisible ? ( +
    + {this.props.explorerContext?.isVisible || + this.props.historyContext?.isVisible ? (
    ) : null}
    -
    - {this.props.historyContext?.isVisible && ( -
    - -
    - )} +
    {this.props.beforeTopBarContent} @@ -816,9 +839,10 @@ class GraphiQLWithContext extends React.Component< keyMap={this.props.keyMap} onClickReference={() => { if ( - this.props.docResize.hiddenElement === 'first' + this.props.pluginResize.hiddenElement === + 'first' ) { - this.props.docResize.setHiddenElement(null); + this.props.pluginResize.setHiddenElement(null); } }} onCopyQuery={this.props.onCopyQuery} diff --git a/packages/graphiql/src/components/QueryHistory.tsx b/packages/graphiql/src/components/QueryHistory.tsx deleted file mode 100644 index e933176602c..00000000000 --- a/packages/graphiql/src/components/QueryHistory.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) 2021 GraphQL Contributors. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { - HistoryContextType, - useHistoryContext, - useSelectHistoryItem, -} from '@graphiql/react'; -import { QueryStoreItem } from '@graphiql/toolkit'; -import React, { useEffect, useRef, useState } from 'react'; - -export function QueryHistory() { - const { hide, items } = useHistoryContext({ - nonNull: true, - }) as HistoryContextType; - - return ( -
    -
    -
    History
    -
    - -
    -
    -
      - {items - .slice() - .reverse() - .map((item, i) => { - return ( - - ); - })} -
    -
    - ); -} - -type QueryHistoryItemProps = { - item: QueryStoreItem; -}; - -export function QueryHistoryItem(props: QueryHistoryItemProps) { - const { editLabel, toggleFavorite } = useHistoryContext({ nonNull: true }); - const selectHistoryItem = useSelectHistoryItem(); - const editField = useRef(null); - const [isEditable, setIsEditable] = useState(false); - - useEffect(() => { - if (isEditable && editField.current) { - editField.current.focus(); - } - }, [isEditable]); - - const displayName = - props.item.label || - props.item.operationName || - props.item.query - ?.split('\n') - .filter(line => line.indexOf('#') !== 0) - .join(''); - const starIcon = props.item.favorite ? '\u2605' : '\u2606'; - return ( -
  • - {isEditable ? ( - { - e.stopPropagation(); - setIsEditable(false); - editLabel({ ...props.item, label: e.target.value }); - }} - onKeyDown={e => { - if (e.keyCode === 13) { - e.stopPropagation(); - setIsEditable(false); - editLabel({ ...props.item, label: e.currentTarget.value }); - } - }} - placeholder="Type a label" - /> - ) : ( - - )} - - -
  • - ); -} diff --git a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx index d0959f015cb..49586570a06 100644 --- a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx +++ b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx @@ -127,11 +127,11 @@ describe('GraphiQL', () => { const { container } = render( , ); - expect(container.querySelector('.docExplorerWrap')).toBeInTheDocument(); + expect(container.querySelector('.graphiql-plugin')).toBeInTheDocument(); }); it('defaults to closed docExplorer', () => { const { container } = render(); - expect(container.querySelector('.docExplorerWrap')).not.toBeVisible(); + expect(container.querySelector('.graphiql-plugin')).not.toBeVisible(); }); it('accepts a defaultVariableEditorOpen param', () => { @@ -167,7 +167,9 @@ describe('GraphiQL', () => { it('defaults to closed history panel', () => { const { container } = render(); - expect(container.querySelector('.historyPaneWrap')).not.toBeInTheDocument(); + expect( + container.querySelector('.graphiql-history'), + ).not.toBeInTheDocument(); }); it('will save history item even when history panel is closed', () => { @@ -182,7 +184,9 @@ describe('GraphiQL', () => { ); fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); fireEvent.click(getByTitle('Show History')); - expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + expect( + container.querySelectorAll('.graphiql-history-items li'), + ).toHaveLength(1); }); it('adds a history item when the execute query function button is clicked', () => { @@ -197,7 +201,9 @@ describe('GraphiQL', () => { ); fireEvent.click(getByTitle('Show History')); fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); - expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + expect( + container.querySelectorAll('.graphiql-history-items li'), + ).toHaveLength(1); }); it('will not save invalid queries', () => { @@ -206,7 +212,9 @@ describe('GraphiQL', () => { ); fireEvent.click(getByTitle('Show History')); fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); - expect(container.querySelectorAll('.history-contents li')).toHaveLength(0); + expect( + container.querySelectorAll('.graphiql-history-items li'), + ).toHaveLength(0); }); it('will save if there was not a previously saved query', () => { @@ -221,7 +229,9 @@ describe('GraphiQL', () => { ); fireEvent.click(getByTitle('Show History')); fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); - expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + expect( + container.querySelectorAll('.graphiql-history-items li'), + ).toHaveLength(1); }); it('will not save a query if the query is the same as previous query', () => { @@ -236,9 +246,13 @@ describe('GraphiQL', () => { ); fireEvent.click(getByTitle('Show History')); fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); - expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + expect( + container.querySelectorAll('.graphiql-history-items li'), + ).toHaveLength(1); fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); - expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + expect( + container.querySelectorAll('.graphiql-history-items li'), + ).toHaveLength(1); }); it('will save if new query is different than previous query', async () => { @@ -255,7 +269,9 @@ describe('GraphiQL', () => { fireEvent.click(getByTitle('Show History')); const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); fireEvent.click(executeQueryButton); - expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + expect(container.querySelectorAll('.graphiql-history-item')).toHaveLength( + 1, + ); fireEvent.change( container.querySelector('[data-testid="query-editor"] .mockCodeMirror'), @@ -268,7 +284,9 @@ describe('GraphiQL', () => { await sleep(150); fireEvent.click(executeQueryButton); - expect(container.querySelectorAll('.history-label')).toHaveLength(2); + expect(container.querySelectorAll('.graphiql-history-item')).toHaveLength( + 2, + ); }); it('will save query if variables are different', async () => { @@ -285,7 +303,9 @@ describe('GraphiQL', () => { fireEvent.click(getByTitle('Show History')); const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); fireEvent.click(executeQueryButton); - expect(container.querySelectorAll('.history-label')).toHaveLength(1); + expect(container.querySelectorAll('.graphiql-history-item')).toHaveLength( + 1, + ); await wait(); fireEvent.change( @@ -296,7 +316,9 @@ describe('GraphiQL', () => { ); fireEvent.click(executeQueryButton); - expect(container.querySelectorAll('.history-label')).toHaveLength(2); + expect(container.querySelectorAll('.graphiql-history-item')).toHaveLength( + 2, + ); }); it('will save query if headers are different', async () => { @@ -315,7 +337,9 @@ describe('GraphiQL', () => { fireEvent.click(getByTitle('Show History')); const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); fireEvent.click(executeQueryButton); - expect(container.querySelectorAll('.history-label')).toHaveLength(1); + expect(container.querySelectorAll('.graphiql-history-item')).toHaveLength( + 1, + ); await wait(); fireEvent.click(getByText('Headers')); @@ -328,7 +352,9 @@ describe('GraphiQL', () => { ); fireEvent.click(executeQueryButton); - expect(container.querySelectorAll('.history-label')).toHaveLength(2); + expect(container.querySelectorAll('.graphiql-history-item')).toHaveLength( + 2, + ); }); describe('children overrides', () => { @@ -564,7 +590,7 @@ describe('GraphiQL', () => { // 797 / (1200 - 797) = 1.977667493796526 expect( - container.querySelector('.docExplorerWrap').parentElement.style.flex, + container.querySelector('.graphiql-plugin').parentElement.style.flex, ).toBe('1.977667493796526'); clientWidthSpy.mockRestore(); diff --git a/packages/graphiql/src/components/__tests__/QueryHistory.spec.tsx b/packages/graphiql/src/components/__tests__/QueryHistory.spec.tsx deleted file mode 100644 index b43c5021287..00000000000 --- a/packages/graphiql/src/components/__tests__/QueryHistory.spec.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Copyright (c) 2021 GraphQL Contributors. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -import React, { ComponentProps } from 'react'; -import { render, fireEvent } from '@testing-library/react'; - -import { QueryHistoryItem } from '../QueryHistory'; -import { - mockOperationName1, - mockQuery1, - mockVariables1, - mockHeaders1, -} from './fixtures'; -import { - EditorContextProvider, - HistoryContextProvider, - useHeaderEditor, - useQueryEditor, - useVariableEditor, -} from '@graphiql/react'; - -type QueryHistoryItemProps = ComponentProps; - -function QueryHistoryItemWithContext(props: QueryHistoryItemProps) { - return ( - - - - - - - ); -} - -function Editors() { - const queryRef = useQueryEditor({}); - const variableRef = useVariableEditor({}); - const headerRef = useHeaderEditor({}); - return ( - <> -
    -
    -
    - - ); -} - -const baseMockProps: QueryHistoryItemProps = { - item: { - query: mockQuery1, - variables: mockVariables1, - headers: mockHeaders1, - favorite: false, - }, -}; - -function getMockProps( - customProps?: Partial, -): QueryHistoryItemProps { - return { - ...baseMockProps, - ...customProps, - item: { ...baseMockProps.item, ...customProps?.item }, - }; -} - -describe('QueryHistoryItem', () => { - it('renders operationName if label is not provided', () => { - const otherMockProps = { item: { operationName: mockOperationName1 } }; - const props = getMockProps(otherMockProps); - const { container } = render(); - expect(container.querySelector('button.history-label')!.textContent).toBe( - mockOperationName1, - ); - }); - - it('renders a string version of the query if label or operation name are not provided', () => { - const { container } = render( - , - ); - expect(container.querySelector('button.history-label')!.textContent).toBe( - mockQuery1 - .split('\n') - .filter(line => line.indexOf('#') !== 0) - .join(''), - ); - }); - - it('sets the editor values when history label button is clicked', () => { - const otherMockProps = { item: { operationName: mockOperationName1 } }; - const mockProps = getMockProps(otherMockProps); - const { container, getByTestId } = render( - , - ); - fireEvent.click(container.querySelector('button.history-label')!); - expect(getByTestId('query-editor').querySelector('textarea')).toHaveValue( - mockProps.item.query, - ); - expect( - getByTestId('variable-editor').querySelector('textarea'), - ).toHaveValue(mockProps.item.variables); - expect(getByTestId('header-editor').querySelector('textarea')).toHaveValue( - mockProps.item.headers, - ); - }); - - it('renders label input if the edit label button is clicked', () => { - const { container } = render( - , - ); - fireEvent.click(container.querySelector('[aria-label="Edit label"]')!); - expect(container.querySelectorAll('li.editable').length).toBe(1); - expect(container.querySelectorAll('input').length).toBe(1); - expect(container.querySelectorAll('button.history-label').length).toBe(0); - }); -}); diff --git a/packages/graphiql/src/css/app.css b/packages/graphiql/src/css/app.css index fc351be8ab2..06fb746a320 100644 --- a/packages/graphiql/src/css/app.css +++ b/packages/graphiql/src/css/app.css @@ -72,15 +72,6 @@ width: 9px; } -.graphiql-container .docExplorerWrap, -.graphiql-container .historyPaneWrap { - background: white; - box-shadow: 0 0 8px rgba(0, 0, 0, 0.15); - position: relative; - width: 100%; - z-index: 3; -} - .graphiql-container .historyPaneWrap { min-width: 230px; z-index: 5; @@ -157,9 +148,7 @@ .graphiql-container .toolbar-menu-items > li:hover, .graphiql-container .toolbar-select-options > li.hover, .graphiql-container .toolbar-select-options > li:active, -.graphiql-container .toolbar-select-options > li:hover, -.graphiql-container .history-contents > li:hover, -.graphiql-container .history-contents > li:active { +.graphiql-container .toolbar-select-options > li:hover { background: #e10098; color: #fff; } diff --git a/packages/graphiql/src/css/doc-explorer.css b/packages/graphiql/src/css/doc-explorer.css index e24096aa490..167b5ca5237 100644 --- a/packages/graphiql/src/css/doc-explorer.css +++ b/packages/graphiql/src/css/doc-explorer.css @@ -2,8 +2,7 @@ background: white; } -.graphiql-container .doc-explorer-title-bar, -.graphiql-container .history-title-bar { +.graphiql-container .doc-explorer-title-bar { cursor: default; display: flex; height: 34px; @@ -13,8 +12,7 @@ user-select: none; } -.graphiql-container .doc-explorer-title, -.graphiql-container .history-title { +.graphiql-container .doc-explorer-title { flex: 1; font-weight: bold; overflow-x: hidden; @@ -54,17 +52,11 @@ position: relative; } -.graphiql-container .doc-explorer-contents, -.graphiql-container .history-contents { +.graphiql-container .doc-explorer-contents { background-color: #ffffff; border-top: 1px solid #d6d6d6; - bottom: 0; - left: 0; overflow-y: auto; padding: 20px 15px; - position: absolute; - right: 0; - top: 47px; } .graphiql-container .doc-type-description p:first-child, diff --git a/packages/graphiql/src/css/history.css b/packages/graphiql/src/css/history.css deleted file mode 100644 index 0946fd2b082..00000000000 --- a/packages/graphiql/src/css/history.css +++ /dev/null @@ -1,63 +0,0 @@ -.graphiql-container .history-contents { - font-family: 'Consolas', 'Inconsolata', 'Droid Sans Mono', 'Monaco', monospace; -} - -.graphiql-container .history-contents { - margin: 0; - padding: 0; -} - -.graphiql-container .history-contents li { - align-items: center; - display: flex; - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0; - padding: 8px; - border-bottom: 1px solid #e0e0e0; -} - -.graphiql-container .history-contents li button:not(.history-label) { - display: none; - margin-left: 10px; -} - -.graphiql-container .history-contents li:hover button:not(.history-label), -.graphiql-container - .history-contents - li:focus-within - button:not(.history-label) { - display: inline-block; -} - -.graphiql-container .history-contents input, -.graphiql-container .history-contents button { - padding: 0; - background: 0; - border: 0; - font-size: inherit; - font-family: inherit; - line-height: 14px; - color: inherit; -} - -.graphiql-container .history-contents input { - flex-grow: 1; -} - -.graphiql-container .history-contents input::placeholder { - color: inherit; -} - -.graphiql-container .history-contents button { - cursor: pointer; - text-align: left; -} - -.graphiql-container .history-contents .history-label { - flex-grow: 1; - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/packages/graphiql/src/style.css b/packages/graphiql/src/style.css index a12eb4ec5e0..e198e9aaee6 100644 --- a/packages/graphiql/src/style.css +++ b/packages/graphiql/src/style.css @@ -137,6 +137,14 @@ border-top: 1px solid var(--color-neutral-15); } +/* The plugin container */ +.graphiql-container .graphiql-plugin { + border-left: 1px solid var(--color-neutral-15); + flex: 1; + max-width: calc(100% - 2 * var(--px-16)); + padding: var(--px-16); +} + /* Generic loading spinner */ .graphiql-container .graphiql-spinner { height: 56px;