GraphiQL provides an editor to compose GraphQL queries, a panel for visualizing the response from the server, and a documentation explorer to browse the schema, greatly enhancing the development experience:

The GraphiQL client

I love GraphiQL, I believe it is absolutely awesome. But it has a big problem: we can't manipulate it from outside its React component, since it doesn't expose an API to interact with it. The only way to extend its functionality is by creating custom Toolbar buttons, which can be an overkill for simple use cases.

In my case, I'd like to modify the content inside the GraphiQL editor via JavaScript, allowing my website visitors to execute a different GraphQL query by clicking on an HTML button:

GraphiQL operated from outside?

If GraphiQL exposed a JavaScript function modifyEditorContent, it would be very easy to do. Since there's none, we will need to hack our way around it.

In this article I'll describe how to implement this hack.

Hacking GraphiQL via the History panel

We will leverage a fortuitous circumstance: GraphiQL has already implemented the same functionality for the History panel.

When pressing the toolbar's History button we are presented a panel with the list of the most recent GraphQL queries. Clicking on any of them will load the corresponding query on the editor:

Clicking on the queries from the History panel

The hack can then build on top of this functionality:

  1. Replace the history queries with the ones for the HTML buttons
  2. When clicking on the HTML button, trigger a click on the corresponding history element

Let's tackle these 2 steps. For the first one, because the history elements are preserved across browsing sessions, there is an obvious place to check where they are stored: in the browser's local storage.

graphiql:queries: Replacing History queries with queries for HTML buttons

Inspecting the Storage tab with DevTools, we can indeed see that the history is stored under entry "graphiql:queries":

Storing GraphiQL's queries in the Local Storage

Then, upon loading the webpage, we can have a <script> override the entries in the local storage under entry "graphiql:queries" with the custom queries for the HTML buttons:

localStorage.setItem( "graphiql:queries", JSON.stringify( { queries: [
  {
    query: '##########################################\n# Translate content to any language.\n# All translations are executed with a\n# single call to the Google Translate API.\n##########################################\nquery TranslateContent {\n  posts(limit: 5) {\n    id\n    title @translate(from: "en", to: "es")\n    excerpt @translate(from: "en", to: "es")\n  }\n}',
    variables: "",
    label: "Translate content",
  },
  {
    query: '##########################################\n# Multi-language will be tackled on Gutenberg\n# on phase 4, in 2022. Do not want to wait?\n# With this API, you can translate strings\n# within blocks, using the Google Translate API.\n##########################################\nquery TranslateStringsInBlocks {\n  post(id:1657) {\n    title\n    paragraphBlocks: blockMetadata(\n      blockName: "core/paragraph"\n    )\n    translatedParagraphBlocks: blockMetadata(\n      blockName: "core/paragraph"\n    )\n      @advancePointerInArray(path: "meta.content")\n        @forEach(nestedUnder: -1)\n          @translate(from: "en", to: "fr", nestedUnder: -1)\n  }\n}',
    variables: "",
    label: "Translate strings within blocks",
  },
  {
    query: "##########################################\n# Whenever a field is either null or empty,\n# retrieve some default value instead.\n##########################################\nquery GetFeaturedImages {\n  posts(limit: 10) {\n    id\n    title\n    featuredImage @default(value: 1647) {\n      id\n      src\n    }\n    sourceFeaturedImage: featuredImage {\n      id\n      src\n    }\n  }\n}",
    variables: "",
    label: "Set default values",
  },
  {
    query: '##########################################\n# The syntax {{ fieldName }} allows to retrieve\n# a field from the queried object, to use it\n# as an input into another field or directive.\n##########################################\nquery GetUsers {\n  users {\n    id\n    name\n    lastname @default(\n      value: "(Empty... Name is {{ name }})",\n      condition: IS_EMPTY\n    )\n  }\n}',
    variables: "",
    label: "Embed fields into other fields",
  },
  {
    query: "##########################################\n# Decide to not output a field when it is null\n##########################################\nquery GetFeaturedImages {\n  posts(limit:10) {\n    id\n    title\n    featuredImage @removeIfNull {\n      id\n      src\n    }\n    sourceFeaturedImage: featuredImage {\n      id\n      src\n    }\n  }\n}",
    variables: "",
    label: "Remove output if null",
  },
  {
    query: '##########################################\n# The "multiple query execution" feature\n# enables to execute several queries\n# as a single operation, and they can share\n# data with each other via @export.\n# Execute the "__ALL" operation when pressing\n# on "Run".\n##########################################\nquery __ALL { id }\n\nquery GetAuthorName($authorID: ID! = 1) {\n  user(id: $authorID) {\n    name @export(as: "_authorName")\n  }\n}\nquery GetPostsContainingAuthorName(\n  $_authorName: String = ""\n) {\n  posts(searchfor: $_authorName) {\n    id\n    title\n  }\n}',
    variables: "",
    label: "Export data across queries",
  },
  {
    query: '##########################################\n# Operations that take time to process\n# can be cached to disk or memory.\n# Execute this query twice within 10 seconds,\n# to appreciate how the cached response\n# improves the performance.\n##########################################\nquery {\n  posts(limit:3) {\n    id\n    title\n      @translate(from:"en", to:"es")\n      @cache(time:10)\n      @traceExecutionTime\n  }\n}',
    variables: "",
    label: "Cache and trace expensive operations",
  },
] } ) );

