diff --git a/package.json b/package.json index 1da4947359..04fb75dd51 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "repository": "graphql/graphql.github.io website", "private": true, - "packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67", + "packageManager": "pnpm@10.15.0", "scripts": { "analyze": "ANALYZE=true next build", "build": "next build && next-image-export-optimizer", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d15d1648f..d1e3b10afb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,7 +57,7 @@ importers: version: 15.5.0 '@radix-ui/react-radio-group': specifier: ^1.2.2 - version: 1.3.8(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sparticuz/chromium': specifier: ^138.0.2 version: 138.0.2 @@ -2174,6 +2174,11 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + '@types/react@18.3.24': resolution: {integrity: sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==} @@ -7456,16 +7461,17 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-collection@1.1.7(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.24)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.24)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.24 + '@types/react-dom': 18.3.7(@types/react@18.3.24) '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.24)(react@18.3.1)': dependencies: @@ -7492,7 +7498,7 @@ snapshots: optionalDependencies: '@types/react': 18.3.24 - '@radix-ui/react-presence@1.1.5(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.24)(react@18.3.1) @@ -7500,24 +7506,26 @@ snapshots: react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.24 + '@types/react-dom': 18.3.7(@types/react@18.3.24) - '@radix-ui/react-primitive@2.1.3(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@18.3.24)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.24 + '@types/react-dom': 18.3.7(@types/react@18.3.24) - '@radix-ui/react-radio-group@1.3.8(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.24)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.24)(react@18.3.1) @@ -7525,22 +7533,24 @@ snapshots: react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.24 + '@types/react-dom': 18.3.7(@types/react@18.3.24) - '@radix-ui/react-roving-focus@1.1.11(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.24)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.24)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.24)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.24 + '@types/react-dom': 18.3.7(@types/react@18.3.24) '@radix-ui/react-slot@1.2.3(@types/react@18.3.24)(react@18.3.1)': dependencies: @@ -8019,6 +8029,11 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/react-dom@18.3.7(@types/react@18.3.24)': + dependencies: + '@types/react': 18.3.24 + optional: true + '@types/react@18.3.24': dependencies: '@types/prop-types': 15.7.15 diff --git a/public/images/next-image-export-optimizer-hashes.json b/public/images/next-image-export-optimizer-hashes.json index 0a24a53bb1..04065b5dd5 100644 --- a/public/images/next-image-export-optimizer-hashes.json +++ b/public/images/next-image-export-optimizer-hashes.json @@ -52,7 +52,7 @@ "/annual-report-1.5ebe2b34.png": "HmbFFbaUL79rvnCKQ-2oRSLETM2FFh5v5dZxwWquuVM=", "/audience.f60c1c99.jpg": "pqx3E31xAO87mNEBlZKqCTX+LRiPlOuQThWQZf08A4A=", "/banner.10d4d66b.jpg": "9UJqBQ9RQu2sxDdJ5uaQr3crx2ZXrlOKMAmY82R8ZBA=", - "/blur-bean-cropped.62af4aa2.webp": "rdPhhzi5e+RLv-u0B-uPkp-eCYnyGlO84Yn0zCLLG4c=", + "/-cropped.62af4aa2.webp": "rdPhhzi5e+RLv-u0B-uPkp-eCYnyGlO84Yn0zCLLG4c=", "/blur-bean.21b930bd.webp": "eTUigN2JSyvccNXMnRwneZJ1YIeNnrVs3klseGSUa7o=", "/blur-bean.314cdc4a.webp": "YAysN2NZeYYWHNI8cFCabzsTifCknmbp-r+P1LAs1bE=", "/blur-bean.d5aa6d13.webp": "30xrtHSB6py7q6r2HxdKzm4gt8WoCiWRownamqyf3wM=", @@ -189,4 +189,4 @@ "/uri.387cb001.jpg": "kSx4huEjQidwIg6bF8UEWLiPACDl0nQ0aqxA2R2LIe0=", "/whiteboard.60eac8e3.jpg": "NodBqUaO+IanhuPaP9o5jCIe+gSrwyZ9TZ3QUdlWbBg=", "/workshop.e02e3501.jpg": "D9ON1z6-vKcjxv50gOH+5XS9HTEWUpc4UgIPW5OXHxE=" -} \ No newline at end of file +} diff --git a/scripts/sync-sched/schedule-2025.json b/scripts/sync-sched/schedule-2025.json index 5fe42530b1..735551fe0c 100644 --- a/scripts/sync-sched/schedule-2025.json +++ b/scripts/sync-sched/schedule-2025.json @@ -4576,4 +4576,4 @@ "event_subtype": "", "description": "" } -] \ No newline at end of file +] diff --git a/scripts/sync-sched/speakers.json b/scripts/sync-sched/speakers.json index e87f68ff22..431c53b803 100644 --- a/scripts/sync-sched/speakers.json +++ b/scripts/sync-sched/speakers.json @@ -3471,4 +3471,4 @@ "~syncedDetailsAt": 1756904595242 } ] -} \ No newline at end of file +} diff --git a/src/app/conf/2025/components/navbar.tsx b/src/app/conf/2025/components/navbar.tsx index e308f6a596..74a95b84bb 100644 --- a/src/app/conf/2025/components/navbar.tsx +++ b/src/app/conf/2025/components/navbar.tsx @@ -21,7 +21,6 @@ export function Navbar({ links, year }: NavbarProps): ReactElement { const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false) const handleDrawerClick = useCallback(() => { - // todo: block scrolling on body setMobileDrawerOpen(prev => !prev) }, []) diff --git a/src/app/conf/2025/components/register-today/index.tsx b/src/app/conf/2025/components/register-today/index.tsx index 193a443ad0..f7018b1edf 100644 --- a/src/app/conf/2025/components/register-today/index.tsx +++ b/src/app/conf/2025/components/register-today/index.tsx @@ -18,7 +18,6 @@ export function RegisterToday({ className }: RegisterTodayProps) { className, )} > - {/* todo: placeholders work in preview, but they could use some improvement */}
diff --git a/src/components/index-page/hero/index.tsx b/src/components/index-page/hero/index.tsx index 16593ace4a..5b2d6ddf63 100644 --- a/src/components/index-page/hero/index.tsx +++ b/src/components/index-page/hero/index.tsx @@ -57,8 +57,8 @@ function HeroStripes() { image={logoBlurred} className="relative h-full bg-gradient-to-b from-pri-base to-pri-lighter opacity-0 transition-opacity duration-[1.5s] [mask-position:center_12%] [mask-size:110%] data-[loaded=true]:opacity-100 dark:to-pri-base sm:[mask-size:auto_300%] lg:[mask-position:7%_7%] lg:[mask-size:200%]" style={{ - maskImage: `url(${logoBlurred.src})`, - WebkitMaskImage: `url(${logoBlurred.src})`, + maskImage: `var(--src)`, + WebkitMaskImage: `var(--src)`, maskRepeat: "no-repeat", WebkitMaskRepeat: "no-repeat", }} diff --git a/src/components/index-page/how-it-works.tsx b/src/components/index-page/how-it-works.tsx deleted file mode 100644 index 20842ac2e1..0000000000 --- a/src/components/index-page/how-it-works.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Button } from "@/app/conf/_design-system/button" -import { SectionLabel } from "@/app/conf/_design-system/section-label" - -import { CodeA, CodeB, CodeC } from "../code-blocks" - -const TRY_IT_OUT_URL = "https://graphql.org/swapi-graphql" - -export function HowItWorks() { - return ( -
- How it works -

A GraphQL Query

-
    - } /> - - {/* TODO: Instead of importing CodeB and CodeC, we'll refactor MiniGraphiQL and dynamically import it here. - Required changes: - - [ ] Move VariableEditor and QueryEditor to separate files. - - [ ] Import them here with the raw code snippets. - - */} - } /> - } /> -
- - -
- ) -} - -function ListItem({ - text, - code, -}: { - text: React.ReactNode - code: React.ReactNode -}) { - return ( -
  • -
    - {text} -
    -
    - {code} -
    -
  • - ) -} diff --git a/src/components/index-page/how-it-works/how-it-works-list-item.tsx b/src/components/index-page/how-it-works/how-it-works-list-item.tsx new file mode 100644 index 0000000000..261b96b40f --- /dev/null +++ b/src/components/index-page/how-it-works/how-it-works-list-item.tsx @@ -0,0 +1,21 @@ +export function HowItWorksListItem({ + text, + code, + icon, +}: { + text: React.ReactNode + code: React.ReactNode + icon?: React.ReactNode +}) { + return ( +
  • +
    + {text} + {icon} +
    +
    + {code} +
    +
  • + ) +} diff --git a/src/components/index-page/how-it-works/index.tsx b/src/components/index-page/how-it-works/index.tsx new file mode 100644 index 0000000000..4b9360c472 --- /dev/null +++ b/src/components/index-page/how-it-works/index.tsx @@ -0,0 +1,59 @@ +import { useRef } from "react" +import { useInView } from "motion/react" +import dynamic from "next/dynamic" + +import { Button } from "@/app/conf/_design-system/button" +import { SectionLabel } from "@/app/conf/_design-system/section-label" +import { CodeA, CodeB, CodeC } from "@/components/code-blocks" + +import { HowItWorksListItem } from "./how-it-works-list-item" +import { PlayButton } from "./play-button" + +const InteractiveEditor = dynamic(() => import("./interactive-editor"), { + ssr: false, +}) + +const TRY_IT_OUT_URL = "https://graphql.org/swapi-graphql" + +export function HowItWorks() { + const sectionRef = useRef(null) + // todo: we could technically consider loading the chunk on hover or focus, + // just so people scrolling through the page don't download CodeMirror + const inView = useInView(sectionRef) + + return ( +
    + How it works +

    A GraphQL Query

    +
    +
      + } /> + } + code={} + /> + } /> +
    + {inView && ( +
      +
      + +
    + )} +
    + + +
    + ) +} diff --git a/src/components/index-page/how-it-works/interactive-editor.tsx b/src/components/index-page/how-it-works/interactive-editor.tsx new file mode 100644 index 0000000000..89fb033e2e --- /dev/null +++ b/src/components/index-page/how-it-works/interactive-editor.tsx @@ -0,0 +1,120 @@ +import { graphql } from "graphql" +import React, { useState } from "react" + +import { getVariableToType } from "@/components/interactive-code-block/get-variable-to-type" +import { QueryEditor } from "@/components/interactive-code-block/query-editor" +import { ResultViewer } from "@/components/interactive-code-block/result-viewer" +import { VariableEditor } from "@/components/interactive-code-block/variable-editor" +import { CodeBlockLabel } from "@/components/pre/code-block-label" + +import { HowItWorksListItem } from "./how-it-works-list-item" +import { PlayButton } from "./play-button" +import { + INITIAL_QUERY_TEXT, + INITIAL_RESULTS_TEXT, + projectsSchema as schema, +} from "./schema" + +export default function InteractiveEditor() { + const [query, setQuery] = useState(INITIAL_QUERY_TEXT) + const [results, setResults] = useState(INITIAL_RESULTS_TEXT) + const [variableTypes, setVariableTypes] = useState>({}) + const [variables, setVariables] = useState("") + const editorQueryId = React.useRef(0) + + async function runQuery( + options: { manual: boolean }, + source: string = query, + ) { + editorQueryId.current++ + const queryID = editorQueryId.current + try { + const result = await graphql({ + schema, + source, + variableValues: JSON.parse(variables || "{}"), + }) + + let resultToSerialize: any = result + if (result.errors) { + if (!options.manual) { + // if the query was ran on edit, we display errors on the left side + // so we can just return instead of showing the resulting error + return + } + + // Convert errors to serializable format + const serializedErrors = result.errors.map(error => ({ + message: error.message, + locations: error.locations, + path: error.path, + })) + // Replace errors with serialized version for JSON.stringify + resultToSerialize = { ...result, errors: serializedErrors } + } + + if (queryID === editorQueryId.current) { + setResults(JSON.stringify(resultToSerialize, null, 2)) + } + } catch (error) { + if (queryID === editorQueryId.current) { + setResults(JSON.stringify(error, null, 2)) + } + } + } + + const editor = ( + { + setQuery(newQuery) + runQuery({ manual: false }, newQuery) + }} + runQuery={() => { + setVariableTypes(getVariableToType(schema, query)) + runQuery({ manual: true }) + }} + /> + ) + + return ( + <> + { + void runQuery({ manual: true }) + }} + /> + } + code={ + Object.keys(variableTypes).length > 0 ? ( +
    + {editor} +
    + + void runQuery({ manual: false })} + /> +
    +
    + ) : ( + editor + ) + } + /> + } + /> + + ) +} diff --git a/src/components/index-page/how-it-works/play-button.tsx b/src/components/index-page/how-it-works/play-button.tsx new file mode 100644 index 0000000000..394847b556 --- /dev/null +++ b/src/components/index-page/how-it-works/play-button.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx" +import { Button } from "../../../app/conf/_design-system/button" + +export function PlayButton({ + className, + ...props +}: React.ButtonHTMLAttributes) { + return ( + + ) +} diff --git a/src/components/index-page/how-it-works/schema.ts b/src/components/index-page/how-it-works/schema.ts new file mode 100644 index 0000000000..f8f4e7c927 --- /dev/null +++ b/src/components/index-page/how-it-works/schema.ts @@ -0,0 +1,99 @@ +import { + GraphQLSchema, + GraphQLObjectType, + GraphQLString, + GraphQLNonNull, + GraphQLList, +} from "graphql" + +const PROJECT_NAME = "GraphQL" +const PROJECT_TAGLINE = "A query language for APIs" +const PROJECT_DESCRIPTION = + "GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data." +const PROJECT_WEBSITE = "https://graphql.org" + +export const INITIAL_QUERY_TEXT = `{ + project(name: "${PROJECT_NAME}") { + tagline + } +}` + +export const INITIAL_RESULTS_TEXT = `{ + "project": { + "tagline": "${PROJECT_TAGLINE}" + } +}` + +interface Project { + name: string + tagline: string + description: string + website: string +} + +const projects: Project[] = [ + { + name: PROJECT_NAME, + tagline: PROJECT_TAGLINE, + description: PROJECT_DESCRIPTION, + website: PROJECT_WEBSITE, + }, + { + name: "GraphiQL", + tagline: "Ecosystem for building browser & IDE tools.", + description: + "GraphiQL is the reference implementation of this monorepo, GraphQL IDE, an official project under the GraphQL Foundation. The code uses the permissive MIT license.", + website: "https://github.com/graphql/graphiql", + }, +] + +const ProjectType = new GraphQLObjectType({ + name: "Project", + fields: { + name: { + type: GraphQLString, + description: "The name of the project", + }, + tagline: { + type: GraphQLString, + description: "A short description of what the project does", + }, + description: { + type: GraphQLString, + description: "A detailed description of the project", + }, + website: { + type: GraphQLString, + description: "The project website URL", + }, + }, +}) + +const QueryType = new GraphQLObjectType({ + name: "Query", + fields: { + project: { + type: ProjectType, + args: { + name: { + type: new GraphQLNonNull(GraphQLString), + description: "The name of the project to retrieve", + }, + }, + resolve: (_, args) => { + return projects.find( + project => project.name.toLowerCase() === args.name.toLowerCase(), + ) + }, + }, + projects: { + type: new GraphQLList(ProjectType), + description: "Get all available projects", + resolve: () => projects, + }, + }, +}) + +export const projectsSchema = new GraphQLSchema({ + query: QueryType, +}) diff --git a/src/components/index-page/use-cases/index.tsx b/src/components/index-page/use-cases/index.tsx index 67f460dde5..e7e46d610d 100644 --- a/src/components/index-page/use-cases/index.tsx +++ b/src/components/index-page/use-cases/index.tsx @@ -139,7 +139,9 @@ export function UseCases({ id={`graphql-use-case-${i}`} className={clsx( "relative h-full flex-1 p-8 lg:p-12 xl:p-16", - selectedIndex === i ? "border-b border-sec-dark" : "hidden", + selectedIndex === i + ? "max-lg:border-b max-lg:border-sec-dark" + : "hidden", )} >
    diff --git a/src/components/interactive-code-block/mini-graphiQL.tsx b/src/components/interactive-code-block/mini-graphiQL.tsx index ac8004aa89..ef08bb09fc 100644 --- a/src/components/interactive-code-block/mini-graphiQL.tsx +++ b/src/components/interactive-code-block/mini-graphiQL.tsx @@ -94,9 +94,12 @@ export default class MiniGraphiQL extends Component< ) return ( -
    +
    {Object.keys(this.state.variableToType).length > 0 ? ( -
    +
    {editor}
    { componentDidUpdate() { if (!this.view) return + let value = this.props.value || "" + if (this.props.vainlyExtractData) { + try { + const json = JSON.parse(value) + if (json && typeof json === "object" && "data" in json) { + value = JSON.stringify(json.data, null, 2) + } + } catch { + // ignore + } + } + this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, - insert: this.props.value || "", + insert: value, }, }) } diff --git a/src/components/interactive-code-block/syntax-highlighting.css b/src/components/interactive-code-block/syntax-highlighting.css index 62580fb102..fd24ab4726 100644 --- a/src/components/interactive-code-block/syntax-highlighting.css +++ b/src/components/interactive-code-block/syntax-highlighting.css @@ -63,6 +63,7 @@ background: var(--cm-background); color: var(--cm-foreground); position: relative; + max-height: 184px; & .cm-content { padding: 16px 0; @@ -75,6 +76,8 @@ & .cm-scroller { line-height: 1.5; font-family: var(--font-mono); + overflow: auto; + -webkit-font-smoothing: auto; } } diff --git a/src/components/interactive-code-block/variable-editor.tsx b/src/components/interactive-code-block/variable-editor.tsx index 73fe6a5e7c..d73f1829bc 100644 --- a/src/components/interactive-code-block/variable-editor.tsx +++ b/src/components/interactive-code-block/variable-editor.tsx @@ -3,7 +3,6 @@ import { EditorView } from "@codemirror/view" import { EditorState } from "@codemirror/state" import { json } from "@codemirror/lang-json" import { history } from "@codemirror/commands" -import { syntaxHighlighting } from "@codemirror/language" import { codeMirrorThemeExtension } from "./codemirror-theme" interface VariableEditorProps { @@ -44,7 +43,6 @@ export class VariableEditor extends Component { componentDidMount() { if (!this.domNode) return - // Create editor state for JSON (variables are JSON) const state = EditorState.create({ doc: this.props.value || "", extensions: [ diff --git a/test/e2e/graphql-interactive.spec.ts b/test/e2e/graphql-interactive.spec.ts index 84946b81cd..a03d57cb65 100644 --- a/test/e2e/graphql-interactive.spec.ts +++ b/test/e2e/graphql-interactive.spec.ts @@ -1,79 +1,99 @@ -import { test, expect, type Locator } from "@playwright/test" - -test.describe("interactive examples", () => { - test("adds appearsIn field to hero query and gets correct response", async ({ - page, - }) => { - await page.goto("/learn") - await page.waitForSelector(".cm-editor", { timeout: 10000 }) - - const editors = page.locator(".cm-editor") - let heroEditor: Locator | null = null - - for (let i = 0; i < (await editors.count()); i++) { - const editor = editors.nth(i) - const content = await editor.textContent() - if (content && content.includes("hero")) { - heroEditor = editor - break - } +import { test, expect, type Locator, type Page } from "@playwright/test" + +// Helper functions to reduce duplication +async function findEditorByContent( + page: Page, + searchText: string, +): Promise { + const editors = page.locator(".cm-editor") + + for (let i = 0; i < (await editors.count()); i++) { + const editor = editors.nth(i) + const content = await editor.textContent() + if (content && content.includes(searchText)) { + return editor } - - if (!heroEditor) { - throw new Error("Could not find hero GraphQL editor") + } + + throw new Error(`Could not find GraphQL editor containing "${searchText}"`) +} + +async function typeInQuery( + page: Page, + editor: Locator, + afterText: string, + newField: string, +): Promise { + const codeLines = editor.locator(".cm-line") + + for (let i = 0; i < (await codeLines.count()); i++) { + const line = codeLines.nth(i) + const lineText = await line.textContent() + if (lineText && lineText.includes(afterText)) { + await line.click() + await page.keyboard.press("End") + await page.keyboard.press("Enter") + await page.keyboard.type(newField) + break } - - const codeLines = heroEditor.locator(".cm-line") - - // Find the line containing "name" and click after it - for (let i = 0; i < (await codeLines.count()); i++) { - const line = codeLines.nth(i) - const lineText = await line.textContent() - if (lineText && lineText.includes("name")) { - await line.click() - // Move to end of line - await page.keyboard.press("End") - // Add new line - await page.keyboard.press("Enter") - break + } +} + +async function expectJsonResult( + resultViewer: Locator, + expectedResult: object, +): Promise { + await expect(resultViewer).toBeVisible() + + await expect + .poll(async () => { + const resultContent = await resultViewer.textContent() + const jsonMatch = resultContent?.match(/\{[\s\S]*\}/) + if (jsonMatch) { + try { + return JSON.parse(jsonMatch[0]) + } catch { + return {} + } } - } + return {} + }) + .toStrictEqual(expectedResult) +} - await page.keyboard.type("ap") - await page.keyboard.press("Control+Space") +test.describe("interactive examples", () => { + test.describe("Learn", () => { + test("adds appearsIn field to hero query and gets correct response", async ({ + page, + }) => { + await page.goto("/learn") + await page.waitForSelector(".cm-editor", { timeout: 10000 }) - const autoCompleteMenu = page.locator(".cm-tooltip-autocomplete") - await expect(autoCompleteMenu).toBeVisible({ timeout: 5000 }) + const heroEditor = await findEditorByContent(page, "hero") - const appearsInSuggestion = page - .locator(".cm-completionLabel") - .filter({ hasText: "appearsIn" }) + await typeInQuery(page, heroEditor, "name", "ap") + await page.keyboard.press("Control+Space") - expect(page.locator(".cm-completionDetail").first()).toHaveText( - "[Episode]!", - ) + const autoCompleteMenu = page.locator(".cm-tooltip-autocomplete") + await expect(autoCompleteMenu).toBeVisible({ timeout: 5000 }) - if (await appearsInSuggestion.isVisible()) { - await appearsInSuggestion.click() - } else { - await page.keyboard.press("Enter") - } + const appearsInSuggestion = page + .locator(".cm-completionLabel") + .filter({ hasText: "appearsIn" }) - const resultViewer = page.locator(".result-window").first() - await expect(resultViewer).toBeVisible() + expect(page.locator(".cm-completionDetail").first()).toHaveText( + "[Episode]!", + ) - await expect - .poll(async () => { - const resultContent = await resultViewer.textContent() - const jsonMatch = resultContent?.match(/\{[\s\S]*\}/) - if (jsonMatch) { - const responseJson = JSON.parse(jsonMatch[0]) - return responseJson - } + if (await appearsInSuggestion.isVisible()) { + await appearsInSuggestion.click() + } else { + await page.keyboard.press("Enter") + } - return {} - }) - .toStrictEqual({ + const resultViewer = page.locator(".result-window").first() + + await expectJsonResult(resultViewer, { data: { hero: { name: "R2-D2", @@ -81,43 +101,67 @@ test.describe("interactive examples", () => { }, }, }) - }) + }) + + test("edits variables and receives an expected mutation result", async ({ + page, + }) => { + await page.goto("/learn/mutations") + await page.waitForLoadState("networkidle") - test("edits variables and receives an expected mutation result", async ({ - page, - }) => { - await page.goto("/learn/mutations") - await page.waitForLoadState("networkidle") - - // Find the mutation example that has GraphiQL enabled - const editors = page.locator(".cm-editor") - let mutationEditor: Locator | null = null - - for (let i = 0; i < (await editors.count()); i++) { - const editor = editors.nth(i) - const content = await editor.textContent() - if (content && content.includes("CreateReviewForEpisode")) { - mutationEditor = editor - break + const mutationEditor = await findEditorByContent( + page, + "CreateReviewForEpisode", + ) + + const variableEditor = mutationEditor.locator(".variable-editor").first() + + if (await variableEditor.isVisible()) { + await variableEditor.click() + + await page.getByText('"This is a great movie!"').first().click() + await page.keyboard.press("ControlOrMeta+ArrowRight") + for (let i = 0; i < 4; i++) + await page.keyboard.press("Alt+Shift+ArrowLeft") + await page.keyboard.type('almost as good as Andor"') + + const resultViewer = mutationEditor.locator(".result-window") + + await expectJsonResult(resultViewer, { + data: { + createReview: { + stars: 5, + commentary: "This is almost as good as Andor", + }, + }, + }) } - } + }) + }) - if (!mutationEditor) { - throw new Error("Could not find mutation GraphQL editor") - } + test.describe("Landing", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForLoadState("networkidle") + page.locator(`text="How it works"`).scrollIntoViewIfNeeded() + }) + + test("allows editing query and gets updated results", async ({ page }) => { + await page.waitForSelector(".cm-editor", { timeout: 10000 }) - const variableEditor = mutationEditor.locator(".variable-editor").first() + const editor = page.locator(".cm-editor").first() - if (await variableEditor.isVisible()) { - await variableEditor.click() + await editor.click() - await page.getByText('"This is a great movie!"').first().click() - await page.keyboard.press("ControlOrMeta+ArrowRight") - for (let i = 0; i < 4; i++) - await page.keyboard.press("Alt+Shift+ArrowLeft") - await page.keyboard.type('almost as good as Andor"') + await typeInQuery(page, editor, "tagline", "des") - const resultViewer = mutationEditor.locator(".result-window") + await page.keyboard.press("Control+Space") + + const autoCompleteMenu = page.locator(".cm-tooltip-autocomplete") + await expect(autoCompleteMenu).toBeVisible({ timeout: 5000 }) + await page.locator(".cm-completionLabel").click() + + const resultViewer = page.locator(".result-window").first() await expect(resultViewer).toBeVisible() await expect @@ -125,19 +169,33 @@ test.describe("interactive examples", () => { const resultContent = await resultViewer.textContent() const jsonMatch = resultContent?.match(/\{[\s\S]*\}/) if (jsonMatch) { - const responseJson = JSON.parse(jsonMatch[0]) - return responseJson + try { + const result = JSON.parse(jsonMatch[0]) + return result.project && result.project.description ? true : false + } catch { + return false + } } - return {} + return false }) - .toStrictEqual({ - data: { - createReview: { - stars: 5, - commentary: "This is almost as good as Andor", - }, - }, - }) - } + .toBe(true) + }) + + test("shows syntax errors", async ({ page }) => { + await page.waitForSelector(".cm-editor", { timeout: 10000 }) + + const editor = page.locator(".cm-editor").first() + + await editor.click() + await page.keyboard.press("ControlOrMeta+a") + await page.keyboard.press("Backspace") + + const playButton = page.getByText("Run query") + await playButton.click() + + const resultViewer = page.locator(".result-window").first() + const resultContent = await resultViewer.textContent() + expect(resultContent).toContain("Syntax Error: Unexpected .") + }) }) })