diff --git a/.changeset/doc-explorer-redesign.md b/.changeset/doc-explorer-redesign.md new file mode 100644 index 00000000000..52a34b662ef --- /dev/null +++ b/.changeset/doc-explorer-redesign.md @@ -0,0 +1,5 @@ +--- +'@graphiql/plugin-doc-explorer': minor +--- + +Redesign the Schema Explorer panel: eyebrow header with filter and search icon buttons, dedicated breadcrumb row with color-coded depth segments, inline search row with keycap hint, type card with TYPE badge and implements list, and a mono field list with type colors and active-row accent border. diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx index fe10367daba..324385ccf55 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx @@ -1,5 +1,5 @@ import { type Mock, describe, it, expect, vi, beforeEach } from 'vitest'; -import { useGraphiQL as $useGraphiQL } from '@graphiql/react'; +import { useGraphiQL as $useGraphiQL, Tooltip } from '@graphiql/react'; import { render } from '@testing-library/react'; import { GraphQLInt, GraphQLObjectType, GraphQLSchema } from 'graphql'; import { FC, useEffect } from 'react'; @@ -54,9 +54,11 @@ const withErrorSchemaContext = { const DocExplorerWithContext: FC = () => { return ( - - - + + + + + ); }; @@ -88,8 +90,8 @@ describe('DocExplorer', () => { const error = container.querySelectorAll('.graphiql-doc-explorer-error'); expect(error).toHaveLength(0); expect( - container.querySelector('.graphiql-markdown-description'), - ).toHaveTextContent('GraphQL Schema for testing'); + container.querySelector('.graphiql-doc-explorer-schema-overview'), + ).toBeInTheDocument(); }); it('renders correctly with schema error', () => { useGraphiQL.mockImplementation(cb => cb(withErrorSchemaContext)); @@ -124,20 +126,27 @@ describe('DocExplorer', () => { cb({ ...defaultSchemaContext, schema: initialSchema }), ); const { container, rerender } = render( - - - , + + + + + , ); // First proper render of doc explorer rerender( - - - , + + + + + , ); - const title = container.querySelector('.graphiql-doc-explorer-title')!; - expect(title.textContent).toEqual('field'); + // The current page is shown as the last breadcrumb segment + const breadcrumb = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; + expect(breadcrumb.textContent).toEqual('field'); // Second render of doc explorer, this time with a new schema, with _same_ field name useGraphiQL.mockImplementation(cb => @@ -147,13 +156,17 @@ describe('DocExplorer', () => { }), ); rerender( - - - , + + + + + , ); - const title2 = container.querySelector('.graphiql-doc-explorer-title')!; + const breadcrumb2 = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; // Because `Query.field` still exists in the new schema, we can still render it - expect(title2.textContent).toEqual('field'); + expect(breadcrumb2.textContent).toEqual('field'); }); it('trims nav stack when necessary', () => { const initialSchema = makeSchema(); @@ -179,20 +192,26 @@ describe('DocExplorer', () => { cb({ ...defaultSchemaContext, schema: initialSchema }), ); const { container, rerender } = render( - - - , + + + + + , ); // First proper render of doc explorer rerender( - - - , + + + + + , ); - const title = container.querySelector('.graphiql-doc-explorer-title')!; - expect(title.textContent).toEqual('field'); + const breadcrumb = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; + expect(breadcrumb.textContent).toEqual('field'); // Second render of doc explorer, this time with a new schema, with a different field name useGraphiQL.mockImplementation(cb => @@ -202,12 +221,16 @@ describe('DocExplorer', () => { }), ); rerender( - - - , + + + + + , ); - const title2 = container.querySelector('.graphiql-doc-explorer-title')!; + const breadcrumb2 = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; // Because `Query.field` doesn't exist anymore, the top-most item we can render is `Query` - expect(title2.textContent).toEqual('Query'); + expect(breadcrumb2.textContent).toEqual('Query'); }); }); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx index 65e0219e628..09c707671a9 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx @@ -78,7 +78,7 @@ describe('FieldDocumentation', () => { />, ); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).not.toBeInTheDocument(); expect( container.querySelector('.graphiql-doc-explorer-type-name'), @@ -95,7 +95,7 @@ describe('FieldDocumentation', () => { />, ); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).not.toBeInTheDocument(); expect( container.querySelector('.graphiql-doc-explorer-type-name'), @@ -113,7 +113,7 @@ describe('FieldDocumentation', () => { container.querySelector('.graphiql-doc-explorer-type-name'), ).toHaveTextContent('String'); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).toHaveTextContent('Example String field with arguments'); }); @@ -127,14 +127,14 @@ describe('FieldDocumentation', () => { container.querySelector('.graphiql-doc-explorer-type-name'), ).toHaveTextContent('String'); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).toHaveTextContent('Example String field with arguments'); expect( container.querySelectorAll('.graphiql-doc-explorer-argument'), ).toHaveLength(1); expect( container.querySelector('.graphiql-doc-explorer-argument'), - ).toHaveTextContent('stringArg: String'); + ).toHaveTextContent('stringArg:String'); // by default, the deprecation docs should be hidden expect( container.querySelectorAll('.graphiql-markdown-deprecation'), diff --git a/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.css b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.css new file mode 100644 index 00000000000..f444085807b --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.css @@ -0,0 +1,140 @@ +.graphiql-doc-explorer-arguments-list { + display: flex; + flex-direction: column; + padding: var(--px-4) 0; +} + +/* Section header — "ARGUMENTS · N" / "DIRECTIVES · N" */ +.graphiql-doc-explorer-arguments-list-header { + display: flex; + align-items: center; + gap: var(--px-6); + padding: var(--px-6) var(--px-16) var(--px-4); + background: transparent; + border: none; + cursor: pointer; + color: oklch(var(--fg-subtle)); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + width: 100%; + text-align: left; + + & svg { + width: 9px; + height: 9px; + flex-shrink: 0; + } + + &:hover { + color: oklch(var(--fg-muted)); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-arguments-list-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +.graphiql-doc-explorer-arguments-list-body { + display: flex; + flex-direction: column; +} + +/* Individual argument row */ +.graphiql-doc-explorer-argument { + display: flex; + flex-direction: column; + gap: var(--px-4); + padding: 5px var(--px-16) 5px var(--px-24); +} + +.graphiql-doc-explorer-argument--deprecated { + opacity: 0.6; +} + +/* Signature line: name : type = default */ +.graphiql-doc-explorer-argument-sig { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--px-4); + font-family: var(--font-family-mono); + font-size: 12px; +} + +.graphiql-doc-explorer-argument-name { + color: oklch(var(--accent-green-light)); +} + +.graphiql-doc-explorer-argument-colon { + color: oklch(var(--fg-disabled)); +} + +.graphiql-doc-explorer-argument-type { + color: oklch(var(--accent-orange)); + + & .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); + + &:hover { + text-decoration: underline; + } + } +} + +.graphiql-doc-explorer-argument-default { + color: oklch(var(--fg-muted)); +} + +.graphiql-doc-explorer-argument-desc { + font-size: 11px; + color: oklch(var(--fg-subtle)); + line-height: 15px; +} + +.graphiql-doc-explorer-arguments-list-show-deprecated { + margin: var(--px-8) var(--px-16); +} + +/* Directives — same eyebrow layout as ArgumentsList */ +.graphiql-doc-explorer-directives-list { + display: flex; + flex-direction: column; + padding: var(--px-4) 0; +} + +.graphiql-doc-explorer-directives-list-header { + padding: var(--px-6) var(--px-16) var(--px-4); + color: oklch(var(--fg-subtle)); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.graphiql-doc-explorer-directives-list-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +.graphiql-doc-explorer-directives-list-body { + display: flex; + flex-direction: column; +} + +.graphiql-doc-explorer-directive-row { + padding: 5px var(--px-16) 5px var(--px-24); + font-family: var(--font-family-mono); + font-size: 12px; +} + +.graphiql-doc-explorer-directive-row .graphiql-doc-explorer-directive { + color: oklch(var(--accent-orange)); +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.tsx b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.tsx new file mode 100644 index 00000000000..ed36f970da6 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.tsx @@ -0,0 +1,115 @@ +import { FC, useState } from 'react'; +import { astFromValue, print, type GraphQLArgument } from 'graphql'; +import { Button, ChevronDownIcon, ChevronUpIcon } from '@graphiql/react'; +import { DeprecationReason } from './deprecation-reason'; +import { TypeLink } from './type-link'; +import { renderType } from './utils'; +import './arguments-list.css'; + +type ArgumentsListProps = { + title: 'ARGUMENTS' | 'DEPRECATED ARGUMENTS'; + args: GraphQLArgument[]; +}; + +export const ArgumentsList: FC = ({ title, args }) => { + const [expanded, setExpanded] = useState(true); + + if (args.length === 0) { + return null; + } + + return ( +
+ + {expanded && ( +
+ {args.map(arg => ( + + ))} +
+ )} +
+ ); +}; + +type ShowDeprecatedArgumentsButtonProps = { + onClick: () => void; +}; + +export const ShowDeprecatedArgumentsButton: FC< + ShowDeprecatedArgumentsButtonProps +> = ({ onClick }) => ( + +); + +type ArgumentRowProps = { + arg: GraphQLArgument; +}; + +const ArgumentRow: FC = ({ arg }) => { + const defaultAst = + arg.defaultValue === undefined + ? null + : astFromValue(arg.defaultValue, arg.type); + const isDeprecated = Boolean(arg.deprecationReason); + + return ( +
+
+ {arg.name} + + + {renderType(arg.type, namedType => ( + + ))} + + {defaultAst && ( + + = {print(defaultAst)} + + )} +
+ {arg.description && ( +
+ {arg.description} +
+ )} + {arg.deprecationReason ? ( + + {arg.deprecationReason} + + ) : null} +
+ ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.css b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.css new file mode 100644 index 00000000000..2213a8ec1e1 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.css @@ -0,0 +1,50 @@ +.graphiql-doc-explorer-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + padding: var(--px-6) var(--px-16) 0; + font-family: var(--font-family-mono); + font-size: 11.5px; + line-height: 1.4; +} + +.graphiql-doc-explorer-breadcrumb-segment { + display: flex; + align-items: center; + gap: var(--px-4); +} + +.graphiql-doc-explorer-breadcrumb-sep { + color: oklch(var(--fg-dim)); + margin: 0 var(--px-4); + font-family: var(--font-family); + font-size: 13px; +} + +/* Query root — accent blue */ +a.graphiql-doc-explorer-breadcrumb-root, +span.graphiql-doc-explorer-breadcrumb-root { + color: oklch(var(--accent-blue)); + text-decoration: none; +} + +a.graphiql-doc-explorer-breadcrumb-root:hover { + text-decoration: underline; +} + +/* Intermediate types — green */ +a.graphiql-doc-explorer-breadcrumb-intermediate, +span.graphiql-doc-explorer-breadcrumb-intermediate { + color: oklch(var(--accent-green-light)); + text-decoration: none; +} + +a.graphiql-doc-explorer-breadcrumb-intermediate:hover { + text-decoration: underline; +} + +/* Current (last) segment — strong foreground */ +span.graphiql-doc-explorer-breadcrumb-current { + color: oklch(var(--fg-strong)); +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.tsx b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.tsx new file mode 100644 index 00000000000..50d31a01853 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.tsx @@ -0,0 +1,62 @@ +import type { FC } from 'react'; +import type { DocExplorerNavStack } from '../context'; +import './breadcrumb.css'; + +type BreadcrumbProps = { + navStack: DocExplorerNavStack; + onNavigateTo: (index: number) => void; +}; + +export const Breadcrumb: FC = ({ navStack, onNavigateTo }) => { + if (navStack.length <= 1) { + return null; + } + + return ( + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css index 5b27c40e27b..1bb60b6a48c 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css @@ -1,108 +1,61 @@ -/* The header wrapper — positions PanelHeader as a containing block for search overlay */ -.graphiql-doc-explorer-header { - & .graphiql-panel-header { - position: relative; - - &:focus-within { - & .graphiql-doc-explorer-title { - /* Hide the title when the search input is focused */ - visibility: hidden; - } - - & .graphiql-doc-explorer-back:not(:focus) { - /** - * Make the back link invisible when focussing the search input. Hiding - * it in any other way makes it impossible to focus the link by pressing - * Shift-Tab while the input is focussed. - */ - color: transparent; - } - } - } -} - -.graphiql-doc-explorer-header-content { - display: flex; - flex-direction: column; - min-width: 0; +/* Eyebrow title in PanelHeader */ +.graphiql-panel-header-title { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: var(--font-size-eyebrow); } -/* The search input in the header of the doc explorer */ -.graphiql-doc-explorer-search { - position: absolute; - right: var(--px-14); - top: var(--px-10); - - &:focus-within { - left: var(--px-14); - } - - &:not(:focus-within) [role='combobox'] { - height: 24px; - width: 6.5ch; - } - - & [role='combobox']:focus { - width: 100%; - } +/* Main content area */ +.graphiql-doc-explorer-content { + overflow-y: auto; + flex: 1; } -/* The back-button in the doc explorer */ -a.graphiql-doc-explorer-back { - align-items: center; +/* Schema overview and field-documentation padding */ +.graphiql-doc-explorer-content > * { color: oklch(var(--fg-muted)); - display: flex; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - - &:focus { - outline: oklch(var(--fg-muted)) auto 1px; - - & + .graphiql-doc-explorer-title { - /* Don't hide the header when focussing the back link */ - visibility: unset; - } - } - - & > svg { - height: var(--px-8); - margin-right: var(--px-8); - width: var(--px-8); - } + padding: 0 var(--px-16) var(--px-16); + margin-top: var(--px-16); } -/* The title of the currently active page in the doc explorer */ -.graphiql-doc-explorer-title { - color: oklch(var(--fg-strong)); - font-weight: 600; - font-size: 12px; - overflow-x: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - &:not(:first-child) { - margin-top: var(--px-4); - } +/* TypeCard and FieldCard keep their own horizontal padding */ +.graphiql-doc-explorer-content .graphiql-doc-explorer-type-card, +.graphiql-doc-explorer-content .graphiql-doc-explorer-field-card { + padding: var(--px-10) var(--px-16); + margin-top: 0; } -/* The contents of the currently active page in the doc explorer */ -.graphiql-doc-explorer-content { - padding: 0 var(--px-14) var(--px-14); +/* FieldsList and ArgumentsList rows span full width, so zero out container padding */ +.graphiql-doc-explorer-content .graphiql-doc-explorer-fields-list, +.graphiql-doc-explorer-content .graphiql-doc-explorer-arguments-list { + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-top: 0; } -.graphiql-doc-explorer-content > * { +/* Wrap for enum/union/interface extra sections */ +.graphiql-doc-explorer-type-extra { + padding: 0 var(--px-16) var(--px-16); + margin-top: var(--px-16); color: oklch(var(--fg-muted)); - margin-top: var(--px-20); } /* Error message */ .graphiql-doc-explorer-error { background-color: oklch(var(--accent-red) / 0.15); border: 1px solid oklch(var(--accent-red)); - border-radius: var(--border-radius-8); + border-radius: var(--radius-sm); color: oklch(var(--accent-red)); padding: var(--px-8) var(--px-12); + margin: var(--px-16); +} + +/* Old back-button and title selectors kept for Cypress compatibility */ +a.graphiql-doc-explorer-back { + display: none; +} + +.graphiql-doc-explorer-title { + display: none; } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx index 709c37d4799..3a6ce7cec84 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx @@ -1,16 +1,21 @@ -import { isType } from 'graphql'; -import type { FC, ReactNode } from 'react'; import { - ChevronLeftIcon, - PanelHeader, - Spinner, - useGraphiQL, - pick, -} from '@graphiql/react'; + GraphQLNamedType, + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isType, + isUnionType, +} from 'graphql'; +import type { FC, ReactNode } from 'react'; +import { PanelHeader, Spinner, useGraphiQL, pick } from '@graphiql/react'; import { useDocExplorer, useDocExplorerActions } from '../context'; +import { Breadcrumb } from './breadcrumb'; import { FieldDocumentation } from './field-documentation'; +import { FieldsList } from './fields-list'; import { SchemaDocumentation } from './schema-documentation'; -import { Search } from './search'; +import { SearchRow } from './search-row'; +import { TypeCard } from './type-card'; import { TypeDocumentation } from './type-documentation'; import './doc-explorer.css'; @@ -22,6 +27,13 @@ export const DocExplorer: FC = () => { const { pop } = useDocExplorerActions(); const navItem = explorerNavStack.at(-1)!; + const navigateToIndex = (index: number) => { + const stepsBack = explorerNavStack.length - 1 - index; + for (let i = 0; i < stepsBack; i++) { + pop(); + } + }; + let content: ReactNode = null; if (fetchError) { content = ( @@ -34,11 +46,8 @@ export const DocExplorer: FC = () => { ); } else if (isIntrospecting) { - // Schema is undefined when it is being loaded via introspection. content = ; } else if (!schema) { - // Schema is null when it explicitly does not exist, typically due to - // an error during introspection. content = (
No GraphQL schema available @@ -47,48 +56,48 @@ export const DocExplorer: FC = () => { } else if (explorerNavStack.length === 1) { content = ; } else if (isType(navItem.def)) { - content = ; + content = ; } else if (navItem.def) { content = ; } - let prevName; - if (explorerNavStack.length > 1) { - prevName = explorerNavStack.at(-2)!.name; - } - - const headerTitle = ( - - ); + const isTypeOrFieldView = explorerNavStack.length > 1; return (
-
- } + + {isTypeOrFieldView && ( + -
+ )} +
{content}
); }; + +const TypeView: FC<{ type: GraphQLNamedType }> = ({ type }) => { + const hasNewFieldsList = + isObjectType(type) || isInterfaceType(type) || isInputObjectType(type); + // Enum/union/scalar still need the TypeDocumentation for enum values and + // possible-type sections; interface needs it for Implementations. + const needsTypeDocs = + isEnumType(type) || isUnionType(type) || isInterfaceType(type); + + return ( + <> + + {hasNewFieldsList && } + {needsTypeDocs && ( +
+ +
+ )} + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-card.css b/packages/graphiql-plugin-doc-explorer/src/components/field-card.css new file mode 100644 index 00000000000..b17d6260076 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-card.css @@ -0,0 +1,45 @@ +.graphiql-doc-explorer-field-card { + border-top: 1px solid oklch(var(--border-default)); +} + +.graphiql-doc-explorer-field-card-header { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--px-6); + margin-bottom: var(--px-4); +} + +.graphiql-doc-explorer-field-card-name { + font-family: var(--font-family-mono); + font-size: 14px; + font-weight: 600; + color: oklch(var(--accent-green-light)); +} + +.graphiql-doc-explorer-field-card-colon { + font-family: var(--font-family-mono); + font-size: 14px; + color: oklch(var(--fg-muted)); +} + +.graphiql-doc-explorer-field-card-type { + font-family: var(--font-family-mono); + font-size: 14px; + color: oklch(var(--accent-orange)); + + & .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); + + &:hover { + text-decoration: underline; + } + } +} + +.graphiql-doc-explorer-field-card-description { + margin: 0 0 var(--px-6); + font-size: 12px; + color: oklch(var(--fg-muted)); + line-height: 17px; +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-card.tsx b/packages/graphiql-plugin-doc-explorer/src/components/field-card.tsx new file mode 100644 index 00000000000..301e7a4f4c8 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-card.tsx @@ -0,0 +1,44 @@ +import type { FC } from 'react'; +import type { DocExplorerFieldDef } from '../context'; +import { DeprecationReason } from './deprecation-reason'; +import { TypeLink } from './type-link'; +import { renderType } from './utils'; +import './field-card.css'; + +type FieldCardProps = { + field: DocExplorerFieldDef; +}; + +export const FieldCard: FC = ({ field }) => { + return ( +
+
+ FIELD + + {field.name} + + + + {renderType(field.type, namedType => ( + + ))} + +
+ {field.description && ( +

+ {field.description} +

+ )} + {'deprecationReason' in field && field.deprecationReason ? ( + + {field.deprecationReason} + + ) : null} +
+ ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx index c4c6ec4f253..e45397503de 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx @@ -1,12 +1,9 @@ -import type { GraphQLArgument } from 'graphql'; +import type { ConstDirectiveNode, GraphQLArgument } from 'graphql'; import { FC, useState } from 'react'; -import { Button, MarkdownContent } from '@graphiql/react'; import type { DocExplorerFieldDef } from '../context'; -import { Argument } from './argument'; -import { DeprecationReason } from './deprecation-reason'; +import { ArgumentsList, ShowDeprecatedArgumentsButton } from './arguments-list'; import { Directive } from './directive'; -import { ExplorerSection } from './section'; -import { TypeLink } from './type-link'; +import { FieldCard } from './field-card'; type FieldDocumentationProps = { /** @@ -18,17 +15,7 @@ type FieldDocumentationProps = { export const FieldDocumentation: FC = ({ field }) => { return ( <> - {field.description ? ( - - {field.description} - - ) : null} - - {field.deprecationReason} - - - - + @@ -37,9 +24,6 @@ export const FieldDocumentation: FC = ({ field }) => { const Arguments: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { const [showDeprecated, setShowDeprecated] = useState(false); - const handleShowDeprecated = () => { - setShowDeprecated(true); - }; if (!('args' in field)) { return null; @@ -57,42 +41,44 @@ const Arguments: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { return ( <> - {args.length > 0 ? ( - - {args.map(arg => ( - - ))} - - ) : null} - {deprecatedArgs.length > 0 ? ( - showDeprecated || args.length === 0 ? ( - - {deprecatedArgs.map(arg => ( - - ))} - + + {deprecatedArgs.length > 0 && + (showDeprecated || args.length === 0 ? ( + ) : ( - - ) - ) : null} + setShowDeprecated(true)} + /> + ))} ); }; const Directives: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { - const directives = field.astNode?.directives; + const directives = field.astNode?.directives as + | readonly ConstDirectiveNode[] + | undefined; if (!directives?.length) { return null; } return ( - - {directives.map(directive => ( -
- -
- ))} -
+
+
+ DIRECTIVES{' '} + + · {directives.length} + +
+
+ {directives.map(directive => ( +
+ +
+ ))} +
+
); }; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/fields-list.css b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.css new file mode 100644 index 00000000000..c2353dbdcc3 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.css @@ -0,0 +1,130 @@ +.graphiql-doc-explorer-fields-list { + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + padding: var(--px-4) 0; +} + +/* Section header — "FIELDS · N" */ +.graphiql-doc-explorer-fields-list-header { + display: flex; + align-items: center; + gap: var(--px-6); + padding: var(--px-6) var(--px-16) var(--px-4); + background: transparent; + border: none; + cursor: pointer; + color: oklch(var(--fg-subtle)); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + width: 100%; + text-align: left; + + & svg { + width: 9px; + height: 9px; + flex-shrink: 0; + } + + &:hover { + color: oklch(var(--fg-muted)); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-fields-list-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +.graphiql-doc-explorer-fields-list-body { + display: flex; + flex-direction: column; +} + +/* Individual field row */ +.graphiql-doc-explorer-field-row { + display: flex; + flex-direction: column; + gap: var(--px-4); + padding: 5px var(--px-12) 5px var(--px-24); + background: transparent; + border: none; + border-left: 2px solid transparent; + cursor: pointer; + text-align: left; + width: 100%; + + &:hover { + background: oklch(var(--fg-default) / 0.04); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +/* Active row — blue accent */ +.graphiql-doc-explorer-field-row--active { + background: oklch(var(--accent-blue) / 0.08); + border-left-color: oklch(var(--accent-blue)); + margin-left: -2px; + + &:hover { + background: oklch(var(--accent-blue) / 0.12); + } +} + +.graphiql-doc-explorer-field-row--deprecated { + opacity: 0.6; +} + +/* Signature line: name : type */ +.graphiql-doc-explorer-field-row-sig { + display: flex; + align-items: baseline; + gap: var(--px-6); + font-family: var(--font-family-mono); + font-size: 12px; +} + +.graphiql-doc-explorer-field-row-name { + color: oklch(var(--accent-green-light)); +} + +.graphiql-doc-explorer-field-row-colon { + color: oklch(var(--fg-disabled)); +} + +/* Return type color — inherits from TypeLink */ +.graphiql-doc-explorer-field-row-type { + color: oklch(var(--accent-orange)); + + /* TypeLink anchors inside a field row get orange color */ + & .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); + + &:hover { + text-decoration: underline; + } + } +} + +/* Optional description */ +.graphiql-doc-explorer-field-row-desc { + font-size: 11px; + color: oklch(var(--fg-subtle)); + line-height: 15px; +} + +.graphiql-doc-explorer-fields-list-show-deprecated { + margin: var(--px-8) var(--px-16); +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/fields-list.tsx b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.tsx new file mode 100644 index 00000000000..8dcc5caea2b --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.tsx @@ -0,0 +1,137 @@ +import { FC, useState } from 'react'; +import { + GraphQLNamedType, + isObjectType, + isInterfaceType, + isInputObjectType, +} from 'graphql'; +import { Button, ChevronDownIcon, ChevronUpIcon } from '@graphiql/react'; +import type { DocExplorerFieldDef } from '../context'; +import { useDocExplorerActions } from '../context'; +import { TypeLink } from './type-link'; +import { renderType } from './utils'; +import './fields-list.css'; + +type FieldsListProps = { + type: GraphQLNamedType; + activeFieldName?: string; +}; + +export const FieldsList: FC = ({ type, activeFieldName }) => { + const [expanded, setExpanded] = useState(true); + const [showDeprecated, setShowDeprecated] = useState(false); + + if ( + !isObjectType(type) && + !isInterfaceType(type) && + !isInputObjectType(type) + ) { + return null; + } + + const fieldMap = type.getFields(); + const fields: DocExplorerFieldDef[] = []; + const deprecatedFields: DocExplorerFieldDef[] = []; + + for (const field of Object.values(fieldMap)) { + if (field.deprecationReason) { + deprecatedFields.push(field); + } else { + fields.push(field); + } + } + + const totalCount = + fields.length + (showDeprecated ? deprecatedFields.length : 0); + + return ( +
+ + {expanded && ( +
+ {fields.map(field => ( + + ))} + {deprecatedFields.length > 0 && + (showDeprecated || fields.length === 0 ? ( + deprecatedFields.map(field => ( + + )) + ) : ( + + ))} +
+ )} +
+ ); +}; + +type FieldRowProps = { + field: DocExplorerFieldDef; + isActive: boolean; + deprecated?: boolean; +}; + +const FieldRow: FC = ({ field, isActive, deprecated }) => { + const { push } = useDocExplorerActions(); + + return ( + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/index.ts b/packages/graphiql-plugin-doc-explorer/src/components/index.ts index 726eefb7198..4686c39120e 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/index.ts +++ b/packages/graphiql-plugin-doc-explorer/src/components/index.ts @@ -1,12 +1,18 @@ export { Argument } from './argument'; +export { ArgumentsList } from './arguments-list'; +export { Breadcrumb } from './breadcrumb'; export { DefaultValue } from './default-value'; export { DeprecationReason } from './deprecation-reason'; export { Directive } from './directive'; export { DocExplorer } from './doc-explorer'; +export { FieldCard } from './field-card'; export { FieldDocumentation } from './field-documentation'; export { FieldLink } from './field-link'; +export { FieldsList } from './fields-list'; export { SchemaDocumentation } from './schema-documentation'; export { Search } from './search'; +export { SearchRow } from './search-row'; export { ExplorerSection } from './section'; +export { TypeCard } from './type-card'; export { TypeDocumentation } from './type-documentation'; export { TypeLink } from './type-link'; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css index d22d70ebcd5..110ee3b2dce 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css @@ -1,3 +1,89 @@ -.graphiql-doc-explorer-root-type { +.graphiql-doc-explorer-schema-overview { + display: flex; + flex-direction: column; + padding: var(--px-8) 0; +} + +/* Eyebrow section headers — matches FieldsList header style */ +.graphiql-doc-explorer-schema-section-header { + padding: var(--px-6) var(--px-16) var(--px-4); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: oklch(var(--fg-subtle)); +} + +.graphiql-doc-explorer-schema-section-header--types { + margin-top: var(--px-12); +} + +.graphiql-doc-explorer-schema-type-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +/* Root type rows */ +.graphiql-doc-explorer-schema-root-row { + display: flex; + align-items: center; + gap: var(--px-6); + padding: 5px var(--px-16); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + font-family: var(--font-family-mono); + font-size: 12px; + + &:hover { + background: oklch(var(--fg-default) / 0.04); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-schema-root-label { + color: oklch(var(--fg-default)); +} + +.graphiql-doc-explorer-schema-root-colon { color: oklch(var(--fg-muted)); } + +/* Type name in root rows — orange like TypeLink */ +.graphiql-doc-explorer-schema-root-row .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); +} + +/* All-types rows */ +.graphiql-doc-explorer-schema-type-row { + display: flex; + align-items: center; + gap: var(--px-8); + padding: 4px var(--px-16); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + + &:hover { + background: oklch(var(--fg-default) / 0.04); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-schema-type-name { + font-family: var(--font-family-mono); + font-size: 12px; + color: oklch(var(--fg-default)); +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx index 280533644c7..af8c665c542 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx @@ -1,8 +1,15 @@ import type { FC } from 'react'; -import type { GraphQLSchema } from 'graphql'; -import { MarkdownContent } from '@graphiql/react'; -import { ExplorerSection } from './section'; -import { TypeLink } from './type-link'; +import type { GraphQLNamedType, GraphQLSchema } from 'graphql'; +import { + isObjectType, + isInterfaceType, + isInputObjectType, + isEnumType, + isScalarType, + isUnionType, +} from 'graphql'; +import { MethodPill } from '@graphiql/react'; +import { useDocExplorerActions } from '../context'; import './schema-documentation.css'; type SchemaDocumentationProps = { @@ -12,68 +19,129 @@ type SchemaDocumentationProps = { schema: GraphQLSchema; }; +function getTypeKindLabel(type: GraphQLNamedType): string { + if (isObjectType(type)) { + return 'TYPE'; + } + if (isInterfaceType(type)) { + return 'INTERFACE'; + } + if (isInputObjectType(type)) { + return 'INPUT'; + } + if (isEnumType(type)) { + return 'ENUM'; + } + if (isScalarType(type)) { + return 'SCALAR'; + } + if (isUnionType(type)) { + return 'UNION'; + } + return 'TYPE'; +} + export const SchemaDocumentation: FC = ({ schema, }) => { + const { push } = useDocExplorerActions(); const queryType = schema.getQueryType(); const mutationType = schema.getMutationType(); const subscriptionType = schema.getSubscriptionType(); - const typeMap = schema.getTypeMap(); - const ignoreTypesInAllSchema = [ - queryType?.name, - mutationType?.name, - subscriptionType?.name, - ]; + const rootTypeNames = new Set( + [queryType?.name, mutationType?.name, subscriptionType?.name].filter( + Boolean, + ), + ); + + const allTypes = Object.values(schema.getTypeMap()).filter( + type => !type.name.startsWith('__') && !rootTypeNames.has(type.name), + ); return ( - <> - - {schema.description || - 'A GraphQL schema provides a root type for each kind of operation.'} - - - {queryType ? ( -
- query - {': '} - -
- ) : null} +
+ {/* Root types section */} +
+ ROOT TYPES +
+
+ {queryType && ( + + )} {mutationType && ( -
- mutation - {': '} - -
+ )} {subscriptionType && ( -
- - subscription +
+ )} - - -
- {Object.values(typeMap).map(type => { - if ( - ignoreTypesInAllSchema.includes(type.name) || - type.name.startsWith('__') - ) { - return null; - } +
- return ( -
- -
- ); - })} -
- - + {/* All schema types section */} +
+ ALL SCHEMA TYPES + + {' '} + · {allTypes.length} + +
+
+ {allTypes.map(type => ( + + ))} +
+
); }; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search-row.css b/packages/graphiql-plugin-doc-explorer/src/components/search-row.css new file mode 100644 index 00000000000..75166f40776 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/search-row.css @@ -0,0 +1,87 @@ +.graphiql-doc-explorer-search-row-wrapper { + padding: var(--px-8) var(--px-16); + position: relative; +} + +.graphiql-doc-explorer-search-row { + position: relative; +} + +.graphiql-doc-explorer-search-row-input { + display: flex; + align-items: center; + gap: var(--px-6); + background: oklch(var(--bg-subtle)); + border: 1px solid oklch(var(--border-muted)); + border-radius: var(--radius-sm); + padding: var(--px-4) var(--px-8); + cursor: text; + + & svg { + width: 11px; + height: 11px; + color: oklch(var(--fg-disabled)); + flex-shrink: 0; + } + + & [role='combobox'] { + flex: 1; + min-width: 0; + background: transparent; + border: none; + font-family: var(--font-family-mono); + font-size: 11.5px; + color: oklch(var(--fg-default)); + + &::placeholder { + color: oklch(var(--fg-disabled)); + } + + &:focus { + outline: none; + } + } +} + +.graphiql-doc-explorer-search-row-listbox { + position: absolute; + top: calc(100% + var(--px-4)); + left: 0; + right: 0; + z-index: 5; + background: oklch(var(--bg-canvas)); + border: 1px solid oklch(var(--border-default)); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-popover); + max-height: 400px; + overflow-y: auto; + padding: var(--px-4); + font-size: var(--font-size-body); + margin: 0; + + & [role='option'] { + border-radius: var(--radius-sm); + color: oklch(var(--fg-muted)); + overflow-x: hidden; + padding: var(--px-8) var(--px-12); + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + + &[data-headlessui-state='active'] { + background-color: oklch(var(--fg-default) / 0.08); + } + + &:hover { + background-color: oklch(var(--fg-default) / 0.12); + } + + &[data-headlessui-state='active']:hover { + background-color: oklch(var(--fg-default) / 0.16); + } + + & + [role='option'] { + margin-top: var(--px-4); + } + } +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search-row.tsx b/packages/graphiql-plugin-doc-explorer/src/components/search-row.tsx new file mode 100644 index 00000000000..04256a68076 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/search-row.tsx @@ -0,0 +1,192 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { + GraphQLArgument, + GraphQLField, + GraphQLInputField, + GraphQLNamedType, + isInputObjectType, + isInterfaceType, + isObjectType, +} from 'graphql'; +import { + Combobox, + ComboboxInput, + ComboboxOptions, + ComboboxOption, +} from '@headlessui/react'; +import { + formatShortcutForOS, + MagnifyingGlassIcon, + KeycapHint, + debounce, + KEY_MAP, +} from '@graphiql/react'; +import { useDocExplorer, useDocExplorerActions } from '../context'; +import { useSearchResults } from './search'; +import { renderType } from './utils'; +import './search-row.css'; + +type TypeMatch = { type: GraphQLNamedType }; +type FieldMatch = { + type: GraphQLNamedType; + field: GraphQLField | GraphQLInputField; + argument?: GraphQLArgument; +}; + +export const SearchRow: FC = () => { + const explorerNavStack = useDocExplorer(); + const { push } = useDocExplorerActions(); + + const inputRef = useRef(null!); + const getSearchResults = useSearchResults(); + const [searchValue, setSearchValue] = useState(''); + const [results, setResults] = useState(() => getSearchResults(searchValue)); + const debouncedGetSearchResults = debounce(200, (search: string) => { + setResults(getSearchResults(search)); + }); + const [ref] = useState(inputRef); + const isFocused = ref.current === document.activeElement; + + useEffect(() => { + debouncedGetSearchResults(searchValue); + }, [debouncedGetSearchResults, searchValue]); + + const navItem = explorerNavStack.at(-1)!; + + const onSelect = (def: TypeMatch | FieldMatch | null) => { + if (!def) { + return; + } + push( + 'field' in def + ? { name: def.field.name, def: def.field } + : { name: def.type.name, def: def.type }, + ); + }; + + const shouldShow = + explorerNavStack.length === 1 || + isObjectType(navItem.def) || + isInterfaceType(navItem.def) || + isInputObjectType(navItem.def); + + if (!shouldShow) { + return null; + } + + const shortcutKeys = formatShortcutForOS(KEY_MAP.searchInDocs.key).split('-'); + + return ( +
+ +
{ + inputRef.current.focus(); + }} + > + + setSearchValue(event.target.value)} + placeholder="Search schema" + ref={inputRef} + value={searchValue} + data-cy="doc-explorer-input" + /> + {!isFocused && !searchValue && ( + + )} +
+ {isFocused && ( + + {results.within.length + + results.types.length + + results.fields.length === + 0 ? ( +
+ No results found +
+ ) : ( + results.within.map((result, i) => ( + + + + )) + )} + {results.within.length > 0 && + results.types.length + results.fields.length > 0 ? ( +
+ Other results +
+ ) : null} + {results.types.map((result, i) => ( + + + + ))} + {results.fields.map((result, i) => ( + + . + + + ))} +
+ )} +
+
+ ); +}; + +const SearchType: FC<{ type: GraphQLNamedType }> = ({ type }) => ( + {type.name} +); + +type SearchFieldProps = { + field: GraphQLField | GraphQLInputField; + argument?: GraphQLArgument; +}; + +const SearchField: FC = ({ field, argument }) => { + return ( + <> + {field.name} + {argument ? ( + <> + ( + + {argument.name} + + :{' '} + {renderType(argument.type, namedType => ( + + ))} + ) + + ) : null} + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search.css b/packages/graphiql-plugin-doc-explorer/src/components/search.css index 7d181150974..7c7ed30be89 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/search.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/search.css @@ -17,20 +17,26 @@ background-color: oklch(var(--fg-default) / 0.08); border-radius: var(--border-radius-4); display: flex; - padding: var(--px-8) var(--px-12); + flex: 1; + gap: var(--px-8); + padding: var(--px-4) var(--px-8); } .graphiql-doc-explorer-search [role='combobox'] { border: none; background-color: transparent; - margin-left: var(--px-4); - width: 100%; + flex: 1; + min-width: 0; &:focus { outline: none; } } +.graphiql-doc-explorer-search:focus-within .graphiql-keycap-hint { + display: none; +} + .graphiql-doc-explorer-search [role='listbox'] { background-color: oklch(var(--bg-canvas)); border: 1px solid oklch(var(--border-default)); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search.tsx b/packages/graphiql-plugin-doc-explorer/src/components/search.tsx index 8e52a424618..f9e1fd806ab 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/search.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/search.tsx @@ -17,6 +17,7 @@ import { import { formatShortcutForOS, useGraphiQL, + KeycapHint, MagnifyingGlassIcon, debounce, KEY_MAP, @@ -58,6 +59,8 @@ export const Search: FC = () => { : { name: def.type.name, def: def.type }, ); }; + const shortcutKeys = formatShortcutForOS(KEY_MAP.searchInDocs.key).split('-'); + const shouldSearchBoxAppear = explorerNavStack.length === 1 || isObjectType(navItem.def) || @@ -85,13 +88,12 @@ export const Search: FC = () => { setSearchValue(event.target.value)} - placeholder={formatShortcutForOS( - formatShortcutForOS(KEY_MAP.searchInDocs.key).replaceAll('-', ' '), - )} + placeholder="Search Docs" ref={inputRef} value={searchValue} data-cy="doc-explorer-input" /> +
{isFocused && ( diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-card.css b/packages/graphiql-plugin-doc-explorer/src/components/type-card.css new file mode 100644 index 00000000000..73e8dd98dcc --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-card.css @@ -0,0 +1,76 @@ +.graphiql-doc-explorer-type-card { + border-top: 1px solid oklch(var(--border-default)); +} + +.graphiql-doc-explorer-type-card-header { + display: flex; + align-items: center; + gap: var(--px-6); + margin-bottom: var(--px-4); +} + +/* [TYPE] badge */ +.graphiql-doc-explorer-type-badge { + padding: 1px 6px; + background: oklch(var(--accent-blue) / 0.12); + color: oklch(var(--accent-blue)); + font-family: var(--font-family-mono); + font-size: 10px; + font-weight: 600; + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.graphiql-doc-explorer-type-card-name { + font-family: var(--font-family-mono); + font-size: 14px; + font-weight: 600; + color: oklch(var(--fg-strong)); + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.graphiql-doc-explorer-type-card-description { + margin: 0 0 var(--px-6); + font-size: 12px; + color: oklch(var(--fg-muted)); + line-height: 17px; +} + +/* implements row */ +.graphiql-doc-explorer-type-card-implements { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--px-6); + margin-top: var(--px-6); + font-size: 10.5px; + color: oklch(var(--fg-subtle)); +} + +.graphiql-doc-explorer-type-card-implements-keyword { + font-family: var(--font-family-mono); +} + +.graphiql-doc-explorer-type-card-implements-item { + display: flex; + align-items: center; + gap: var(--px-6); +} + +.graphiql-doc-explorer-type-card-implements-dot { + color: oklch(var(--fg-dim)); +} + +a.graphiql-doc-explorer-type-card-implements-link { + font-family: var(--font-family-mono); + color: oklch(var(--accent-orange)); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-card.tsx b/packages/graphiql-plugin-doc-explorer/src/components/type-card.tsx new file mode 100644 index 00000000000..52ed749ef6a --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-card.tsx @@ -0,0 +1,92 @@ +import type { FC } from 'react'; +import { + GraphQLNamedType, + isObjectType, + isInterfaceType, + isInputObjectType, + isEnumType, + isScalarType, + isUnionType, +} from 'graphql'; +import { useDocExplorerActions } from '../context'; +import './type-card.css'; + +function getTypeKind(type: GraphQLNamedType): string { + if (isObjectType(type)) { + return 'TYPE'; + } + if (isInterfaceType(type)) { + return 'INTERFACE'; + } + if (isInputObjectType(type)) { + return 'INPUT'; + } + if (isEnumType(type)) { + return 'ENUM'; + } + if (isScalarType(type)) { + return 'SCALAR'; + } + if (isUnionType(type)) { + return 'UNION'; + } + return 'TYPE'; +} + +type TypeCardProps = { + type: GraphQLNamedType; +}; + +export const TypeCard: FC = ({ type }) => { + const { push } = useDocExplorerActions(); + const kind = getTypeKind(type); + const interfaces = isObjectType(type) ? type.getInterfaces() : []; + + return ( +
+
+ {kind} + + {type.name} + +
+ {type.description && ( +

+ {type.description} +

+ )} + {interfaces.length > 0 && ( +
+ + implements + + {interfaces.map((iface, i) => ( + + {i > 0 && ( + + )} + { + event.preventDefault(); + push({ name: iface.name, def: iface }); + }} + > + {iface.name} + + + ))} +
+ )} +
+ ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx index 219ecf3488b..b2d010de80d 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx @@ -24,16 +24,25 @@ type TypeDocumentationProps = { * The type that should be rendered. */ type: GraphQLNamedType; + /** + * When true, the description, implements-interfaces, and fields sections + * are omitted. Used when TypeCard + FieldsList are already rendering those + * above the type documentation. + */ + hideHeader?: boolean; }; -export const TypeDocumentation: FC = ({ type }) => { +export const TypeDocumentation: FC = ({ + type, + hideHeader, +}) => { return isNamedType(type) ? ( <> - {type.description ? ( + {!hideHeader && type.description ? ( {type.description} ) : null} - - + {!hideHeader && } + {!hideHeader && } diff --git a/packages/graphiql-plugin-doc-explorer/src/context.tsx b/packages/graphiql-plugin-doc-explorer/src/context.tsx index e0bb2c97a8a..91e12425063 100644 --- a/packages/graphiql-plugin-doc-explorer/src/context.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/context.tsx @@ -96,7 +96,7 @@ export type DocExplorerStoreType = { }; }; -const INITIAL_NAV_STACK: DocExplorerNavStack = [{ name: 'Docs' }]; +const INITIAL_NAV_STACK: DocExplorerNavStack = [{ name: 'Root' }]; export const docExplorerStore = createStore( (set, get) => ({ diff --git a/packages/graphiql-plugin-doc-explorer/src/stories/doc-explorer.stories.tsx b/packages/graphiql-plugin-doc-explorer/src/stories/doc-explorer.stories.tsx index d699a79a48b..904ab8b8875 100644 --- a/packages/graphiql-plugin-doc-explorer/src/stories/doc-explorer.stories.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/stories/doc-explorer.stories.tsx @@ -9,12 +9,16 @@ import { GraphQLObjectType, GraphQLSchema, GraphQLString, + GraphQLUnionType, } from 'graphql'; import { Tooltip } from '@graphiql/react'; import { DocExplorerStore } from '../context'; import { TypeDocumentation } from '../components/type-documentation'; import { FieldDocumentation } from '../components/field-documentation'; import { SchemaDocumentation } from '../components/schema-documentation'; +import { TypeCard } from '../components/type-card'; +import { FieldsList } from '../components/fields-list'; +import { Breadcrumb } from '../components/breadcrumb'; import { TypeLink } from '../components/type-link'; import { FieldLink } from '../components/field-link'; import { Argument } from '../components/argument'; @@ -87,6 +91,12 @@ const HumanType = new GraphQLObjectType({ }), }); +const SearchResultUnion = new GraphQLUnionType({ + name: 'SearchResult', + description: 'A result from a global search', + types: [HumanType], +}); + const QueryType = new GraphQLObjectType({ name: 'Query', description: 'The root query type for the Star Wars API', @@ -116,6 +126,10 @@ const QueryType = new GraphQLObjectType({ type: GraphQLBoolean, deprecationReason: 'Use `hero` with status field instead', }, + search: { + type: SearchResultUnion, + description: 'Search across all types', + }, }, }); @@ -137,7 +151,13 @@ const StarWarsSchema = new GraphQLSchema({ description: 'The Star Wars GraphQL API', query: QueryType, mutation: MutationType, - types: [HumanType, CharacterInterface, EpisodeEnum, ReviewInputType], + types: [ + HumanType, + CharacterInterface, + EpisodeEnum, + ReviewInputType, + SearchResultUnion, + ], }); function withDocExplorerStore(Story: React.FC) { @@ -167,24 +187,51 @@ export const SchemaOverview: Story = { }, }; -export const TypeDetail: Story = { - name: 'Type detail (Human)', - render: function TypeDetailStory() { - return ; +export const TypeDetailObject: Story = { + name: 'Type detail — Object', + render: function TypeDetailObjectStory() { + return ( + <> + + + + ); }, }; -export const EnumTypeDetail: Story = { - name: 'Type detail (Enum)', - render: function EnumTypeDetailStory() { - return ; +export const TypeDetailInterface: Story = { + name: 'Type detail — Interface', + render: function TypeDetailInterfaceStory() { + return ( + <> + + + + ); }, }; -export const InputTypeDetail: Story = { - name: 'Type detail (Input)', - render: function InputTypeDetailStory() { - return ; +export const TypeDetailInput: Story = { + name: 'Type detail — Input', + render: function TypeDetailInputStory() { + return ( + <> + + + + ); + }, +}; + +export const TypeDetailEnum: Story = { + name: 'Type detail — Enum', + render: function TypeDetailEnumStory() { + return ( + <> + + + + ); }, }; @@ -196,6 +243,18 @@ export const FieldDetail: Story = { }, }; +export const BreadcrumbNav: Story = { + name: 'Breadcrumb navigation', + render: function BreadcrumbNavStory() { + const navStack: Parameters[0]['navStack'] = [ + { name: 'Root' }, + { name: 'Query', def: QueryType }, + { name: 'Human', def: HumanType }, + ]; + return {}} />; + }, +}; + export const TokenColors: Story = { name: 'Token colors', render: function TokenColorsStory() { diff --git a/packages/graphiql/cypress/e2e/docs.cy.ts b/packages/graphiql/cypress/e2e/docs.cy.ts index 031113d68a5..1f0b369c81f 100644 --- a/packages/graphiql/cypress/e2e/docs.cy.ts +++ b/packages/graphiql/cypress/e2e/docs.cy.ts @@ -31,12 +31,15 @@ describe('GraphiQL DocExplorer - search', () => { it('Navigates to a docs entry on selecting a search result', () => { cy.dataCy('doc-explorer-option').eq(4).children().click(); - cy.get('.graphiql-doc-explorer-title').should('have.text', 'TestInput'); + cy.get('.graphiql-doc-explorer-breadcrumb-current').should( + 'have.text', + 'TestInput', + ); }); it('Allows searching fields within a type', () => { cy.dataCy('doc-explorer-option').eq(4).children().click(); - cy.dataCy('doc-explorer-input').type('list'); + cy.dataCy('doc-explorer-input').clear().type('list'); cy.dataCy('doc-explorer-option').should('have.length', 14); cy.get('.graphiql-doc-explorer-search-divider').should( 'have.text', @@ -54,16 +57,21 @@ describe('GraphiQL DocExplorer - search', () => { it('Navigates back', () => { cy.dataCy('doc-explorer-option').eq(4).children().click(); - cy.get('.graphiql-doc-explorer-back').click(); - cy.get('.graphiql-doc-explorer-title').should('have.text', 'Docs'); + // Click the root breadcrumb segment (first link, at depth 0 = "Root") + cy.get('.graphiql-doc-explorer-breadcrumb-root').click(); + // After navigating back, breadcrumb disappears (at root level, no breadcrumb shown) + cy.get('.graphiql-doc-explorer-breadcrumb').should('not.exist'); }); it('Type fields link to their own docs entry', () => { cy.dataCy('doc-explorer-option').last().click(); - cy.get('.graphiql-doc-explorer-title').should('have.text', 'isTest'); - cy.get('.graphiql-markdown-description').should( + cy.get('.graphiql-doc-explorer-breadcrumb-current').should( 'have.text', - 'Is this a test schema? Sure it is.\n', + 'isTest', + ); + cy.get('.graphiql-doc-explorer-field-card-description').should( + 'have.text', + 'Is this a test schema? Sure it is.', ); }); }); @@ -79,20 +87,21 @@ describe('GraphQL DocExplorer - deprecated fields', () => { // Show deprecated fields cy.contains('Show Deprecated Fields').click(); - // Assert that title is shown - cy.get('.graphiql-doc-explorer-section-title').contains( - 'Deprecated Fields', - ); + // Click into the deprecated field to view its documentation + cy.contains( + 'button.graphiql-doc-explorer-field-row--deprecated', + 'deprecatedField', + ).click(); - // Assert that the deprecated field is shown correctly - cy.get('.graphiql-doc-explorer-field-name') - .contains('deprecatedField') - .closest('.graphiql-doc-explorer-item') - .should('contain.text', 'This field is an example of a deprecated field') - .and( - 'contain.html', - '

No longer in use, try test instead.

', - ); + // Assert description and deprecation reason are shown + cy.get('.graphiql-doc-explorer-field-card-description').should( + 'contain.text', + 'This field is an example of a deprecated field', + ); + cy.get('.graphiql-markdown-deprecation').should( + 'contain.html', + '

No longer in use, try test instead.

', + ); }); }); @@ -107,13 +116,13 @@ describeOrSkip('GraphQL DocExplorer - deprecated arguments', () => { // Open doc explorer cy.get('.graphiql-activity-rail-item').eq(0).click(); - // Select query type - cy.get('.graphiql-doc-explorer-type-name').first().click(); + // Use search to navigate directly to hasArgs + cy.dataCy('doc-explorer-input').type('hasArgs'); + cy.dataCy('doc-explorer-option').first().children().first().click(); - cy.get('.graphiql-doc-explorer-field-name').contains('hasArgs').click(); cy.contains('Show Deprecated Arguments').click(); - cy.get('.graphiql-doc-explorer-section-title').contains( - 'Deprecated Arguments', + cy.get('.graphiql-doc-explorer-arguments-list-header').contains( + 'DEPRECATED ARGUMENTS', ); cy.get('.graphiql-markdown-deprecation').should( 'have.text',