If clicking on the toolbar's "History" button, the trick is put in evidence:

Making the trick clear

To avoid visitors from pressing the History button, we can hide it with css:

#graphiql-client .toolbar-button:last-child {
  display: none;
}

clickHistoryButton: Linking the HTML button to an entry in the History panel

The next step is to link the HTML button to the corresponding entry on the History panel, so that clicking in the button will trigger a click on the history query.

We will accomplish it via JavaScript function clickHistoryButton, which finds the <li> entry with index position (following the order in array passed to the local storage) under <ul class="history-contents"> (which is the original markup for the History panel):

function clickHistoryButton( index ) {
  // Find the corresponding query entry.
  // Count from the back, because when clicking on an item, the History keeps piling up
  const totalItems = document.querySelectorAll( "#graphiql-client .history-contents li" ).length;
  const itemPos = totalItems - ( index - 1 );
  const selector = "#graphiql-client .history-contents li:nth-child(" + itemPos + ") > button";

  // Trigger the click!
  document.querySelector( selector ).click();
}

Then we make all buttons execute clickHistoryButton passing their position on the array:

<div id="graphiql-nav">
  <p>Click on any of these buttons, and then press the "Run" button on the client below</p>
  <nav>
    <button onclick="clickHistoryButton( 1 )">
    πŸ‘‰πŸ» Translate content
    </button>
    <button onclick="clickHistoryButton( 2 )">
    πŸ‘‰πŸ» Translate strings within blocks
    </button>
    <button onclick="clickHistoryButton( 3 )">
    πŸ‘‰πŸ» Set default values
    </button>
    <button onclick="clickHistoryButton( 4 )">
    πŸ‘‰πŸ» Embed fields into other fields
    </button>
    <button onclick="clickHistoryButton( 5 )">
    πŸ‘‰πŸ» Remove output if null
    </button>
    <button onclick="clickHistoryButton( 6 )">
    πŸ‘‰πŸ» Export data across queries
    </button>
    <button onclick="clickHistoryButton( 7 )">
    πŸ‘‰πŸ» Cache and trace expensive operations
    </button>
  </nav>
</div>

We are pretty much done, all that's left to do is to render the GraphiQL client:

<div id="graphiql-client">
  <!-- GraphiQL client will be rendered here -->
</div>
<script>
  const endpoint = "https://newapi.getpop.org/api/graphql/";
  const graphQLFetcher = graphQLParams => fetch(
    endpoint,
    {
      method: 'post',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(graphQLParams)
    }
  )
  .then(response => response.json())
  .catch(() => response.text());

  ReactDOM.render(
    React.createElement(
      GraphiQL,
      {
        fetcher: graphQLFetcher,
        response: "Click the \"Execute Query\" button",
        query: '##############################\\n# Waiting for some query...\\n# Click on any button from above,\\n# and then press the Run button\\n##############################\\n',
        variables: null,
        docExplorerOpen: false,
        defaultVariableEditorOpen: false
      }
    ),
    document.getElementById('graphiql-client'),
  );
</script>

The result

The hack achieves the objective.

I'd rather be able to interact with GraphiQL directly via an API, but with a little bit of imagination, a hack can take us a long way πŸ™

GraphiQL operated from outside