├── .changeset ├── README.md └── config.json ├── .env ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report_extension.md │ ├── bug_report_standalone.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── assets └── preview.gif ├── babel.config.js ├── cosmos.config.json ├── cosmos.override.js ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── scripts ├── changelog.js ├── cli.js ├── cosmos-add-badge.js └── postinstall.js ├── src ├── assets │ ├── events │ │ ├── execution.svg │ │ ├── other.svg │ │ ├── teardown.svg │ │ └── update.svg │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-256.png │ ├── icon-32.png │ ├── icon-48.png │ ├── icon-512.png │ ├── icon-64.png │ ├── icon-disabled-128.png │ ├── icon-disabled-256.png │ ├── icon-disabled-32.png │ ├── icon-disabled-512.png │ ├── icon-disabled-64.png │ └── icon.svg ├── electron │ └── main.ts ├── extension │ ├── background.ts │ ├── content_script.ts │ ├── devtools.html │ ├── devtools.ts │ └── manifest.json ├── panel │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── __image_snapshots__ │ │ ├── TimelineDuration - Alive │ │ └── TimelineDuration - Network │ ├── __snapshots__ │ │ └── App.test.tsx.snap │ ├── components │ │ ├── Background.tsx │ │ ├── CodeHighlight.fixture.tsx │ │ ├── CodeHighlight.tsx │ │ ├── Collapsible.tsx │ │ ├── IndicatorArrow.tsx │ │ ├── Navigation.fixture.tsx │ │ ├── Navigation.tsx │ │ ├── Pane.test.tsx │ │ ├── Pane.tsx │ │ ├── Portal.tsx │ │ ├── Tabs.test.tsx │ │ ├── Tabs.tsx │ │ ├── Toolbar.fixture.tsx │ │ ├── Toolbar.tsx │ │ ├── __snapshots__ │ │ │ ├── Pane.test.tsx.snap │ │ │ └── Tabs.test.tsx.snap │ │ └── index.ts │ ├── context │ │ ├── Devtools.test.tsx │ │ ├── Devtools.tsx │ │ ├── Explorer │ │ │ ├── Explorer.test.tsx │ │ │ ├── ast │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.ts │ │ │ │ └── variables.ts │ │ │ └── index.tsx │ │ ├── Request.test.tsx │ │ ├── Request.tsx │ │ ├── Timeline.tsx │ │ └── index.ts │ ├── cosmos.decorator.tsx │ ├── definitions.d.ts │ ├── hooks │ │ ├── index.ts │ │ └── useOrientationWatcher.ts │ ├── pages │ │ ├── disconnected │ │ │ ├── Disconnected.fixture.tsx │ │ │ ├── Disconnected.test.tsx │ │ │ ├── Disconnected.tsx │ │ │ ├── __snapshots__ │ │ │ │ └── Disconnected.test.tsx.snap │ │ │ └── index.ts │ │ ├── error │ │ │ ├── ErrorBoundary.fixture.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ └── index.ts │ │ ├── events │ │ │ ├── Timeline.fixture.tsx │ │ │ ├── Timeline.tsx │ │ │ ├── components │ │ │ │ ├── Settings.fixture.tsx │ │ │ │ ├── Settings.test.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ ├── Tick.fixture.tsx │ │ │ │ ├── Tick.tsx │ │ │ │ ├── TimelineDuration.fixture.tsx │ │ │ │ ├── TimelineDuration.tsx │ │ │ │ ├── TimelineEvent.fixture.tsx │ │ │ │ ├── TimelineEvent.tsx │ │ │ │ ├── TimelinePane │ │ │ │ │ ├── TimelinePane.fixture.tsx │ │ │ │ │ ├── TimelinePane.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TimelineRow.fixture.tsx │ │ │ │ ├── TimelineRow.test.tsx │ │ │ │ ├── TimelineRow.tsx │ │ │ │ ├── TimelineSourceIcon.fixture.tsx │ │ │ │ ├── TimelineSourceIcon.tsx │ │ │ │ ├── TimelineTooltip.fixture.tsx │ │ │ │ ├── TimelineTooltip.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── explorer │ │ │ ├── Explorer.fixture.tsx │ │ │ ├── Explorer.tsx │ │ │ ├── components │ │ │ │ ├── Arguments.fixture.tsx │ │ │ │ ├── Arguments.tsx │ │ │ │ ├── Icons.tsx │ │ │ │ ├── ListItem.tsx │ │ │ │ ├── NodeInfoPane.fixture.tsx │ │ │ │ ├── NodeInfoPane.tsx │ │ │ │ ├── Tree.tsx │ │ │ │ └── index.ts │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ └── useFlash.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── mismatch │ │ │ ├── Mismatch.fixture.tsx │ │ │ ├── Mismatch.tsx │ │ │ └── index.ts │ │ └── request │ │ │ ├── Request.fixture.tsx │ │ │ ├── Request.tsx │ │ │ ├── components │ │ │ ├── Collapsible.tsx │ │ │ ├── Fields.fixture.tsx │ │ │ ├── Fields.tsx │ │ │ ├── Query.tsx │ │ │ ├── Response.tsx │ │ │ ├── Schema.fixture.tsx │ │ │ ├── Schema.tsx │ │ │ ├── Search.fixture.tsx │ │ │ ├── Search.tsx │ │ │ ├── Settings.tsx │ │ │ ├── Stack.fixture.tsx │ │ │ ├── Stack.tsx │ │ │ ├── TopBar.tsx │ │ │ ├── Type.tsx │ │ │ └── index.ts │ │ │ └── index.ts │ ├── panel.html │ ├── panel.tsx │ ├── prism.ts │ ├── styled.d.ts │ ├── theme.ts │ ├── types │ │ ├── codemirror.d.ts │ │ └── styled-components.d.ts │ └── util │ │ ├── Connection.ts │ │ ├── EnvUtils.ts │ │ ├── ErrorOverlay.ts │ │ ├── index.ts │ │ └── openExternalUrl.ts ├── setupTests.ts ├── types │ ├── connections.ts │ └── index.ts └── util │ ├── EventTarget.ts │ ├── debug.ts │ └── index.ts ├── tsconfig.json └── webpack ├── webpack.electron.config.js └── webpack.extension.config.js /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@0.3.0/schema.json", 3 | "changelog": "../scripts/changelog.js", 4 | "commit": false, 5 | "access": "public", 6 | "baseBranch": "master" 7 | } 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | TZ=Europe/London 2 | COSMOS_HOST=cosmos 3 | COSMOS_PORT=5001 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.js 3 | *.yaml 4 | node_modules 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": [ 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:import/errors", 7 | "plugin:import/typescript", 8 | "prettier", 9 | "prettier/@typescript-eslint", 10 | "plugin:react/recommended" 11 | ], 12 | "rules": { 13 | "import/order": ["error", { "newlines-between": "never" }], 14 | "@typescript-eslint/no-unused-vars": ["error"], 15 | "react/function-component-definition": [ 16 | "error", 17 | { 18 | "namedComponents": "arrow-function", 19 | "unnamedComponents": "arrow-function" 20 | } 21 | ], 22 | "@typescript-eslint/explicit-function-return-type": 0, 23 | "@typescript-eslint/no-explicit-any": 0, 24 | "@typescript-eslint/no-use-before-define": 0, 25 | "@typescript-eslint/prefer-interface": 0, 26 | "@typescript-eslint/no-var-requires": 0, 27 | "import/named": 0, 28 | "react/prop-types": 0, 29 | "react/no-children-prop": 0 30 | }, 31 | "settings": { 32 | "react": { 33 | "version": "detect" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_extension.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report (browser extension) 3 | about: Create a bug report for the browser extension version of devtools. 4 | title: "" 5 | labels: Bug 6 | assignees: 7 | --- 8 | 9 | # About 10 | 11 | 12 | 13 | Devtools does not detect a running instance of urql. 14 | 15 | # Reproduction 16 | 17 | 18 | 19 | 1. Clone [this example](https://github.com/FormidableLabs/urql/tree/main/packages/react-urql/examples/1-getting-started) project 20 | 2. Run `pnpm install` 21 | 3. Run `pnpm start` 22 | 4. Open chrome and navigate to [http://localhost:8080](http://localhost:8080) 23 | 5. Open the urql devtools panel 24 | 25 | ## Expected result 26 | 27 | 28 | 29 | - Extension detects app 30 | 31 | ## Actual result 32 | 33 | 34 | 35 | - Extension shows message "Waiting for exchange" 36 | 37 | # Additional info 38 | 39 | | environment | version | 40 | | -------------- | --------- | 41 | | browser | Chrome 69 | 42 | | urql | 0.0.0 | 43 | | urql devtools | 0.0.0 | 44 | | @urql/devtools | 0.0.0 | 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_standalone.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report (standalone) 3 | about: Create a bug report for the native/standalone version of devtools. 4 | title: "" 5 | labels: Bug, Electron 6 | assignees: "" 7 | --- 8 | 9 | # About 10 | 11 | 12 | 13 | Devtools is unresponsive when using on an Android device with expo. 14 | 15 | # Reproduction 16 | 17 | 18 | 19 | 1. Clone [this example](https://github.com/kadikraman/UrqlTest) react native project 20 | 2. Plug in Android phone via USB 21 | 3. Run `pnpm install` 22 | 4. Run `pnpm start` 23 | 5. Open devtools using npx `npx urql-devtools` 24 | 25 | ## Expected result 26 | 27 | 28 | 29 | - App opens on Android phone 30 | - Urql Devtools opens in standalone window 31 | - Urql devtools detects app 32 | 33 | ## Actual result 34 | 35 | 36 | 37 | - App opens on Android phone 38 | - Urql devtools opens in standalone window 39 | - Urql devtools stays on "waiting for exchange" notice 40 | 41 | # Additional info 42 | 43 | | environment | version | 44 | | -------------- | -------------- | 45 | | os | Macbuntu 20.04 | 46 | | node | 0.0.0 | 47 | | urql | 0.0.0 | 48 | | urql-devtools | 0.0.0 | 49 | | @urql/devtools | 0.0.0 | 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Create a feature request. 4 | title: "Add ..." 5 | labels: Feature 6 | assignees: "" 7 | --- 8 | 9 | # About 10 | 11 | 12 | 13 | When the user is on the disconnected screen, display a timer showing how long the client has been disconnected. 14 | 15 | 16 | 17 | This would help me keep track of bugs in my app. 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: master 8 | 9 | jobs: 10 | check: 11 | name: Checks 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: '16' 24 | 25 | - name: Setup pnpm 26 | uses: pnpm/action-setup@v2.2.2 27 | with: 28 | version: 7 29 | run_install: false 30 | 31 | - name: Get pnpm store directory 32 | id: pnpm-store 33 | run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 34 | 35 | - name: Use pnpm store 36 | uses: actions/cache@v3 37 | id: pnpm-cache 38 | with: 39 | path: ${{ steps.pnpm-store.outputs.pnpm_cache_dir }} 40 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm- 43 | 44 | - name: Install Dependencies 45 | run: pnpm install --frozen-lockfile 46 | 47 | - name: Install chromium 48 | run: node ./node_modules/puppeteer/install.js 49 | 50 | - name: TypeScript 51 | run: pnpm run type-check 52 | 53 | - name: Lint 54 | run: pnpm run lint 55 | 56 | ## TODO: consolidate in linting step 57 | - name: Prettier 58 | run: pnpm run lint:prettier 59 | 60 | - name: Unit Tests 61 | run: pnpm run test --coverage 62 | env: 63 | TZ: Europe/London 64 | HEADLESS: true 65 | 66 | - name: Build 67 | run: pnpm run build && pnpm run bundle 68 | 69 | - name: Lint FireFox 70 | run: pnpm run lint:firefox 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | check: 9 | name: Checks 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '16' 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v2.2.2 25 | with: 26 | version: 7 27 | run_install: false 28 | 29 | - name: Get pnpm store directory 30 | id: pnpm-store 31 | run: echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 32 | 33 | - name: Use pnpm store 34 | uses: actions/cache@v3 35 | id: pnpm-cache 36 | with: 37 | path: ${{ steps.pnpm-store.outputs.pnpm_cache_dir }} 38 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-pnpm- 41 | 42 | - name: Install Dependencies 43 | run: pnpm install --frozen-lockfile 44 | 45 | - name: PR or Publish 46 | id: changesets 47 | uses: changesets/action@v1.4.1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | cosmos-export 4 | .DS_Store 5 | web-ext-artifacts 6 | __diff_output__ 7 | .idea -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.* 2 | /* 3 | !/CHANGELOG.md 4 | !/README.md 5 | !/LICENSE 6 | 7 | /dist/extension 8 | !/dist/electron 9 | 10 | /src/** 11 | !/src/{electron,panel,types,util}/**/!(*.fixture|*.test).{ts,tsx} 12 | 13 | /scripts/** 14 | !/scripts/cli.js 15 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | cosmos-export/ 3 | dist/ 4 | *.yml 5 | *.yaml 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Contributor Covenant Code of Conduct 2 | 3 | ### Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ### Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ### Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ### Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ### Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at coc@formidable.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ### Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to `urql` Devtools! We want to ensure that the community has opportunities to contribute to `urql` devtools in as many ways as possible, no matter how big or small those contributions may be. 4 | 5 | ## How to contribute? 6 | 7 | Before getting started, make sure you are aware of our [Code of Conduct](./CODE_OF_CONDUCT.md) and comply within those guidelines. 8 | 9 | If you have an idea for a feature or want to fix a bug, consider opening an issue first. We're also happy to discuss and help you open a PR to get your changes in! 10 | 11 | ## How do I set up the project? 12 | 13 | Check out the [development guide](./DEVELOPMENT.md) for the technical details and how to build the project. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018–2020 Formidable, 4 | Copyright (c) urql GraphQL Team and other contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |

Urql Devtools

4 |

The official browser extension for Urql

5 | 6 | Chrome Web Store 7 | 8 | 9 | Firefox Addon 10 | 11 | 12 | Fixtures 13 | 14 | 15 | Licence MIT 16 | 17 |
18 | 19 |
20 |
21 | 22 |
23 | 24 |
25 | 26 | ## Features 27 | 28 | ### 📬 Event timeline 29 | 30 | See all debugging and network events in real time. 31 | 32 | ### 🗂 Cache explorer 33 | 34 | Explore your cache and see when cached data is being used. 35 | 36 | ### 🚀 Request tool 37 | 38 | Explore your backend schema and trigger queries directly via your running Urql client. 39 | 40 | ## Usage 41 | 42 | ### Add the urql exchange 43 | 44 | Follow the instructions to [install and setup the devtools exchange](https://github.com/urql-graphql/urql-devtools-exchange#usage) 45 | 46 | ### 🌐 Browser 47 | 48 | Install the extension for your browser of choice 49 | 50 | - [Chrome extension](https://chrome.google.com/webstore/detail/urql-devtools/mcfphkbpmkbeofnkjehahlmidmceblmm) 51 | - [Firefox addon](https://addons.mozilla.org/en-GB/firefox/addon/urql-devtools) 52 | 53 | Open the [devtools panel](https://developers.google.com/web/tools/chrome-devtools/open) in your browser and click on the _Urql_ tab 54 | 55 | ### 📱 React Native 56 | 57 | Start the electron app from a dedicated shell 58 | 59 | ```sh 60 | npx urql-devtools 61 | ``` 62 | 63 | > **Note:** Android users may need to forward port 7700 from their device to their local machine: 64 | > 65 | > ```sh 66 | > adb reverse tcp:7700 tcp:7700 67 | > ``` 68 | 69 | ## Integrations 70 | 71 | Visit the [debugging docs](https://formidable.com/open-source/urql/docs/advanced/debugging/#adding-your-own-debug-events) to find out how to integrate your self-made exchanges with our devtools. 72 | 73 | ## Contributing 74 | 75 | Have experience working with devtools extensions or want to get involved? Check out our [contributing](./CONTRIBUTING.md) docs to get started, information on setting up the project can be found [here](https://github.com/urql-graphql/urql-devtools/blob/master/DEVELOPMENT.md). 76 | -------------------------------------------------------------------------------- /assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/assets/preview.gif -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { targets: { firefox: "70", chrome: "78", electron: "9" } }, 6 | ], 7 | "@babel/preset-react", 8 | "@babel/preset-typescript", 9 | ], 10 | plugins: [ 11 | "inline-react-svg", 12 | "@babel/plugin-proposal-class-properties", 13 | "@babel/plugin-proposal-optional-chaining", 14 | [ 15 | "babel-plugin-styled-components", 16 | { 17 | fileName: false, 18 | }, 19 | ], 20 | ], 21 | env: { 22 | test: { 23 | plugins: ["@babel/plugin-transform-modules-commonjs"], 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /cosmos.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "watchDirs": ["src/panel/!__image_snapshots__"], 3 | "port": 5001, 4 | "webpack": { 5 | "configPath": "", 6 | "overridePath": "cosmos.override.js" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /cosmos.override.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | 3 | module.exports = (c) => ({ 4 | ...c, 5 | module: { 6 | ...c.module, 7 | rules: [ 8 | ...c.module.rules, 9 | { 10 | test: /\.svg$/, 11 | use: ["svg-react-loader"], 12 | }, 13 | ], 14 | }, 15 | resolve: { 16 | ...c.resolve, 17 | extensions: [".mjs", ...c.resolve.extensions], 18 | alias: { 19 | "react-error-overlay": `${__dirname}/src/panel/util/ErrorOverlay.ts`, 20 | }, 21 | }, 22 | node: { 23 | fs: "empty", 24 | }, 25 | plugins: [ 26 | new webpack.DefinePlugin({ 27 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 28 | "process.env.BUILD_ENV": JSON.stringify(process.env.BUILD_ENV), 29 | }), 30 | new webpack.ContextReplacementPlugin( 31 | /graphql-language-service-interface[\/\\]dist/, 32 | /\.js$/ 33 | ), 34 | ...c.plugins, 35 | ], 36 | }); 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | module.exports = { 3 | preset: "ts-jest/presets/default-esm", 4 | transform: { 5 | "^.+\\.[t|j]sx?$": "babel-jest", 6 | "^.+\\.js$": "babel-jest", 7 | }, 8 | transformIgnorePatterns: ["node_modules/.pnpm/(?!nanoid)"], 9 | setupFiles: ["dotenv/config"], 10 | setupFilesAfterEnv: ["src/setupTests.ts"], 11 | snapshotSerializers: ["enzyme-to-json/serializer"], 12 | moduleNameMapper: { 13 | "\\.(jpg|svg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|css)$": 14 | "identity-obj-proxy", 15 | }, 16 | testEnvironment: "jsdom", 17 | }; 18 | -------------------------------------------------------------------------------- /scripts/changelog.js: -------------------------------------------------------------------------------- 1 | const { getInfo } = require("@changesets/get-github-info"); 2 | 3 | const REPO = "urql-graphql/urql-devtools"; 4 | const SEE_LINE = /^See:\s*(.*)/i; 5 | const TRAILING_CHAR = /[.;:]$/g; 6 | const listFormatter = new Intl.ListFormat("en-US"); 7 | 8 | const getSummaryLines = (cs) => { 9 | const lines = cs.summary 10 | .trim() 11 | .split(/[\r\n]+/) 12 | .map((l) => l.trim()) 13 | .filter(Boolean); 14 | const size = lines.length; 15 | if (size > 0) { 16 | lines[size - 1] = lines[size - 1].replace(TRAILING_CHAR, ""); 17 | } 18 | 19 | return lines; 20 | }; 21 | 22 | /** Creates a "(See X)" string from a template */ 23 | const templateSeeRef = (links) => { 24 | const humanReadableLinks = links.filter(Boolean).map((link) => { 25 | if (typeof link === "string") return link; 26 | return link.pull || link.commit; 27 | }); 28 | 29 | const size = humanReadableLinks.length; 30 | if (size === 0) return ""; 31 | 32 | const str = listFormatter.format(humanReadableLinks); 33 | return `(See ${str})`; 34 | }; 35 | 36 | const changelogFunctions = { 37 | getDependencyReleaseLine: async (changesets, dependenciesUpdated) => { 38 | if (dependenciesUpdated.length === 0) return ""; 39 | 40 | const dependenciesLinks = await Promise.all( 41 | changesets.map(async (cs) => { 42 | if (!cs.commit) return undefined; 43 | 44 | const lines = getSummaryLines(cs); 45 | const prLine = lines.find((line) => SEE_LINE.test(line)); 46 | if (prLine) { 47 | const match = prLine.match(SEE_LINE); 48 | return (match && match[1].trim()) || undefined; 49 | } 50 | 51 | const { links } = await getInfo({ 52 | repo: REPO, 53 | commit: cs.commit, 54 | }); 55 | 56 | return links; 57 | }) 58 | ); 59 | 60 | let changesetLink = "- Updated dependencies"; 61 | 62 | const seeRef = templateSeeRef(dependenciesLinks); 63 | if (seeRef) changesetLink += ` ${seeRef}`; 64 | 65 | const detailsLinks = dependenciesUpdated.map((dep) => { 66 | return ` - ${dep.name}@${dep.newVersion}`; 67 | }); 68 | 69 | return [changesetLink, ...detailsLinks].join("\n"); 70 | }, 71 | getReleaseLine: async (changeset, type) => { 72 | let pull, commit, user; 73 | 74 | const lines = getSummaryLines(changeset); 75 | const prLineIndex = lines.findIndex((line) => SEE_LINE.test(line)); 76 | if (prLineIndex > -1) { 77 | const match = lines[prLineIndex].match(SEE_LINE); 78 | pull = (match && match[1].trim()) || undefined; 79 | lines.splice(prLineIndex, 1); 80 | } 81 | 82 | const [firstLine, ...futureLines] = lines; 83 | 84 | if (changeset.commit && !pull) { 85 | const { links } = await getInfo({ 86 | repo: REPO, 87 | commit: changeset.commit, 88 | }); 89 | 90 | pull = links.pull || undefined; 91 | commit = links.commit || undefined; 92 | user = links.user || undefined; 93 | } 94 | 95 | let annotation = ""; 96 | if (type === "patch" && /^\s*fix/i.test(firstLine)) { 97 | annotation = "⚠️ "; 98 | } 99 | 100 | let str = `- ${annotation}${firstLine}`; 101 | if (futureLines.length > 0) { 102 | str += `\n${futureLines.map((l) => ` ${l}`).join("\n")}`; 103 | } 104 | 105 | if (user) { 106 | str += `, by ${user}`; 107 | } 108 | 109 | if (pull || commit) { 110 | const seeRef = templateSeeRef([pull || commit]); 111 | if (seeRef) str += ` ${seeRef}`; 112 | } 113 | 114 | return str; 115 | }, 116 | }; 117 | 118 | module.exports = { 119 | ...changelogFunctions, 120 | default: changelogFunctions, 121 | }; 122 | -------------------------------------------------------------------------------- /scripts/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { spawn } = require("child_process"); 3 | spawn(/^win/.test(process.platform) ? "npm.cmd" : "npm", ["start"], { 4 | stdio: "inherit", 5 | cwd: `${__dirname}/..`, 6 | }); 7 | -------------------------------------------------------------------------------- /scripts/cosmos-add-badge.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Post-process Cosmos output. 3 | * 4 | * Run after `pnpm run cosmos-export` 5 | */ 6 | const fs = require("fs"); 7 | const path = require("path"); 8 | const { promisify } = require("util"); 9 | 10 | const readFile = promisify(fs.readFile); 11 | const writeFile = promisify(fs.writeFile); 12 | 13 | const COSMOS_INDEX = path.resolve(__dirname, "../cosmos-export/index.html"); 14 | const BADGE = ` 15 |
16 | 17 | Deploys by Netlify 18 | 19 |
20 | `; 21 | 22 | const main = async () => { 23 | const index = await readFile(COSMOS_INDEX) 24 | .then((buf) => buf.toString()) 25 | .catch((err) => { 26 | if (err.code === "ENOENT") { 27 | console.error( 28 | `Could not find ${COSMOS_INDEX}. Have you run cosmos-export?` 29 | ); 30 | } 31 | throw err; 32 | }); 33 | 34 | // Add badge at end of body if not present 35 | if (index.indexOf(BADGE) === -1) { 36 | const withBadge = index.replace("", `${BADGE} `); 37 | await writeFile(COSMOS_INDEX, withBadge); 38 | } 39 | }; 40 | 41 | if (require.main === module) { 42 | main().catch((err) => { 43 | console.error(err); 44 | process.exit(1); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | 4 | const hookSource = path.resolve( 5 | __dirname, 6 | "../node_modules/husky-v4/sh/husky.sh" 7 | ); 8 | const hook = path.resolve(__dirname, "../.git/hooks/husky.sh"); 9 | const localHook = path.resolve(__dirname, "../.git/hooks/husky.local.sh"); 10 | const gitConfig = path.resolve(__dirname, "../.git/config"); 11 | 12 | let script = fs.readFileSync(hookSource, { encoding: "utf-8" }); 13 | script = script.replace(`$(basename "$0")`, `$(basename "$0" .sh)`); 14 | 15 | let config = fs.readFileSync(gitConfig, { encoding: "utf-8" }); 16 | config = config.replace(/\s*hooksPath\s*=\s*\.husky\n?/g, "\n"); 17 | 18 | fs.writeFileSync(hook, script); 19 | fs.writeFileSync(gitConfig, config); 20 | 21 | fs.writeFileSync(localHook, "packageManager=yarn\n" + 'cd "."\n'); 22 | -------------------------------------------------------------------------------- /src/assets/events/execution.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/events/other.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/events/teardown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/events/update.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-128.png -------------------------------------------------------------------------------- /src/assets/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-16.png -------------------------------------------------------------------------------- /src/assets/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-256.png -------------------------------------------------------------------------------- /src/assets/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-32.png -------------------------------------------------------------------------------- /src/assets/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-48.png -------------------------------------------------------------------------------- /src/assets/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-512.png -------------------------------------------------------------------------------- /src/assets/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-64.png -------------------------------------------------------------------------------- /src/assets/icon-disabled-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-disabled-128.png -------------------------------------------------------------------------------- /src/assets/icon-disabled-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-disabled-256.png -------------------------------------------------------------------------------- /src/assets/icon-disabled-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-disabled-32.png -------------------------------------------------------------------------------- /src/assets/icon-disabled-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-disabled-512.png -------------------------------------------------------------------------------- /src/assets/icon-disabled-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/assets/icon-disabled-64.png -------------------------------------------------------------------------------- /src/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/electron/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain } from "electron"; 2 | import Websocket from "ws"; 3 | import { debug } from "../util/debug"; 4 | 5 | let windows: BrowserWindow[] = []; 6 | 7 | const createWebsocketServer = () => { 8 | debug("Creating websocket server"); 9 | const port = Number(process.env.port || 7700); 10 | const socket = new Websocket.Server({ port }); 11 | 12 | socket.on("connection", (ws) => { 13 | debug("WebSocket connection established"); 14 | 15 | // Forward messages from the extension to the exchange 16 | ipcMain.on("message", (event, message) => { 17 | debug("Extension message:", message); 18 | ws.send(JSON.stringify(message)); 19 | }); 20 | 21 | // Forward messages from the exchange to the extension 22 | ws.on("message", (data) => { 23 | if (typeof data !== "string") { 24 | console.warn("Unsupported webSocket message:", data); 25 | return; 26 | } 27 | 28 | debug("WebSocket message:", JSON.parse(data)); 29 | try { 30 | windows.forEach((w) => w.webContents.send("message", JSON.parse(data))); 31 | } catch (err) {} 32 | }); 33 | 34 | ws.on("close", () => { 35 | windows.forEach((w) => 36 | w.webContents.send("message", { 37 | type: "connection-disconnect", 38 | source: "exchange", 39 | }) 40 | ); 41 | }); 42 | }); 43 | }; 44 | 45 | const createWindow = () => { 46 | // Create the browser window. 47 | const win = new BrowserWindow({ 48 | width: 800, 49 | height: 600, 50 | webPreferences: { 51 | nodeIntegration: true, 52 | contextIsolation: false, 53 | }, 54 | }); 55 | windows = [...windows, win]; 56 | 57 | // and load the index.html of the app. 58 | win.loadFile(`./shell/panel.html`); 59 | process.env.NODE_ENV !== "production" && win.webContents.openDevTools(); 60 | }; 61 | 62 | app.allowRendererProcessReuse = true; 63 | app.whenReady().then(() => { 64 | createWebsocketServer(); 65 | createWindow(); 66 | }); 67 | -------------------------------------------------------------------------------- /src/extension/background.ts: -------------------------------------------------------------------------------- 1 | import { DevtoolsMessage } from "@urql/devtools"; 2 | import { 3 | ContentScriptConnectionName, 4 | DevtoolsPanelConnectionName, 5 | } from "../types"; 6 | import { debug, BackgroundEventTarget } from "../util"; 7 | 8 | /** Collection of targets grouped by tabId. */ 9 | const targets: Record = {}; 10 | 11 | type AddToTargetArgs = { 12 | tabId: number; 13 | source: "exchange" | "devtools"; 14 | port: chrome.runtime.Port; 15 | }; 16 | 17 | /** Ensures all messages are forwarded to and from tab connections. */ 18 | const addToTarget = ({ tabId, port, source }: AddToTargetArgs) => { 19 | if (targets[tabId] === undefined) { 20 | targets[tabId] = new BackgroundEventTarget(); 21 | } 22 | 23 | const target = targets[tabId]; 24 | const portName = port.name; 25 | 26 | debug("Connect: ", { tabId, portName }); 27 | target.addEventListener(portName, (a) => port.postMessage(a)); 28 | 29 | port.onMessage.addListener((e) => { 30 | debug("Message: ", { tabId, portName, message: e }); 31 | target.dispatchEvent(portName, e); 32 | }); 33 | port.onDisconnect.addListener(() => { 34 | debug("Disconnect: ", { tabId, portName }); 35 | target.removeEventListener(portName); 36 | target.dispatchEvent(portName, { type: "connection-disconnect", source }); 37 | }); 38 | }; 39 | 40 | /** Handle initial connection from content script. */ 41 | const handleContentScriptConnection = (port: chrome.runtime.Port) => { 42 | if (port?.sender?.tab?.id) { 43 | const tabId = port.sender.tab.id; 44 | 45 | addToTarget({ tabId, port, source: "exchange" }); 46 | chrome.pageAction.setIcon({ tabId, path: "/assets/icon-32.png" }); 47 | port.onDisconnect.addListener(() => { 48 | chrome.pageAction.setIcon( 49 | { 50 | tabId, 51 | path: "/assets/icon-disabled-32.png", 52 | }, 53 | () => true 54 | ); 55 | }); 56 | } 57 | }; 58 | 59 | /** Handle initial connection from devtools panel. */ 60 | const handleDevtoolsPanelConnection = (port: chrome.runtime.Port) => { 61 | const source = "devtools"; 62 | const initialListener = (msg: DevtoolsMessage) => { 63 | debug("Devtools Initial Message: ", { msg }); 64 | if (msg.type !== "connection-init") { 65 | return; 66 | } 67 | 68 | // tabId is required when working with chrome extension 69 | if (msg.tabId === undefined) { 70 | console.error( 71 | "Recieved devtools panel connection but no tabId was specified." 72 | ); 73 | return; 74 | } 75 | 76 | addToTarget({ tabId: msg.tabId, port, source }); 77 | targets[msg.tabId].dispatchEvent(source, msg); 78 | 79 | port.onMessage.removeListener(initialListener); 80 | }; 81 | 82 | port.onMessage.addListener(initialListener); 83 | }; 84 | 85 | const connectionHandlers: Record void> = { 86 | [ContentScriptConnectionName]: handleContentScriptConnection, 87 | [DevtoolsPanelConnectionName]: handleDevtoolsPanelConnection, 88 | }; 89 | 90 | chrome.runtime.onConnect.addListener((port) => { 91 | const handler = connectionHandlers[port.name]; 92 | return handler && handler(port); 93 | }); 94 | -------------------------------------------------------------------------------- /src/extension/content_script.ts: -------------------------------------------------------------------------------- 1 | import { ExchangeMessage, ExchangeSource } from "@urql/devtools"; 2 | import { ContentScriptConnectionName } from "../types"; 3 | import { debug } from "../util"; 4 | 5 | /** Connection to background.js */ 6 | let connection: chrome.runtime.Port | undefined; 7 | 8 | // Listen for init message from exchange 9 | window.addEventListener("message", ({ data, isTrusted }) => { 10 | const exchangeSource: ExchangeSource = "exchange"; 11 | 12 | // Filter messages not from the exchange 13 | if (!isTrusted || data?.source !== exchangeSource) { 14 | return; 15 | } 16 | 17 | const message = data as ExchangeMessage; 18 | debug("Exchange Message: ", data); 19 | 20 | // Setup connection on init message 21 | if (message.type === "connection-init") { 22 | connection = chrome.runtime.connect({ name: ContentScriptConnectionName }); 23 | connection.onMessage.addListener(handleMessage); 24 | connection.onDisconnect.addListener(handleDisconnect); 25 | } 26 | 27 | if (connection === undefined) { 28 | return console.warn("Unable to send message to Urql Devtools extension"); 29 | } 30 | 31 | // Forward message to devtools 32 | connection.postMessage(message); 33 | }); 34 | 35 | /** Handle message from background script. */ 36 | const handleMessage = (message: ExchangeMessage) => { 37 | debug("Background Message: ", message); 38 | window.postMessage(message, window.location.origin); 39 | }; 40 | 41 | /** Handle disconnect from background script. */ 42 | const handleDisconnect = () => { 43 | connection = undefined; 44 | }; 45 | -------------------------------------------------------------------------------- /src/extension/devtools.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/extension/devtools.ts: -------------------------------------------------------------------------------- 1 | // Show urql devtools if devtools exchange has been mounted 2 | chrome.devtools.panels.create("Urql", "", "panel.html"); 3 | -------------------------------------------------------------------------------- /src/extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Urql Devtools", 4 | "description": "The official Urql chrome extension", 5 | "icons": { 6 | "16": "/assets/icon-16.png", 7 | "32": "/assets/icon-32.png", 8 | "48": "/assets/icon-48.png", 9 | "64": "/assets/icon-64.png", 10 | "128": "/assets/icon-128.png", 11 | "256": "/assets/icon-256.png", 12 | "512": "/assets/icon-512.png" 13 | }, 14 | "background": { 15 | "scripts": ["background.js"], 16 | "persistent": false 17 | }, 18 | "content_scripts": [ 19 | { 20 | "matches": [""], 21 | "all_frames": true, 22 | "js": ["content_script.js"], 23 | "run_at": "document_start" 24 | } 25 | ], 26 | "page_action": { 27 | "default_title": "Urql Devtools", 28 | "default_icon": { 29 | "32": "/assets/icon-disabled-32.png", 30 | "64": "/assets/icon-disabled-64.png", 31 | "128": "/assets/icon-disabled-128.png" 32 | } 33 | }, 34 | "devtools_page": "devtools.html", 35 | "permissions": ["file:///*", "http://*/*", "https://*/*"], 36 | "content_security_policy": "script-src 'self'; object-src 'self'", 37 | "application": { 38 | "gecko": { 39 | "id": "{c11f3a69-f159-4708-b044-853066c2d2fe}", 40 | "strict_min_version": "42.0" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/panel/App.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap"); 2 | @import url("https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700&display=swap"); 3 | 4 | html { 5 | font-size: 100%; 6 | } 7 | 8 | body { 9 | font-family: "Roboto", "Helvetica", sans-serif; 10 | font-weight: 400; 11 | margin: 0; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | font-synthesis: none; 16 | } 17 | 18 | code, 19 | pre { 20 | font-family: "Roboto Mono", monospace; 21 | } 22 | 23 | button { 24 | border: 0; 25 | padding: 0; 26 | background: none; 27 | border-radius: 0; 28 | font-size: inherit; 29 | font-family: inherit; 30 | cursor: pointer; 31 | } 32 | -------------------------------------------------------------------------------- /src/panel/App.test.tsx: -------------------------------------------------------------------------------- 1 | jest.mock("./context/Devtools.tsx", () => { 2 | return { 3 | ...(jest.requireActual("./context/Devtools.tsx") as Record< 4 | string, 5 | unknown 6 | >), 7 | useDevtoolsContext: jest.fn(), 8 | }; 9 | }); 10 | import React from "react"; 11 | import { shallow } from "enzyme"; 12 | import { App, AppRoutes } from "./App"; 13 | import { useDevtoolsContext } from "./context"; 14 | import { darkTheme, lightTheme } from "./theme"; 15 | 16 | describe("App", () => { 17 | describe("on mount", () => { 18 | it("matches snapshot", () => { 19 | expect(shallow()).toMatchSnapshot(); 20 | }); 21 | }); 22 | 23 | describe("in dark mode", () => { 24 | const origThemeName = chrome.devtools.panels.themeName; 25 | 26 | beforeAll(() => { 27 | chrome.devtools.panels.themeName = "dark"; 28 | }); 29 | 30 | afterAll(() => { 31 | chrome.devtools.panels.themeName = origThemeName; 32 | }); 33 | 34 | it("has a GlobalStyle component and dark theme passed to ThemeProvider", () => { 35 | const wrapper = shallow(); 36 | 37 | expect(wrapper.find("GlobalStyle").exists()).toBe(true); 38 | expect(wrapper.find("ThemeProvider").prop("theme")).toBe(darkTheme); 39 | }); 40 | }); 41 | 42 | describe("in light mode", () => { 43 | const origThemeName = chrome.devtools.panels.themeName; 44 | 45 | beforeAll(() => { 46 | chrome.devtools.panels.themeName = "default"; 47 | }); 48 | 49 | afterAll(() => { 50 | chrome.devtools.panels.themeName = origThemeName; 51 | }); 52 | 53 | it("has a GlobalStyle component and light theme passed to ThemeProvider", () => { 54 | const wrapper = shallow(); 55 | 56 | expect(wrapper.find("GlobalStyle").exists()).toBe(true); 57 | expect(wrapper.find("ThemeProvider").prop("theme")).toBe(lightTheme); 58 | }); 59 | }); 60 | }); 61 | 62 | describe("App routes", () => { 63 | describe("on mount", () => { 64 | describe("on connected", () => { 65 | beforeEach(() => { 66 | (useDevtoolsContext as jest.Mocked).mockReturnValue({ 67 | client: { 68 | connected: true, 69 | version: { 70 | mismatch: false, 71 | }, 72 | }, 73 | } as any); 74 | }); 75 | 76 | it("matches snapshot", () => { 77 | expect(shallow()).toMatchSnapshot(); 78 | }); 79 | }); 80 | 81 | describe("on version mismatch", () => { 82 | beforeEach(() => { 83 | (useDevtoolsContext as jest.Mocked).mockReturnValue({ 84 | client: { 85 | connected: true, 86 | version: { 87 | required: "9.9.9", 88 | actual: "0.0.1", 89 | mismatch: true, 90 | }, 91 | }, 92 | } as any); 93 | }); 94 | 95 | it("matches snapshot", () => { 96 | expect(shallow()).toMatchSnapshot(); 97 | }); 98 | }); 99 | 100 | describe("on disconnected", () => { 101 | beforeEach(() => { 102 | (useDevtoolsContext as jest.Mocked).mockReturnValue({ 103 | client: { 104 | connected: false, 105 | }, 106 | } as any); 107 | }); 108 | 109 | it("matches snapshot", () => { 110 | expect(shallow()).toMatchSnapshot(); 111 | }); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/panel/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import React, { FC } from "react"; 3 | import { HashRouter, Route, Redirect } from "react-router-dom"; 4 | import { ThemeProvider } from "styled-components"; 5 | import { 6 | Disconnected, 7 | Explorer, 8 | Request, 9 | Timeline, 10 | Mismatch, 11 | ErrorBoundary, 12 | } from "./pages"; 13 | import { Navigation } from "./components/Navigation"; 14 | import { lightTheme, darkTheme, GlobalStyle } from "./theme"; 15 | import { 16 | DevtoolsProvider, 17 | RequestProvider, 18 | ExplorerProvider, 19 | useDevtoolsContext, 20 | TimelineProvider, 21 | } from "./context"; 22 | import { isLightMode } from "./util/EnvUtils"; 23 | 24 | export const App: FC = () => ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | 35 | export const AppRoutes: FC = () => { 36 | const { client } = useDevtoolsContext(); 37 | 38 | if (!client.connected) { 39 | return ; 40 | } 41 | 42 | if (client.version.mismatch) { 43 | return ; 44 | } 45 | 46 | return ( 47 | 48 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | } /> 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/panel/__image_snapshots__/TimelineDuration - Alive: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/panel/__image_snapshots__/TimelineDuration - Alive -------------------------------------------------------------------------------- /src/panel/__image_snapshots__/TimelineDuration - Network: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urql-graphql/urql-devtools/20968bf219cb0543f8e8b448d8ff4a94f36a5ec3/src/panel/__image_snapshots__/TimelineDuration - Network -------------------------------------------------------------------------------- /src/panel/__snapshots__/App.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App on mount matches snapshot 1`] = ` 4 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | `; 135 | 136 | exports[`App routes on mount on connected matches snapshot 1`] = ` 137 | 138 | 156 | 157 | 161 | 162 | 163 | 167 | 168 | 169 | 174 | 175 | 180 | 181 | `; 182 | 183 | exports[`App routes on mount on disconnected matches snapshot 1`] = ``; 184 | 185 | exports[`App routes on mount on version mismatch matches snapshot 1`] = ``; 186 | -------------------------------------------------------------------------------- /src/panel/components/Background.tsx: -------------------------------------------------------------------------------- 1 | import { rem } from "polished"; 2 | import styled from "styled-components"; 3 | 4 | export const Background = styled.div` 5 | overflow: hidden; 6 | position: fixed; 7 | background-color: ${(p) => p.theme.colors.canvas.base}; 8 | top: ${rem(36)}; 9 | bottom: 0; 10 | left: 0; 11 | right: 0; 12 | display: flex; 13 | flex-direction: column; 14 | 15 | @media (min-aspect-ratio: 1/1) { 16 | flex-direction: row; 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /src/panel/components/CodeHighlight.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { print } from "graphql"; 4 | import { gql } from "@urql/core"; 5 | import { CodeHighlight, InlineCodeHighlight } from "./CodeHighlight"; 6 | 7 | const query = gql` 8 | query Todos { 9 | todos { 10 | id 11 | name 12 | __typename 13 | } 14 | } 15 | `; 16 | 17 | const Wrapper = styled.div` 18 | padding: ${(p) => p.theme.space[6]}; 19 | 20 | p { 21 | color: ${(p) => p.theme.colors.text.base}; 22 | } 23 | `; 24 | 25 | export default { 26 | "Block - GraphQL": ( 27 | 28 |

29 | value: 30 |

31 |
32 | ), 33 | "Block - JSON": ( 34 | 35 |

36 | value:{" "} 37 | 45 |

46 |
47 | ), 48 | "Inline - string": ( 49 | 50 |

51 | value:{" "} 52 | 53 |

54 |
55 | ), 56 | "Inline - string (large)": ( 57 | 58 |

59 | value:{" "} 60 | 64 |

65 |
66 | ), 67 | "Inline - object": ( 68 | 69 |

70 | value:{" "} 71 | 75 |

76 |
77 | ), 78 | "Inline - object (large)": ( 79 | 80 |

81 | value:{" "} 82 | 89 |

90 |
91 | ), 92 | "Inline - array": ( 93 | 94 |

95 | value:{" "} 96 | 100 |

101 |
102 | ), 103 | "Inline - array (large)": ( 104 | 105 |

106 | value:{" "} 107 | 111 |

112 |
113 | ), 114 | }; 115 | -------------------------------------------------------------------------------- /src/panel/components/CodeHighlight.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useCallback, 4 | ComponentPropsWithoutRef, 5 | useState, 6 | useEffect, 7 | } from "react"; 8 | import styled from "styled-components"; 9 | 10 | type PrismLanguage = "javascript" | "graphql"; 11 | 12 | export const CodeHighlight: FC< 13 | { 14 | code: string; 15 | language: PrismLanguage; 16 | } & ComponentPropsWithoutRef 17 | > = ({ code, language, ...props }) => { 18 | const [visible, setVisibility] = useState(false); 19 | const [copy, setCopied] = useState({ state: false }); 20 | 21 | const handleClick = async () => { 22 | const text = document.getElementsByClassName("language")[0].textContent; 23 | if (text) { 24 | try { 25 | await navigator.clipboard.writeText(text); 26 | setCopied({ state: true }); 27 | } catch (err) { 28 | console.error("Failed to copy!", err); 29 | } 30 | } 31 | }; 32 | 33 | useEffect(() => { 34 | if (!copy) return; 35 | const timeout = setTimeout(function () { 36 | setCopied({ state: false }); 37 | }, 1000); 38 | return () => clearTimeout(timeout); 39 | }, [copy]); 40 | 41 | const handleRef = useCallback( 42 | (ref: HTMLPreElement | null) => { 43 | if (!ref) { 44 | return; 45 | } 46 | // Create new child node with text 47 | const child = document.createElement("code"); 48 | child.textContent = code; 49 | 50 | if (ref.hasChildNodes()) { 51 | ref.innerHTML = ""; 52 | } 53 | 54 | ref.appendChild(child); 55 | // Run prism on element (in web worker/async) 56 | // when code is a chonker 57 | Prism.highlightElement(ref, code.length > 600); 58 | }, 59 | [language, code] 60 | ); 61 | 62 | return ( 63 |
setVisibility(true)} 65 | onMouseLeave={() => setVisibility(false)} 66 | > 67 | 72 | {visible ? ( 73 | 74 | {copy.state ? "Copied" : "Copy"} 75 | 76 | ) : null} 77 |
78 | ); 79 | }; 80 | 81 | export const InlineCodeHighlight: FC< 82 | { 83 | code: string; 84 | language: PrismLanguage; 85 | } & ComponentPropsWithoutRef 86 | > = ({ code, language, ...props }) => { 87 | const handleRef = useCallback( 88 | (ref: HTMLPreElement | null) => { 89 | if (!ref) { 90 | return; 91 | } 92 | 93 | // Create new child node with text 94 | const child = document.createElement("code"); 95 | child.textContent = code; 96 | ref.firstChild 97 | ? ref.replaceChild(child, ref.firstChild) 98 | : ref.appendChild(child); 99 | 100 | // Run prism on pre 101 | Prism.highlightElement(ref, false); 102 | }, 103 | [language, code] 104 | ); 105 | 106 | return ( 107 | 112 | ); 113 | }; 114 | 115 | export const StyledInlineBlock = styled.pre` 116 | display: inline-flex; 117 | margin: 0 !important; 118 | padding: 0 !important; 119 | background-color: none !important; 120 | background: none !important; 121 | 122 | & > code > div { 123 | text-overflow: ellipsis; 124 | overflow: hidden; 125 | } 126 | `; 127 | 128 | const StyledCodeBlock = styled.pre` 129 | background: ${(p) => p.theme.colors.codeblock.background} !important; 130 | font-size: ${(p) => p.theme.fontSizes.body.m} !important; 131 | `; 132 | 133 | const CopyButton = styled.button` 134 | background: ${(p) => p.theme.colors.canvas.elevated05}; 135 | color: ${(p) => p.theme.colors.text.base}; 136 | padding: ${(p) => p.theme.space[3]}; 137 | border-radius: ${(p) => p.theme.radii.m}; 138 | position: absolute; 139 | top: ${(p) => p.theme.space[2]}; 140 | right: ${(p) => p.theme.space[2]}; 141 | 142 | &:hover { 143 | background: ${(p) => p.theme.colors.canvas.elevated10} !important; 144 | } 145 | `; 146 | 147 | const Div = styled.div` 148 | position: relative; 149 | max-width: 100%; 150 | `; 151 | -------------------------------------------------------------------------------- /src/panel/components/Collapsible.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useRef, useMemo } from "react"; 2 | 3 | export const Collapsible = forwardRef< 4 | HTMLDivElement, 5 | JSX.IntrinsicElements["div"] & { collapsed: boolean } 6 | >(function Collapsible({ collapsed, ...props }, forwardedRef) { 7 | const ref = useRef(); 8 | 9 | const handleRef = useCallback( 10 | (e) => { 11 | ref.current = e; 12 | 13 | if (typeof forwardedRef === "function") { 14 | return forwardedRef(e); 15 | } 16 | 17 | if (forwardedRef) { 18 | (forwardedRef as any).current = e; 19 | } 20 | }, 21 | [ref] 22 | ); 23 | 24 | const maxHeight = useMemo(() => { 25 | if (ref.current && !collapsed) { 26 | return ref.current.scrollHeight; 27 | } 28 | 29 | return 0; 30 | }, [collapsed]); 31 | 32 | return ( 33 |
39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /src/panel/components/IndicatorArrow.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import React, { FC } from "react"; 3 | import { rem } from "polished"; 4 | 5 | const ArrowIcon: FC = (props) => ( 6 | 7 | 11 | 12 | ); 13 | 14 | export const Arrow = styled(ArrowIcon)` 15 | flex-shrink: 0; 16 | width: ${rem(10)}; 17 | height: ${rem(10)}; 18 | margin-right: ${(p) => p.theme.space[2]}; 19 | color: ${(p) => p.theme.colors.text.base}; 20 | transform: rotate(0deg); 21 | transition: transform 100ms ease; 22 | 23 | &[data-active="true"] { 24 | transform: rotate(90deg); 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /src/panel/components/Navigation.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Navigation } from "./Navigation"; 3 | 4 | export default { 5 | basic: ( 6 | 23 | ), 24 | }; 25 | -------------------------------------------------------------------------------- /src/panel/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps, FC } from "react"; 2 | import { rem } from "polished"; 3 | import styled from "styled-components"; 4 | import { NavLink } from "react-router-dom"; 5 | import Icon from "../../assets/icon.svg"; 6 | 7 | type NavItem = { link: string; label: string }; 8 | 9 | export const Navigation: FC< 10 | { items: NavItem[] } & ComponentProps 11 | > = ({ items, ...props }) => ( 12 | 13 | {items.map((item, index) => ( 14 | 15 | {item.label} 16 | 17 | ))} 18 | 19 | 26 | 27 | 28 | 29 | ); 30 | 31 | const Container = styled.div` 32 | position: fixed; 33 | z-index: 1; 34 | display: flex; 35 | align-items: center; 36 | border-bottom: solid 1px ${(p) => p.theme.colors.divider.base}; 37 | background: ${(p) => p.theme.colors.canvas.base}; 38 | height: ${rem(36)}; 39 | top: 0; 40 | left: 0; 41 | right: 0; 42 | `; 43 | 44 | const Item = styled.a<{ alignRight?: boolean }>` 45 | position: relative; 46 | display: flex; 47 | align-items: center; 48 | height: 100%; 49 | padding: 0 ${(p) => p.theme.space[3]}; 50 | font-size: ${(p) => p.theme.fontSizes.body.m}; 51 | font-height: ${(p) => p.theme.lineHeights.body.m}; 52 | font-weight: 400; 53 | text-decoration: none; 54 | color: ${(p) => p.theme.colors.text.base}; 55 | ${({ alignRight }) => alignRight && `margin-left: auto;`} 56 | 57 | &:hover { 58 | background: ${(p) => p.theme.colors.canvas.hover}; 59 | } 60 | 61 | &:active { 62 | background: ${(p) => p.theme.colors.canvas.active}; 63 | } 64 | 65 | &.active::after { 66 | content: ""; 67 | position: absolute; 68 | left: 0; 69 | right: 0; 70 | bottom: -1px; 71 | height: ${rem(2)}; 72 | background: ${(p) => p.theme.colors.primary.base}; 73 | } 74 | `; 75 | 76 | const Logo = styled(Icon)` 77 | width: ${rem(32)}; 78 | height: ${rem(19)}; 79 | 80 | path { 81 | fill: ${(p) => p.theme.colors.textDimmed.base}; 82 | } 83 | `; 84 | -------------------------------------------------------------------------------- /src/panel/components/Pane.test.tsx: -------------------------------------------------------------------------------- 1 | jest.mock("../hooks", () => ({ 2 | useOrientationWatcher: jest.fn(), 3 | })); 4 | 5 | import React from "react"; 6 | import { shallow } from "enzyme"; 7 | import { useOrientationWatcher } from "../hooks"; 8 | import { Pane } from "./Pane"; 9 | 10 | const useOrientation = useOrientationWatcher as jest.Mocked; 11 | const addEventListener = jest.spyOn(window, "addEventListener"); 12 | 13 | beforeEach(jest.clearAllMocks); 14 | 15 | describe("on mount", () => { 16 | describe("on portrait orientation", () => { 17 | beforeEach(() => { 18 | useOrientation.mockReturnValue({ isPortrait: true, isLandscape: false }); 19 | }); 20 | 21 | it("matches snapshot", () => { 22 | const wrapper = shallow(); 23 | expect(wrapper).toMatchSnapshot(); 24 | }); 25 | }); 26 | 27 | describe("on landscape orientation", () => { 28 | beforeEach(() => { 29 | useOrientation.mockReturnValue({ 30 | isPortrait: false, 31 | isLandscape: true, 32 | }); 33 | }); 34 | 35 | it("matches snapshot", () => { 36 | const wrapper = shallow(); 37 | expect(wrapper).toMatchSnapshot(); 38 | }); 39 | }); 40 | }); 41 | 42 | describe("on mouse down", () => { 43 | beforeEach(() => { 44 | const wrapper = shallow(); 45 | wrapper 46 | .find("DraggingEdge") 47 | .simulate("mouseDown", { button: 0, preventDefault: jest.fn() }); 48 | }); 49 | it("listens for mouse up events", () => { 50 | expect(addEventListener).toBeCalledWith("mouseup", expect.any(Function)); 51 | }); 52 | 53 | it("listens for mouse move events", () => { 54 | expect(addEventListener).toBeCalledWith("mousemove", expect.any(Function)); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/panel/components/Pane.tsx: -------------------------------------------------------------------------------- 1 | import { rem } from "polished"; 2 | import React, { 3 | FC, 4 | useCallback, 5 | useState, 6 | useMemo, 7 | MouseEventHandler, 8 | ComponentProps, 9 | useRef, 10 | } from "react"; 11 | import styled from "styled-components"; 12 | import { useOrientationWatcher } from "../hooks"; 13 | 14 | interface OverrideProps { 15 | forcedOrientation?: { isPortrait: boolean }; 16 | initSize?: { x: number; y: number }; 17 | "data-snapshot"?: boolean; 18 | } 19 | 20 | const PaneRoot: FC & OverrideProps> = ({ 21 | children, 22 | forcedOrientation, 23 | initSize, 24 | ...props 25 | }) => { 26 | const [grabbed, setGrabbed] = useState(false); 27 | const [size, setSize] = useState(initSize ? initSize : { x: 400, y: 400 }); 28 | const dynamicOrientation = useOrientationWatcher(); 29 | const paneRef = useRef(null); 30 | 31 | const { isPortrait } = forcedOrientation 32 | ? forcedOrientation 33 | : dynamicOrientation; 34 | 35 | type position = { x: number; y: number }; 36 | const handleClick = useCallback( 37 | (ce) => { 38 | // Right/middle click 39 | if (ce.button !== 0) { 40 | return; 41 | } 42 | 43 | ce.preventDefault(); 44 | document.body.style.cursor = isPortrait ? "ns-resize" : "ew-resize"; 45 | setGrabbed(true); 46 | let latestPosition: position = { x: ce.clientX, y: ce.clientY }; 47 | let moving = true; 48 | 49 | const renderFrame = () => { 50 | window.requestAnimationFrame(() => { 51 | setSize((s) => 52 | isPortrait 53 | ? { 54 | ...s, 55 | y: window.innerHeight - latestPosition.y, 56 | } 57 | : { ...s, x: window.innerWidth - latestPosition.x } 58 | ); 59 | moving && renderFrame(); 60 | }); 61 | }; 62 | 63 | const handleMouseMove = (e: MouseEvent) => { 64 | latestPosition = { x: e.clientX, y: e.clientY }; 65 | }; 66 | 67 | const handleMouseUp = () => { 68 | moving = false; 69 | document.body.style.cursor = ""; 70 | setGrabbed(false); 71 | window.removeEventListener("mouseup", handleMouseUp); 72 | window.removeEventListener("mousemove", handleMouseMove); 73 | }; 74 | 75 | renderFrame(); 76 | window.addEventListener("mouseup", handleMouseUp); 77 | window.addEventListener("mousemove", handleMouseMove); 78 | }, 79 | [size, isPortrait] 80 | ); 81 | 82 | const style = useMemo( 83 | () => 84 | isPortrait 85 | ? { minHeight: size.y, height: size.y, width: "auto" } 86 | : { minWidth: size.x, width: size.x, height: "auto" }, 87 | [size, isPortrait] 88 | ); 89 | 90 | return ( 91 | 97 | {children} 98 | 105 | 106 | ); 107 | }; 108 | 109 | const PaneContainer = styled.div` 110 | position: relative; 111 | display: flex; 112 | flex-direction: column; 113 | background: ${(p) => p.theme.colors.canvas.base}; 114 | border-top: solid 1px ${(p) => p.theme.colors.divider.base}; 115 | 116 | width: 100%; 117 | height: ${rem(400)}; 118 | 119 | &[data-portrait="false"] { 120 | width: ${rem(400)}; 121 | height: 100%; 122 | border-top: none; 123 | border-left: solid 1px ${(p) => p.theme.colors.divider.base}; 124 | } 125 | `; 126 | 127 | const edgeWidth = 4; 128 | 129 | const DraggingEdge = styled.div` 130 | position: absolute; 131 | z-index: 3; 132 | opacity: 0; 133 | 134 | cursor: ns-resize; 135 | width: 100%; 136 | height: ${edgeWidth}px; 137 | top: -${edgeWidth / 2}px; 138 | 139 | &[data-portrait="false"] { 140 | width: ${edgeWidth}px; 141 | height: 100%; 142 | margin-top: 0; 143 | top: 0; 144 | left: -${edgeWidth / 2}px; 145 | cursor: ew-resize; 146 | } 147 | `; 148 | 149 | const Body = styled.div` 150 | flex: 1; 151 | overflow: auto; 152 | `; 153 | 154 | const Header = styled.h2` 155 | margin: 0; 156 | padding: ${(p) => p.theme.space[3]}; 157 | background: ${(p) => p.theme.colors.codeblock.background}; 158 | border-bottom: solid 1px ${(p) => p.theme.colors.divider.base}; 159 | font-size: ${(p) => p.theme.fontSizes.body.m}; 160 | line-height: ${(p) => p.theme.lineHeights.body.m}; 161 | font-weight: 400; 162 | `; 163 | 164 | const Item = styled.div` 165 | padding: ${(p) => p.theme.space[3]}; 166 | 167 | & + & { 168 | border-top: solid 1px ${(p) => p.theme.colors.divider.base}; 169 | } 170 | `; 171 | 172 | const ItemTitle = styled.h3` 173 | color: ${(p) => p.theme.colors.text.base}; 174 | font-size: ${(p) => p.theme.fontSizes.body.m}; 175 | line-height: ${(p) => p.theme.lineHeights.body.m}; 176 | font-weight: normal; 177 | margin-top: 0; 178 | margin-bottom: ${(p) => p.theme.space[2]}; 179 | `; 180 | 181 | type Pane = typeof PaneRoot & { 182 | Body: typeof Body; 183 | Header: typeof Header; 184 | Item: typeof Item; 185 | ItemTitle: typeof ItemTitle; 186 | }; 187 | 188 | (PaneRoot as Pane).Body = Body; 189 | (PaneRoot as Pane).Header = Header; 190 | (PaneRoot as Pane).Item = Item; 191 | (PaneRoot as Pane).ItemTitle = ItemTitle; 192 | 193 | export const Pane = PaneRoot as Pane; 194 | -------------------------------------------------------------------------------- /src/panel/components/Portal.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef, FC } from "react"; 2 | import { createPortal } from "react-dom"; 3 | 4 | export const Portal: FC = ({ children }) => { 5 | const root = useRef(document.createElement("div")); 6 | 7 | useLayoutEffect(() => { 8 | const portalElement = 9 | document.getElementById("portal") || createPortalRoot(); 10 | 11 | portalElement.appendChild(root.current); 12 | return () => root.current.remove(); 13 | }, []); 14 | 15 | return createPortal(children, root.current); 16 | }; 17 | 18 | const createPortalRoot = () => { 19 | const portalElement = document.createElement("div"); 20 | portalElement.id = "portal"; 21 | 22 | document.querySelector("body")?.appendChild(portalElement); 23 | return portalElement; 24 | }; 25 | -------------------------------------------------------------------------------- /src/panel/components/Tabs.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { Tabs } from "./Tabs"; 4 | 5 | beforeEach(jest.clearAllMocks); 6 | 7 | const props = { 8 | active: "a", 9 | options: [ 10 | { label: "first", value: "a" }, 11 | { label: "second", value: "b" }, 12 | ], 13 | setActive: jest.fn(), 14 | }; 15 | 16 | describe("on mount", () => { 17 | it("matches snapshot", () => { 18 | const wrapper = shallow(); 19 | expect(wrapper).toMatchSnapshot(); 20 | }); 21 | }); 22 | 23 | describe("on tab click", () => { 24 | it("calls setActive", () => { 25 | const wrapper = shallow(); 26 | wrapper.find("Tab:last-child").simulate("click"); 27 | expect(props.setActive).toBeCalledWith("b"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/panel/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | 4 | interface TabsProps { 5 | readonly active: T; 6 | readonly options: readonly { readonly label: string; readonly value: T }[]; 7 | readonly setActive: (active: T) => void; 8 | } 9 | 10 | export const Tabs: FC = ({ active, options, setActive }) => ( 11 | 12 | {options.map((o) => ( 13 | setActive(o.value)} 17 | > 18 | {o.label} 19 | 20 | ))} 21 | 22 | ); 23 | 24 | const Container = styled.div` 25 | display: flex; 26 | `; 27 | 28 | const Tab = styled.h3` 29 | margin: 0; 30 | padding: ${(p) => p.theme.space[3]}; 31 | font-size: ${(p) => p.theme.fontSizes.body.m}; 32 | line-height: ${(p) => p.theme.lineHeights.body.m}; 33 | color: ${(p) => p.theme.colors.textDimmed.base}; 34 | 35 | &[data-active="true"] { 36 | color: ${(p) => p.theme.colors.text.base}; 37 | } 38 | 39 | &:hover { 40 | color: ${(p) => p.theme.colors.textDimmed.hover}; 41 | cursor: pointer; 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /src/panel/components/Toolbar.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | faCog, 4 | faPlay, 5 | faTrashAlt, 6 | faAlignLeft, 7 | } from "@fortawesome/free-solid-svg-icons"; 8 | import { Toolbar } from "./Toolbar"; 9 | 10 | export default { 11 | basic: ( 12 | console.log("Settings"), 18 | active: true, 19 | }, 20 | { 21 | title: "Run", 22 | icon: faPlay, 23 | onClick: () => console.log("Run"), 24 | }, 25 | { 26 | title: "Clear", 27 | icon: faTrashAlt, 28 | onClick: () => console.log("Clear"), 29 | }, 30 | { 31 | title: "Prettify", 32 | icon: faAlignLeft, 33 | onClick: () => console.log("Prettify"), 34 | }, 35 | ]} 36 | data-snapshot 37 | /> 38 | ), 39 | }; 40 | -------------------------------------------------------------------------------- /src/panel/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps, FC } from "react"; 2 | import styled from "styled-components"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { IconProp } from "@fortawesome/fontawesome-svg-core"; 5 | import { rem } from "polished"; 6 | 7 | type ToolbarItem = { 8 | title: string; 9 | icon: IconProp; 10 | onClick: () => void; 11 | id?: string; 12 | active?: boolean; 13 | disabled?: boolean; 14 | }; 15 | 16 | export const Toolbar: FC< 17 | { items: ToolbarItem[] } & ComponentProps 18 | > = ({ items, children, ...props }) => ( 19 | 20 | {items.map((item, index) => ( 21 | 29 | 30 | 31 | ))} 32 | 33 | {children} 34 | 35 | ); 36 | 37 | const Container = styled.div` 38 | display: flex; 39 | align-items: center; 40 | width: 100%; 41 | border-bottom: solid 1px ${(p) => p.theme.colors.divider.base}; 42 | `; 43 | 44 | const Item = styled.button<{ active?: boolean }>` 45 | font-size: ${(p) => p.theme.fontSizes.body.l}; 46 | line-height: ${(p) => p.theme.lineHeights.body.l}; 47 | width: ${rem(32)}; 48 | height: ${rem(32)}; 49 | flex-shrink: 0; 50 | color: ${(p) => 51 | p.active ? p.theme.colors.primary.base : p.theme.colors.textDimmed.base}; 52 | 53 | &:hover { 54 | color: ${(p) => 55 | p.active 56 | ? p.theme.colors.primary.hover 57 | : p.theme.colors.textDimmed.hover}; 58 | } 59 | 60 | &:active { 61 | color: ${(p) => 62 | p.active 63 | ? p.theme.colors.primary.active 64 | : p.theme.colors.textDimmed.active}; 65 | } 66 | 67 | &::[disabled] { 68 | opacity: 0.5; 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /src/panel/components/__snapshots__/Pane.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`on mount on landscape orientation matches snapshot 1`] = ` 4 | 14 | 21 | 22 | `; 23 | 24 | exports[`on mount on portrait orientation matches snapshot 1`] = ` 25 | 35 | 42 | 43 | `; 44 | -------------------------------------------------------------------------------- /src/panel/components/__snapshots__/Tabs.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`on mount matches snapshot 1`] = ` 4 | 5 | 10 | first 11 | 12 | 17 | second 18 | 19 | 20 | `; 21 | -------------------------------------------------------------------------------- /src/panel/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Background"; 2 | export * from "./Tabs"; 3 | export * from "./Pane"; 4 | export * from "./Portal"; 5 | export * from "./CodeHighlight"; 6 | export * from "./Collapsible"; 7 | export * from "./Navigation"; 8 | export * from "./IndicatorArrow"; 9 | export * from "./Toolbar"; 10 | -------------------------------------------------------------------------------- /src/panel/context/Devtools.tsx: -------------------------------------------------------------------------------- 1 | import { DevtoolsMessage, ExchangeMessage } from "@urql/devtools"; 2 | import semver from "semver"; 3 | import React, { 4 | createContext, 5 | useEffect, 6 | FC, 7 | useRef, 8 | useCallback, 9 | useState, 10 | useContext, 11 | } from "react"; 12 | import { createConnection } from "../util"; 13 | 14 | export interface DevtoolsContextType { 15 | sendMessage: (message: DevtoolsMessage) => void; 16 | addMessageHandler: (cb: (message: ExchangeMessage) => void) => () => void; 17 | client: 18 | | { 19 | connected: false; 20 | } 21 | | { 22 | connected: true; 23 | version: { 24 | required: string; 25 | actual: string; 26 | mismatch: boolean; 27 | }; 28 | }; 29 | } 30 | 31 | const REQUIRED_VERSION = "2.0.0"; 32 | 33 | export const DevtoolsContext = createContext(null as any); 34 | 35 | export const useDevtoolsContext = (): DevtoolsContextType => 36 | useContext(DevtoolsContext); 37 | 38 | export const DevtoolsProvider: FC = ({ children }) => { 39 | const [client, setClient] = useState({ 40 | connected: false, 41 | }); 42 | const connection = useRef(createConnection()); 43 | 44 | /** Collection of operation events */ 45 | const messageHandlers = useRef< 46 | Record void> 47 | >({}); 48 | 49 | const sendMessage = useCallback( 50 | (msg) => connection.current.postMessage(msg), 51 | [] 52 | ); 53 | 54 | const addMessageHandler = useCallback< 55 | DevtoolsContextType["addMessageHandler"] 56 | >((callback) => { 57 | const i = index++; 58 | messageHandlers.current[i] = callback; 59 | 60 | return () => { 61 | delete messageHandlers.current[i]; 62 | }; 63 | }, []); 64 | 65 | // Send init message on mount 66 | useEffect(() => { 67 | connection.current.postMessage({ 68 | type: "connection-init", 69 | source: "devtools", 70 | tabId: 71 | process.env.BUILD_ENV === "extension" 72 | ? chrome?.devtools?.inspectedWindow?.tabId 73 | : NaN, 74 | version: process.env.PKG_VERSION, 75 | }); 76 | }, []); 77 | 78 | // Forward exchange messages to subscribers 79 | useEffect(() => { 80 | const handleMessage = (msg: ExchangeMessage | DevtoolsMessage) => { 81 | if (msg?.source !== "exchange") { 82 | return; 83 | } 84 | 85 | return Object.values(messageHandlers.current).forEach((h) => h(msg)); 86 | }; 87 | 88 | connection.current.onMessage.addListener(handleMessage); 89 | return () => connection.current.onMessage.removeListener(handleMessage); 90 | }, []); 91 | 92 | // Listen for client connect 93 | useEffect(() => { 94 | if (client.connected) { 95 | return; 96 | } 97 | 98 | return addMessageHandler((message) => { 99 | if ( 100 | message.type !== "connection-acknowledge" && 101 | message.type !== "connection-init" 102 | ) { 103 | return; 104 | } 105 | 106 | if (message.type === "connection-init") { 107 | connection.current.postMessage({ 108 | type: "connection-acknowledge", 109 | source: "devtools", 110 | version: process.env.PKG_VERSION, 111 | }); 112 | } 113 | 114 | return setClient({ 115 | connected: true, 116 | version: { 117 | required: REQUIRED_VERSION, 118 | actual: message.version, 119 | mismatch: 120 | !semver.valid(message.version) || 121 | !semver.satisfies(message.version, `>=${REQUIRED_VERSION}`), 122 | }, 123 | }); 124 | }); 125 | }, [addMessageHandler, client.connected]); 126 | 127 | // Listen for client disconnect 128 | useEffect(() => { 129 | if (!client.connected) { 130 | return; 131 | } 132 | 133 | return addMessageHandler((message) => { 134 | if (message.type !== "connection-disconnect") { 135 | return; 136 | } 137 | 138 | setClient({ connected: false }); 139 | }); 140 | }, [addMessageHandler, client.connected]); 141 | 142 | return ( 143 | 146 | {children} 147 | 148 | ); 149 | }; 150 | 151 | let index = 0; 152 | -------------------------------------------------------------------------------- /src/panel/context/Explorer/Explorer.test.tsx: -------------------------------------------------------------------------------- 1 | jest.mock("../Devtools"); 2 | jest.mock("./ast"); 3 | import React, { useContext } from "react"; 4 | import { mount } from "enzyme"; 5 | import { act } from "react-dom/test-utils"; 6 | import { useDevtoolsContext } from "../Devtools"; 7 | import { ExplorerProvider, ExplorerContext } from "../Explorer"; 8 | import { defaultEvents } from "../../pages/explorer/Explorer.fixture"; 9 | import { handleResponse } from "./ast"; 10 | const sendMessage = jest.fn(); 11 | const addMessageHandler = jest.fn(); 12 | 13 | beforeEach(() => { 14 | (useDevtoolsContext as jest.Mocked).mockReturnValue({ 15 | client: { 16 | connected: true, 17 | version: { 18 | required: "9.9.9", 19 | mismatch: false, 20 | actual: "9.9.9", 21 | }, 22 | }, 23 | sendMessage, 24 | addMessageHandler, 25 | }); 26 | }); 27 | 28 | beforeEach(jest.clearAllMocks); 29 | 30 | let state: any; 31 | 32 | const Fixture = () => { 33 | state = useContext(ExplorerContext); 34 | return null; 35 | }; 36 | 37 | describe("on mount", () => { 38 | beforeEach(() => { 39 | mount( 40 | 41 | 42 | 43 | ); 44 | }); 45 | 46 | it("listens for events", () => { 47 | expect(addMessageHandler).toBeCalledTimes(1); 48 | }); 49 | 50 | describe("state", () => { 51 | it("matches snapshot", () => { 52 | expect(state).toMatchInlineSnapshot(` 53 | { 54 | "expandedNodes": [], 55 | "focusedNode": undefined, 56 | "operations": {}, 57 | "setExpandedNodes": [Function], 58 | "setFocusedNode": [Function], 59 | } 60 | `); 61 | }); 62 | }); 63 | }); 64 | 65 | describe("DebugMessage", () => { 66 | beforeEach(async () => { 67 | addMessageHandler.mockImplementationOnce((cb) => cb(defaultEvents[0])); 68 | await act(async () => { 69 | mount( 70 | 71 | 72 | 73 | ); 74 | }); 75 | }); 76 | it("calls handleResponse with the correct message ", () => { 77 | expect(addMessageHandler).toHaveBeenCalledTimes(1); 78 | expect(handleResponse).toHaveBeenCalledTimes(1); 79 | expect(handleResponse).toHaveBeenCalledWith( 80 | expect.objectContaining({ 81 | operation: defaultEvents[0].data.operation, 82 | data: defaultEvents[0].data.data.value, 83 | }) 84 | ); 85 | }); 86 | }); 87 | 88 | describe("unknown message", () => { 89 | beforeEach(() => { 90 | addMessageHandler.mockImplementationOnce((cb) => cb({ type: "unknown" })); 91 | }); 92 | it("doesn't call handleResponse", (done) => { 93 | act(() => { 94 | mount( 95 | 96 | 97 | 98 | ); 99 | }); 100 | expect(addMessageHandler).toHaveBeenCalledTimes(1); 101 | expect(handleResponse).toHaveBeenCalledTimes(0); 102 | done(); 103 | }); 104 | }); 105 | 106 | describe("disconnect message", () => { 107 | beforeEach(async () => { 108 | addMessageHandler.mockImplementationOnce((cb) => cb(defaultEvents[0])); 109 | await act(async () => { 110 | mount( 111 | 112 | 113 | 114 | ); 115 | }); 116 | addMessageHandler.mockImplementationOnce((cb) => 117 | cb({ type: "disconnect" }) 118 | ); 119 | await act(async () => { 120 | mount( 121 | 122 | 123 | 124 | ); 125 | }); 126 | }); 127 | it("doesn't call handleResponse and resets the operations", () => { 128 | expect(addMessageHandler).toHaveBeenCalledTimes(2); 129 | // * once for initial DebugMessage 130 | expect(handleResponse).toHaveBeenCalledTimes(1); 131 | 132 | expect(state).toEqual( 133 | expect.objectContaining({ 134 | operations: {}, 135 | }) 136 | ); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/panel/context/Explorer/ast/variables.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldNode, 3 | OperationDefinitionNode, 4 | valueFromASTUntyped, 5 | } from "graphql"; 6 | import { Operation } from "@urql/core"; 7 | 8 | type Maybe = null | undefined | T; 9 | 10 | /** Evaluates a fields arguments taking vars into account */ 11 | export const getFieldArguments = ( 12 | node: FieldNode, 13 | vars: Operation["variables"] 14 | ): Record | undefined => { 15 | if (node.arguments === undefined || node.arguments.length === 0) { 16 | return; 17 | } 18 | 19 | return node.arguments.reduce( 20 | (p, arg) => ({ 21 | ...p, 22 | [arg.name.value]: valueFromASTUntyped(arg.value, vars as any), 23 | }), 24 | {} 25 | ) as Record; 26 | }; 27 | 28 | /** Returns a normalized form of variables with defaulted values */ 29 | export const getNormalizedVariables = ( 30 | variableDefinitions: OperationDefinitionNode["variableDefinitions"] = [], 31 | variables?: Maybe> 32 | ): Record | undefined => 33 | variableDefinitions.reduce( 34 | (normalized, definition) => ({ 35 | ...normalized, 36 | [definition.variable.name.value]: valueFromASTUntyped( 37 | definition.variable, 38 | variables 39 | ), 40 | }), 41 | {} 42 | ) as Record; 43 | -------------------------------------------------------------------------------- /src/panel/context/Explorer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useState, 4 | useEffect, 5 | FC, 6 | useMemo, 7 | SetStateAction, 8 | Dispatch, 9 | } from "react"; 10 | import { useDevtoolsContext } from "../Devtools"; 11 | import { handleResponse, ParsedNodeMap, ParsedFieldNode } from "./ast"; 12 | 13 | export interface ExplorerContextValue { 14 | operations: ParsedNodeMap; 15 | expandedNodes: ParsedFieldNode[]; 16 | setExpandedNodes: Dispatch>; 17 | focusedNode?: ParsedFieldNode; 18 | setFocusedNode: Dispatch>; 19 | } 20 | 21 | export const ExplorerContext = createContext(null as any); 22 | 23 | export const ExplorerProvider: FC = ({ children }) => { 24 | const { addMessageHandler } = useDevtoolsContext(); 25 | const [operations, setOperations] = useState< 26 | ExplorerContextValue["operations"] 27 | >({}); 28 | const [expandedNodes, setExpandedNodes] = useState< 29 | ExplorerContextValue["expandedNodes"] 30 | >([]); 31 | const [focusedNode, setFocusedNode] = useState< 32 | ExplorerContextValue["focusedNode"] 33 | >(undefined); 34 | 35 | useEffect(() => { 36 | return addMessageHandler((message) => { 37 | if (message.type === "connection-disconnect") { 38 | setOperations({}); 39 | return; 40 | } 41 | 42 | if (message.type !== "debug-event" || message.data.type !== "update") { 43 | return; 44 | } 45 | 46 | const debugEvent = message.data; 47 | 48 | if (debugEvent.data) { 49 | setOperations((operations) => 50 | handleResponse({ 51 | operation: debugEvent.operation, 52 | data: debugEvent.data.value, 53 | parsedNodes: operations, 54 | }) 55 | ); 56 | return; 57 | } 58 | }); 59 | }, [addMessageHandler]); 60 | 61 | const value = useMemo( 62 | () => ({ 63 | expandedNodes, 64 | setExpandedNodes, 65 | focusedNode, 66 | setFocusedNode, 67 | operations, 68 | }), 69 | [operations, focusedNode, setFocusedNode, expandedNodes, setExpandedNodes] 70 | ); 71 | 72 | return ( 73 | 74 | {children} 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/panel/context/Request.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | FC, 4 | useState, 5 | useEffect, 6 | useCallback, 7 | useMemo, 8 | useContext, 9 | } from "react"; 10 | import { 11 | visit, 12 | buildClientSchema, 13 | DocumentNode, 14 | extendSchema, 15 | GraphQLSchema, 16 | getIntrospectionQuery, 17 | } from "graphql"; 18 | import { gql } from "@urql/core"; 19 | import { useDevtoolsContext } from "./Devtools"; 20 | 21 | interface RequestContextValue { 22 | query?: string; 23 | setQuery: (s: string) => void; 24 | fetching: boolean; 25 | response?: Record; 26 | execute: () => void; 27 | error?: Record; 28 | schema?: GraphQLSchema; 29 | } 30 | 31 | export const RequestContext = createContext(null as any); 32 | 33 | export const useRequest = (): RequestContextValue => useContext(RequestContext); 34 | 35 | export const RequestProvider: FC = ({ children }) => { 36 | const { sendMessage, addMessageHandler } = useDevtoolsContext(); 37 | const [state, setState] = useState<{ 38 | fetching: boolean; 39 | response?: Record; 40 | error?: Record; 41 | }>({ fetching: false, response: undefined, error: undefined }); 42 | const [query, setQuery] = useState( 43 | localStorage.getItem("urql-last-request") || undefined 44 | ); 45 | const [schema, setSchema] = useState(); 46 | 47 | const execute = useCallback(() => { 48 | setState({ 49 | fetching: true, 50 | response: undefined, 51 | error: undefined, 52 | }); 53 | sendMessage({ 54 | type: "execute-query", 55 | source: "devtools", 56 | query: query || "", 57 | }); 58 | }, [query, sendMessage]); 59 | 60 | // Listen for response for devtools 61 | useEffect(() => { 62 | return addMessageHandler((e) => { 63 | if (e.type !== "debug-event") { 64 | return; 65 | } 66 | 67 | const debugEvent = e.data; 68 | 69 | if (debugEvent.operation.context.meta?.source !== "Devtools") { 70 | return; 71 | } 72 | 73 | let isIntrospection; 74 | try { 75 | // Starting at GQL 16 this can throw for invalid queries 76 | isIntrospection = isIntrospectionQuery(debugEvent.operation.query); 77 | } catch (e) { 78 | isIntrospection = false; 79 | } 80 | 81 | if (debugEvent.type === "update" && isIntrospection) { 82 | setSchema( 83 | appendPopulateDirective(buildClientSchema(debugEvent.data.value)) 84 | ); 85 | return; 86 | } 87 | 88 | if (debugEvent.type === "update") { 89 | setState({ 90 | fetching: false, 91 | response: debugEvent.data.value, 92 | }); 93 | return; 94 | } 95 | 96 | if (debugEvent.type === "error") { 97 | setState({ 98 | fetching: false, 99 | error: debugEvent.data.value, 100 | }); 101 | return; 102 | } 103 | }); 104 | }, [addMessageHandler]); 105 | 106 | // Get schema 107 | useEffect(() => { 108 | sendMessage({ 109 | type: "execute-query", 110 | source: "devtools", 111 | query: getIntrospectionQuery(), 112 | }); 113 | }, []); 114 | 115 | useEffect(() => { 116 | if (query === undefined) { 117 | return; 118 | } 119 | localStorage.setItem("urql-last-request", query); 120 | }, [query]); 121 | 122 | const value = useMemo( 123 | () => ({ 124 | query, 125 | setQuery, 126 | ...state, 127 | execute, 128 | schema, 129 | }), 130 | [query, state, execute, schema] 131 | ); 132 | 133 | return ; 134 | }; 135 | 136 | const isIntrospectionQuery = (query: DocumentNode) => { 137 | let value = false; 138 | 139 | visit(query, { 140 | OperationDefinition: { 141 | enter: (n) => { 142 | if (n.name?.value === "IntrospectionQuery") { 143 | value = true; 144 | } 145 | return false; 146 | }, 147 | }, 148 | }); 149 | 150 | return value; 151 | }; 152 | 153 | const appendPopulateDirective = (schema: GraphQLSchema): GraphQLSchema => { 154 | try { 155 | return extendSchema( 156 | schema, 157 | gql` 158 | directive @populate on FIELD 159 | ` 160 | ); 161 | } catch (err: any) { 162 | if ( 163 | err.message.startsWith( 164 | 'Directive "populate" already exists in the schema' 165 | ) 166 | ) 167 | return schema; 168 | throw err; 169 | } 170 | }; 171 | -------------------------------------------------------------------------------- /src/panel/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Devtools"; 2 | export * from "./Request"; 3 | export * from "./Explorer"; 4 | export * from "./Timeline"; 5 | -------------------------------------------------------------------------------- /src/panel/cosmos.decorator.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import "./prism"; 3 | import React, { FC } from "react"; 4 | import { MemoryRouter } from "react-router"; 5 | import { ThemeProvider, createGlobalStyle } from "styled-components"; 6 | import { useSelect } from "react-cosmos/fixture"; 7 | import { lightTheme, darkTheme, GlobalStyle } from "./theme"; 8 | import { DevtoolsContext } from "./context"; 9 | 10 | const FixtureStyle = createGlobalStyle` 11 | body, html, #root { 12 | height: 100%; 13 | margin: 0; 14 | background: ${(p) => p.theme.colors.canvas.base}; 15 | font-family: "Roboto", "Arial", sans-serif; 16 | font-weight: 400; 17 | text-rendering: optimizeLegibility; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | font-synthesis: none; 21 | } 22 | 23 | #root { 24 | display: flex; 25 | } 26 | 27 | #root > * { 28 | top: 0; 29 | } 30 | `; 31 | 32 | export const ThemeDecorator: FC = ({ children, ...props }) => { 33 | const [theme] = useSelect("theme", { 34 | options: ["dark", "light"], 35 | }); 36 | 37 | return ( 38 | 39 | 40 | {children} 41 | 42 | ); 43 | }; 44 | 45 | export const DevtoolsDecorator: FC = (props) => ( 46 | () => false, 50 | client: { 51 | connected: true, 52 | version: { 53 | required: "8.8.8", 54 | mismatch: false, 55 | actual: "9.9.9", 56 | }, 57 | }, 58 | sendMessage: () => false, 59 | }} 60 | /> 61 | ); 62 | 63 | const Decorator: FC = ({ children }) => ( 64 | 65 | 66 | {children} 67 | 68 | 69 | 70 | ); 71 | 72 | export default Decorator; 73 | -------------------------------------------------------------------------------- /src/panel/definitions.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg"; 2 | 3 | declare namespace NodeJS { 4 | interface Global { 5 | matchMedia: jest.Mock; 6 | } 7 | export interface ProcessEnv { 8 | NODE_ENV: "production" | "development" | "testing"; 9 | BUILD_ENV: "extension" | "electron"; 10 | PKG_VERSION: string; 11 | } 12 | } 13 | 14 | /** 15 | * Implicit prism declaration (injected by ./prism.ts). 16 | */ 17 | declare const Prism: import("prismjs"); 18 | -------------------------------------------------------------------------------- /src/panel/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useOrientationWatcher"; 2 | -------------------------------------------------------------------------------- /src/panel/hooks/useOrientationWatcher.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useMemo } from "react"; 2 | 3 | const aspectQuery = window.matchMedia("(min-aspect-ratio: 1/1)"); 4 | 5 | interface OrientationWatcher { 6 | isPortrait: boolean; 7 | isLandscape: boolean; 8 | } 9 | 10 | export const useOrientationWatcher = (): OrientationWatcher => { 11 | const [state, setState] = useState(aspectQuery.matches); 12 | 13 | useEffect(() => { 14 | const listener = () => setState(aspectQuery.matches); 15 | aspectQuery.addEventListener("change", listener); 16 | return () => aspectQuery.removeEventListener("change", listener); 17 | }, []); 18 | 19 | return useMemo(() => ({ isPortrait: !state, isLandscape: state }), [state]); 20 | }; 21 | -------------------------------------------------------------------------------- /src/panel/pages/disconnected/Disconnected.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Disconnected } from "./Disconnected"; 3 | 4 | export default { 5 | basic: , 6 | }; 7 | -------------------------------------------------------------------------------- /src/panel/pages/disconnected/Disconnected.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { Disconnected } from "./Disconnected"; 4 | 5 | describe("on mount", () => { 6 | it("matches snapshot", () => { 7 | expect(shallow()).toMatchSnapshot(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/panel/pages/disconnected/Disconnected.tsx: -------------------------------------------------------------------------------- 1 | import { rem } from "polished"; 2 | import React, { FC, ComponentProps } from "react"; 3 | import styled, { createGlobalStyle } from "styled-components"; 4 | import Icon from "../../../assets/icon.svg"; 5 | 6 | export const Disconnected: FC> = (props) => ( 7 | <> 8 | 9 | 10 | 11 |
Waiting for exchange
12 | Make sure {"you're"} using the Urql Devtools exchange! 13 |
14 | 15 | ); 16 | 17 | const GlobalStyle = createGlobalStyle` 18 | body { 19 | margin: 0; 20 | } 21 | `; 22 | 23 | const Container = styled.div` 24 | width: 100%; 25 | height: 100%; 26 | background: ${(p) => p.theme.colors.canvas.base}; 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: center; 30 | align-items: center; 31 | `; 32 | 33 | const Header = styled.h1` 34 | color: ${(p) => p.theme.colors.text.base}; 35 | font-weight: 400; 36 | margin: 0; 37 | `; 38 | 39 | const Hint = styled.p` 40 | color: ${(p) => p.theme.colors.textDimmed.base}; 41 | `; 42 | 43 | const Logo = styled(Icon)` 44 | width: ${rem(150)}; 45 | 46 | path { 47 | fill: ${(p) => p.theme.colors.text.base}; 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /src/panel/pages/disconnected/__snapshots__/Disconnected.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`on mount matches snapshot 1`] = ` 4 | 5 | 6 | 7 | 8 |
9 | Waiting for exchange 10 |
11 | 12 | Make sure 13 | you're 14 | using the Urql Devtools exchange! 15 | 16 |
17 |
18 | `; 19 | -------------------------------------------------------------------------------- /src/panel/pages/disconnected/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Disconnected"; 2 | -------------------------------------------------------------------------------- /src/panel/pages/error/ErrorBoundary.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ErrorBoundary } from "./ErrorBoundary"; 3 | 4 | const ErrorChild = () => { 5 | const err = Error("Something went wrong"); 6 | 7 | if (!err.stack) { 8 | throw err; 9 | } 10 | 11 | err.stack = err.stack 12 | .replace(/localhost|host.docker.internal/g, "cosmos") 13 | .replace(/:\d+/g, ":1"); 14 | throw err; 15 | }; 16 | 17 | export default { 18 | "no error": ( 19 |
20 | 21 |

No errors to see here!

22 |
23 |
24 | ), 25 | error: ( 26 | 27 | 28 | 29 | ), 30 | }; 31 | -------------------------------------------------------------------------------- /src/panel/pages/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ErrorBoundary"; 2 | -------------------------------------------------------------------------------- /src/panel/pages/events/Timeline.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from "react"; 2 | import { DebugEvent, gql } from "@urql/core"; 3 | import { TimelineProvider, DevtoolsContext } from "../../context"; 4 | import { Timeline } from "./Timeline"; 5 | 6 | const operation1 = { 7 | key: 1, 8 | kind: "query", 9 | query: gql` 10 | query Users { 11 | users { 12 | id 13 | name 14 | posts { 15 | id 16 | title 17 | } 18 | } 19 | } 20 | `, 21 | }; 22 | 23 | const operation2 = { 24 | key: 2, 25 | kind: "mutation", 26 | query: gql` 27 | mutation AddUser($id: ID!) { 28 | addUser(id: $id) { 29 | id 30 | name 31 | posts { 32 | id 33 | title 34 | } 35 | } 36 | } 37 | `, 38 | variables: { 39 | id: 1234, 40 | }, 41 | }; 42 | 43 | const defaultEvents: DebugEvent[] = [ 44 | { 45 | type: "debug-event", 46 | data: { 47 | type: "execution", 48 | message: "A listener was added to the stream", 49 | operation: operation1, 50 | source: "devtoolsExchange", 51 | }, 52 | }, 53 | { 54 | type: "debug-event", 55 | data: { 56 | type: "fetchRequest", 57 | message: "An update occured", 58 | operation: operation1, 59 | source: "fetchExchange", 60 | }, 61 | }, 62 | { 63 | type: "debug-event", 64 | data: { 65 | type: "update", 66 | message: "This is an update to the operation response / data", 67 | operation: operation1, 68 | source: "devtoolsExchange", 69 | }, 70 | }, 71 | { 72 | type: "debug-event", 73 | data: { 74 | type: "fetchSuccess", 75 | message: "The fetch request succeeded", 76 | operation: operation1, 77 | source: "fetchExchange", 78 | }, 79 | }, 80 | { 81 | type: "debug-event", 82 | data: { 83 | type: "other", 84 | message: "This is an update to the operation response / data", 85 | operation: operation1, 86 | source: "otherExchange", 87 | }, 88 | }, 89 | { 90 | type: "debug-event", 91 | data: { 92 | type: "other", 93 | message: "This is an update to the operation response / data", 94 | operation: operation1, 95 | source: "otherExchange", 96 | }, 97 | }, 98 | { 99 | type: "debug-event", 100 | data: { 101 | type: "error", 102 | message: "This is an update to the operation response / data", 103 | operation: operation1, 104 | source: "fetchExchange", 105 | }, 106 | }, 107 | { 108 | type: "debug-event", 109 | data: { 110 | type: "update", 111 | message: "A listener was added to the stream", 112 | operation: operation2, 113 | source: "fetchExchange", 114 | }, 115 | }, 116 | { 117 | type: "debug-event", 118 | data: { 119 | type: "fetchRequest", 120 | message: "An request was triggered", 121 | operation: operation2, 122 | source: "fetchExchange", 123 | }, 124 | }, 125 | { 126 | type: "debug-event", 127 | data: { 128 | type: "fetchError", 129 | message: "An request errored", 130 | operation: operation2, 131 | source: "fetchExchange", 132 | }, 133 | }, 134 | { 135 | type: "debug-event", 136 | data: { 137 | type: "teardown", 138 | message: "A teardown was triggered on the stream", 139 | operation: operation1, 140 | source: "devtoolsExchange", 141 | }, 142 | }, 143 | { 144 | type: "debug-event", 145 | data: { 146 | type: "update", 147 | message: "An update was triggered on the stream", 148 | operation: operation2, 149 | source: "devtoolsExchange", 150 | }, 151 | }, 152 | ] as any; 153 | 154 | const DevtoolsContextMock: FC<{ events?: typeof defaultEvents }> = ({ 155 | children, 156 | events = defaultEvents, 157 | }) => { 158 | return ( 159 | 162 | ({ 163 | addMessageHandler: (h: any) => { 164 | let i = 0; 165 | 166 | // h({ ...events[i++ % events.length], timestamp: Date.now() }); 167 | const interval = setInterval(() => { 168 | const event = events[i++ % events.length]; 169 | h({ 170 | ...event, 171 | data: { 172 | ...event.data, 173 | timestamp: Date.now(), 174 | }, 175 | }); 176 | i === events.length ? clearInterval(interval) : null; 177 | }, 400); 178 | 179 | return () => clearInterval(interval); 180 | }, 181 | } as any), 182 | [] 183 | )} 184 | > 185 | {children} 186 | 187 | ); 188 | }; 189 | 190 | export default { 191 | empty: ( 192 | () => false } as any} 194 | > 195 | 196 | 197 | 198 | 199 | ), 200 | dynamic: ( 201 | 202 | 203 | 204 | 205 | 206 | ), 207 | }; 208 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/Settings.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, FC } from "react"; 2 | import styled from "styled-components"; 3 | import { TimelineContext } from "../../../context/Timeline"; 4 | import { Settings, Filter } from "./Settings"; 5 | 6 | const Wrapper = styled.div` 7 | padding: 20px; 8 | `; 9 | 10 | const MockTimelineProvider: FC = ({ children }) => { 11 | const [filter, setFilter] = useState({ 12 | source: ["devtoolsExchange"], 13 | graphqlType: ["query", "mutation", "subscription"], 14 | }); 15 | 16 | const value = useMemo( 17 | () => ({ 18 | filter, 19 | setFilter, 20 | filterables: { 21 | source: ["devtoolsExchange", "fetchExchange", "graphCacheExchange"], 22 | graphqlType: ["query", "mutation", "subscription"], 23 | }, 24 | }), 25 | [filter] 26 | ); 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | }; 34 | 35 | export default { 36 | settings: ( 37 | 38 | 39 | 40 | 41 | 42 | ), 43 | filter: ( 44 | 45 | 46 | 47 | 48 | 49 | ), 50 | }; 51 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/Settings.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { act } from "react-dom/test-utils"; 3 | import { mount } from "enzyme"; 4 | import { ThemeDecorator } from "../../../cosmos.decorator"; 5 | import fixtures from "./Settings.fixture"; 6 | 7 | describe("on icon click", () => { 8 | it("expands content", () => { 9 | const wrapper = mount({fixtures.settings}); 10 | wrapper.find("svg").at(0).simulate("click"); 11 | wrapper.update(); 12 | expect(wrapper.find("Content").props()).toHaveProperty("collapsed", false); 13 | }); 14 | }); 15 | 16 | describe("on filter click", () => { 17 | it("enables filters", () => { 18 | const wrapper = mount({fixtures.filter}); 19 | act(() => { 20 | wrapper 21 | .find("FilterButton[aria-selected=false]") 22 | .forEach((b) => b.simulate("click")); 23 | }); 24 | 25 | act(() => { 26 | wrapper.update(); 27 | }); 28 | 29 | wrapper 30 | .find("FilterButton") 31 | .forEach((b) => expect(b.props()).toHaveProperty("aria-selected", true)); 32 | }); 33 | 34 | it("disables filters", () => { 35 | const wrapper = mount({fixtures.filter}); 36 | act(() => { 37 | wrapper 38 | .find("FilterButton[aria-selected=true]") 39 | .forEach((b) => b.simulate("click")); 40 | }); 41 | 42 | act(() => { 43 | wrapper.update(); 44 | }); 45 | 46 | wrapper 47 | .find("FilterButton") 48 | .forEach((b) => expect(b.props()).toHaveProperty("aria-selected", false)); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/Tick.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { Tick } from "./Tick"; 4 | 5 | const Wrapper = styled.div` 6 | margin-left: 20px; 7 | min-width: 100px; 8 | position: relative; 9 | `; 10 | 11 | export default { 12 | basic: ( 13 | 14 | 15 | 16 | ), 17 | }; 18 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/Tick.tsx: -------------------------------------------------------------------------------- 1 | import { rem } from "polished"; 2 | import styled from "styled-components"; 3 | 4 | export const Tick = styled.div<{ label: string }>` 5 | position: absolute; 6 | width: ${rem(2)}; 7 | top: ${(p) => p.theme.space[6]}; 8 | bottom: 0; 9 | 10 | &:before { 11 | content: "${(p) => p.label}"; 12 | font-family: "Roboto"; 13 | font-size: ${(p) => p.theme.fontSizes.body.m}; 14 | color: ${(p) => p.theme.colors.textDimmed.base}; 15 | display: block; 16 | text-align: center; 17 | width: ${rem(100)}; 18 | margin-left: -${rem(50)}; 19 | } 20 | 21 | &:after { 22 | content: ""; 23 | position: absolute; 24 | width: ${rem(2)}; 25 | top: ${rem(25)}; 26 | bottom: 0; 27 | background: ${(p) => p.theme.colors.divider.base}; 28 | opacity: 0.3; 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineDuration.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { 4 | TimelineAliveDuration, 5 | TimelineNetworkDuration, 6 | } from "./TimelineDuration"; 7 | 8 | const Wrapper = styled.div` 9 | padding: 100px; 10 | 11 | > :first-child { 12 | width: 100px; 13 | } 14 | `; 15 | 16 | export default { 17 | "Alive: basic": ( 18 | 19 | 20 | 21 | ), 22 | "Network: fetching": ( 23 | 24 | 25 | 26 | ), 27 | "Network: success": ( 28 | 29 | 30 | 31 | ), 32 | "Network: error": ( 33 | 34 | 35 | 36 | ), 37 | }; 38 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineDuration.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import React, { FC, ComponentProps } from "react"; 3 | import { rem } from "polished"; 4 | import { useTooltip, TimelineTooltip } from "./TimelineTooltip"; 5 | 6 | export const TimelineAliveDuration = styled.div` 7 | height: ${(p) => p.theme.space[6]}; 8 | background: ${(p) => p.theme.colors.canvas.elevated05}; 9 | `; 10 | 11 | type NetworkState = "fetching" | "success" | "error"; 12 | 13 | export const NetworkDuration = styled.div<{ isSelected?: boolean }>` 14 | cursor: pointer; 15 | height: ${rem(10)}; 16 | 17 | outline: ${({ isSelected, theme }) => 18 | isSelected ? `${theme.colors.divider.base} solid 3px` : "none"}; 19 | 20 | &[data-state="fetching"] { 21 | background: ${(p) => p.theme.colors.pending.base}; 22 | } 23 | 24 | &[data-state="success"] { 25 | background: ${(p) => p.theme.colors.success.base}; 26 | } 27 | 28 | &[data-state="error"] { 29 | background: ${(p) => p.theme.colors.error.base}; 30 | } 31 | `; 32 | 33 | export const TimelineNetworkDuration: FC< 34 | { state: NetworkState; isSelected?: boolean } & ComponentProps< 35 | typeof NetworkDuration 36 | > 37 | > = ({ state, ...props }) => { 38 | const { ref, tooltipProps, isVisible } = useTooltip(); 39 | 40 | return ( 41 | <> 42 | 43 | {isVisible && ( 44 | 45 | {`Network state: ${state}`} 46 | 47 | )} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineEvent.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { DebugEvent } from "@urql/core"; 4 | import { TimelineEvent, TimelineEventGroup } from "./TimelineEvent"; 5 | 6 | const Wrapper = styled.div` 7 | padding: 100px; 8 | `; 9 | 10 | const props = { 11 | selectEvent: () => false, 12 | }; 13 | 14 | const executionEvent: DebugEvent = { 15 | type: "execution", 16 | } as any; 17 | 18 | const executionEventWithMessage: DebugEvent = { 19 | ...executionEvent, 20 | message: "currently executing", 21 | } as any; 22 | 23 | const updateEvent: DebugEvent = { 24 | type: "update", 25 | } as any; 26 | 27 | const teardownEvent: DebugEvent = { 28 | type: "teardown", 29 | } as any; 30 | 31 | const otherEvent: DebugEvent = { 32 | type: "abcdefg", 33 | } as any; 34 | 35 | export default { 36 | execution: ( 37 | 38 | 39 | 40 | ), 41 | "execution (with tooltip)": ( 42 | 43 | 48 | 49 | ), 50 | update: ( 51 | 52 | 53 | 54 | ), 55 | teardown: ( 56 | 57 | 58 | 59 | ), 60 | other: ( 61 | 62 | 63 | 64 | ), 65 | group: ( 66 | 67 | 73 | 74 | 75 | 76 | 77 | ), 78 | }; 79 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineEvent.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useMemo, 4 | useState, 5 | ComponentProps, 6 | useCallback, 7 | } from "react"; 8 | import styled from "styled-components"; 9 | import { DebugEvent } from "@urql/core"; 10 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 11 | import { faCaretSquareUp } from "@fortawesome/free-solid-svg-icons"; 12 | import ExecutionIcon from "../../../../assets/events/execution.svg"; 13 | import OtherIcon from "../../../../assets/events/other.svg"; 14 | import TeardownIcon from "../../../../assets/events/teardown.svg"; 15 | import UpdateIcon from "../../../../assets/events/update.svg"; 16 | import { useTooltip, TimelineTooltip } from "./TimelineTooltip"; 17 | 18 | const Svg = styled.svg` 19 | cursor: pointer; 20 | filter: brightness(1); 21 | transition: filter 300ms ease; 22 | 23 | & > * { 24 | fill: ${(p) => p.theme.colors.textDimmed.base}; 25 | } 26 | 27 | &:hover > * { 28 | fill: ${(p) => p.theme.colors.textDimmed.hover}; 29 | } 30 | 31 | &:active > * { 32 | fill: ${(p) => p.theme.colors.textDimmed.active}; 33 | } 34 | `; 35 | 36 | const eventGroupIcon: Record = { 37 | execution: ExecutionIcon, 38 | update: UpdateIcon, 39 | teardown: TeardownIcon, 40 | other: OtherIcon, 41 | }; 42 | 43 | export const TimelineEvent: FC< 44 | { 45 | event: DebugEvent; 46 | } & ComponentProps 47 | > = ({ event, ...svgProps }) => { 48 | const { ref, tooltipProps, isVisible } = useTooltip(); 49 | 50 | const iconSize = useMemo( 51 | () => 52 | Object.keys(eventGroupIcon) 53 | .filter((k) => k !== "other") 54 | .includes(event.type) 55 | ? 12 56 | : 8, 57 | [event.type] 58 | ); 59 | 60 | const Icon = useMemo( 61 | () => eventGroupIcon[event.type] || eventGroupIcon.other, 62 | [] 63 | ); 64 | 65 | return ( 66 | <> 67 | 74 | {isVisible && ( 75 | {event.message} 76 | )} 77 | 78 | ); 79 | }; 80 | 81 | export const TimelineEventGroup: FC> = ({ 82 | children, 83 | ...props 84 | }) => { 85 | const { ref, tooltipProps } = useTooltip(); 86 | const [isExpanded, setExpanded] = useState(false); 87 | 88 | const handleMouseLeave = useCallback(() => setExpanded(false), []); 89 | 90 | return ( 91 | <> 92 | 93 | setExpanded((e) => !e)} 97 | style={{ width: 10, height: 10 }} 98 | /> 99 | 100 | {isExpanded && ( 101 | 102 | {children} 103 | 104 | )} 105 | 106 | ); 107 | }; 108 | 109 | /** Container to get SVG ref :/ */ 110 | const SvgContainer = styled.span` 111 | display: flex; 112 | `; 113 | 114 | const EventPopout = styled.div` 115 | display: flex; 116 | align-items: center; 117 | background-color: ${(p) => p.theme.colors.canvas.elevated05}; 118 | padding: ${(p) => p.theme.space[2]}; 119 | 120 | & > * + * { 121 | margin-left: ${(p) => p.theme.space[2]}; 122 | } 123 | `; 124 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelinePane/TimelinePane.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DebugEvent, gql } from "@urql/core"; 3 | import { TimelineContext } from "../../../../context"; 4 | import { TimelinePane } from "./TimelinePane"; 5 | 6 | const state = { 7 | startTime: 1000, 8 | } as any; 9 | 10 | const mockDebugEvent: DebugEvent = { 11 | timestamp: 1234, 12 | type: "execution", 13 | message: "An operation was executed", 14 | source: "MyComponent", 15 | operation: { 16 | kind: "query", 17 | key: 1, 18 | context: { 19 | requestPolicy: "network-only", 20 | url: "https://example.com/graphql", 21 | }, 22 | query: gql` 23 | query { 24 | todos(id: 1234) { 25 | id 26 | content 27 | } 28 | } 29 | `, 30 | variables: { 31 | myVar: 1234, 32 | }, 33 | }, 34 | data: { 35 | myData: 4321, 36 | }, 37 | }; 38 | 39 | export default { 40 | event: ( 41 | 42 | 43 | 44 | ), 45 | "event (without metadata)": ( 46 | 47 | 55 | 56 | ), 57 | source: ( 58 | 59 | 60 | 61 | ), 62 | "source (without variables)": ( 63 | 64 | 68 | 69 | ), 70 | }; 71 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelinePane/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TimelinePane"; 2 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineRow.test.tsx: -------------------------------------------------------------------------------- 1 | jest.mock("./TimelineEvent", () => ({ 2 | TimelineEvent: () => <>TimelineEvent /* eslint-disable-line */, 3 | })); 4 | import React from "react"; 5 | import { mount } from "enzyme"; 6 | import { ThemeDecorator } from "../../../cosmos.decorator"; 7 | 8 | const dateNow = jest.spyOn(Date, "now"); 9 | 10 | beforeAll(() => { 11 | dateNow.mockReturnValue(3000); 12 | }); 13 | 14 | // This fixture is variable (dependent on Date.now) so we need to snapshot it in jest 15 | describe("on fetching", () => { 16 | describe("network duration", () => { 17 | it("is fetching", async () => { 18 | const { default: fixtures } = await import("./TimelineRow.fixture"); 19 | dateNow.mockReturnValue(5000); 20 | const wrapper = mount( 21 | {fixtures["network fetching"]} 22 | ); 23 | 24 | const duration = wrapper.find("NetworkDuration"); 25 | expect(duration.props()).toHaveProperty("data-state", "fetching"); 26 | }); 27 | 28 | it("grows to current time", async () => { 29 | const { default: fixtures } = await import("./TimelineRow.fixture"); 30 | const wrapper = mount( 31 | {fixtures["network fetching"]} 32 | ); 33 | 34 | const duration = wrapper.find("NetworkDuration"); 35 | expect(duration.props().style).toMatchInlineSnapshot(` 36 | { 37 | "bottom": 0, 38 | "left": 0, 39 | "position": "absolute", 40 | "right": 280, 41 | } 42 | `); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineSourceIcon.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { TimelineSourceIcon } from "./TimelineSourceIcon"; 4 | 5 | const Wrapper = styled.div` 6 | padding: 100px; 7 | `; 8 | 9 | export default { 10 | query: ( 11 | 12 | 13 | 14 | ), 15 | mutation: ( 16 | 17 | 18 | 19 | ), 20 | subscription: ( 21 | 22 | 23 | 24 | ), 25 | }; 26 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineSourceIcon.tsx: -------------------------------------------------------------------------------- 1 | import { rem } from "polished"; 2 | import styled from "styled-components"; 3 | 4 | export const TimelineSourceIcon = styled.div<{ 5 | kind: "query" | "mutation" | "subscription"; 6 | }>` 7 | border-radius: ${(p) => p.theme.radii.s}; 8 | background-color: ${(p) => p.theme.colors.canvas.elevated05}; 9 | color: ${(p) => p.theme.colors.text.base}; 10 | cursor: pointer; 11 | height: ${rem(20)}; 12 | line-height: ${rem(20)}; 13 | text-align: center; 14 | width: ${rem(20)}; 15 | transition: background-color 150ms ease-out; 16 | 17 | :before { 18 | content: "${({ kind }) => kind[0].toUpperCase()}"; 19 | } 20 | 21 | &:hover { 22 | background-color: ${(p) => p.theme.colors.canvas.elevated10}; 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineTooltip.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { TimelineTooltip, useTooltip } from "./TimelineTooltip"; 4 | 5 | const Wrapper = styled.div` 6 | padding: 50px; 7 | `; 8 | 9 | const HoverableItem = () => { 10 | const { ref, tooltipProps, isVisible } = useTooltip(); 11 | 12 | return ( 13 | <> 14 | 26 | {isVisible && Hello!} 27 | 28 | ); 29 | }; 30 | 31 | export default { 32 | basic: ( 33 | 34 | 35 | A network response or cache update 36 | 37 | 38 | ), 39 | onHover: ( 40 | 41 | 42 | 43 | ), 44 | }; 45 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/TimelineTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { rem } from "polished"; 2 | import React, { 3 | useState, 4 | useEffect, 5 | useRef, 6 | useMemo, 7 | useCallback, 8 | FC, 9 | ComponentProps, 10 | } from "react"; 11 | import styled from "styled-components"; 12 | import { Portal } from "../../../components"; 13 | 14 | export const TimelineTooltip: FC = ({ 15 | children, 16 | style: styleProp, 17 | ...props 18 | }) => { 19 | const previousOffset = useRef(0); 20 | const ref = useRef(null); 21 | 22 | const offset = useMemo(() => { 23 | if (!ref.current) { 24 | return 0; 25 | } 26 | 27 | const { left, right } = ref.current.getBoundingClientRect(); 28 | 29 | if (left < 0) { 30 | return Math.abs(left) + 10; 31 | } 32 | 33 | if (right > window.innerWidth) { 34 | return window.innerWidth - right - 10; 35 | } 36 | 37 | return previousOffset.current; 38 | }, [styleProp, ref]); 39 | 40 | previousOffset.current = offset; 41 | 42 | return ( 43 | 44 | 50 | {children} 51 | 52 | 53 | ); 54 | }; 55 | 56 | const TooltipElement = styled.div<{ positionOffset: number }>` 57 | position: relative; 58 | background-color: ${(p) => p.theme.colors.tooltip.background}; 59 | border-radius: ${(p) => p.theme.radii.s}; 60 | color: ${(p) => p.theme.colors.text.base}; 61 | font-size: ${(p) => p.theme.fontSizes.body.m}; 62 | line-height: ${(p) => p.theme.lineHeights.body.m}; 63 | margin: 0; 64 | padding: ${(p) => `${p.theme.space[3]} ${p.theme.space[4]}`}; 65 | white-space: nowrap; 66 | 67 | &::after { 68 | content: ""; 69 | display: block; 70 | position: absolute; 71 | border-top: ${rem(9)} solid ${(p) => p.theme.colors.tooltip.background}; 72 | border-left: ${rem(6)} solid transparent; 73 | border-right: ${rem(6)} solid transparent; 74 | margin-top: -${rem(1)}; 75 | left: calc(50% - ${(p) => rem(p.positionOffset)}); 76 | top: 100%; 77 | transform: translate(-50%, 0); 78 | } 79 | `; 80 | 81 | export const useTooltip = () => { 82 | const ref = useRef(); 83 | const mouseX = useRef(undefined); 84 | const [hasRef, setHasRef] = useState(false); 85 | const [isVisible, setIsVisible] = useState(false); 86 | const [tooltipProps, setTooltipProps] = useState< 87 | Omit, "ref"> 88 | >({}); 89 | 90 | const calculateTooltipPosition = useCallback(() => { 91 | if (!ref.current) { 92 | return; 93 | } 94 | 95 | const { x, y, width } = ref.current.getBoundingClientRect(); 96 | 97 | setTooltipProps({ 98 | style: { 99 | position: "fixed", 100 | // 12px for event size 101 | left: width > 12 ? mouseX.current : x + width / 2, 102 | bottom: window.innerHeight - y + 10, 103 | transform: `translateX(-50%)`, 104 | }, 105 | }); 106 | }, []); 107 | 108 | const handleTargetRef = useMemo(() => { 109 | const fn = (r: null | HTMLElement) => { 110 | if (r === null) { 111 | return; 112 | } 113 | 114 | ref.current = r; 115 | calculateTooltipPosition(); 116 | setHasRef(true); 117 | }; 118 | fn.current = ref.current; 119 | return fn; 120 | }, []); 121 | 122 | // Update position on resize 123 | useEffect(() => { 124 | if (!ref.current) { 125 | return; 126 | } 127 | 128 | const mObserver = new MutationObserver(calculateTooltipPosition); 129 | const rObserver = new ResizeObserver(calculateTooltipPosition); 130 | mObserver.observe(ref.current, { 131 | attributes: true, 132 | childList: true, 133 | }); 134 | rObserver.observe(ref.current); 135 | return () => { 136 | mObserver.disconnect(); 137 | rObserver.disconnect(); 138 | }; 139 | }, [hasRef]); 140 | 141 | // Set visible on mouse enter 142 | useEffect(() => { 143 | if (!ref.current) { 144 | return; 145 | } 146 | 147 | const handleMouseEnter = (e: MouseEvent) => { 148 | handleMouseMove(e); 149 | setIsVisible(true); 150 | }; 151 | const setInvisible = () => setIsVisible(false); 152 | const handleMouseMove = (e: MouseEvent) => { 153 | mouseX.current = e.clientX; 154 | calculateTooltipPosition(); 155 | }; 156 | ref.current.addEventListener("mouseenter", handleMouseEnter); 157 | ref.current.addEventListener("mouseleave", setInvisible); 158 | ref.current.addEventListener("mousemove", handleMouseMove); 159 | return () => { 160 | if (!ref.current) { 161 | return; 162 | } 163 | 164 | ref.current.removeEventListener("mouseenter", handleMouseEnter); 165 | ref.current.removeEventListener("mouseleave", setInvisible); 166 | ref.current.removeEventListener("mousemove", handleMouseMove); 167 | }; 168 | }, [hasRef]); 169 | 170 | return useMemo( 171 | () => ({ 172 | ref: handleTargetRef, 173 | tooltipProps, 174 | isVisible, 175 | }), 176 | [handleTargetRef, tooltipProps, isVisible] 177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /src/panel/pages/events/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TimelineRow"; 2 | export * from "./Tick"; 3 | export * from "./TimelinePane"; 4 | export * from "./TimelineSourceIcon"; 5 | export * from "./Settings"; 6 | -------------------------------------------------------------------------------- /src/panel/pages/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Timeline"; 2 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/Explorer.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from "react"; 2 | import { gql } from "@urql/core"; 3 | import { ExchangeDebugEventMessage } from "@urql/devtools"; 4 | import { 5 | DevtoolsContext, 6 | ExplorerProvider, 7 | DevtoolsContextType, 8 | } from "../../context"; 9 | import { Explorer } from "./Explorer"; 10 | 11 | export const defaultEvents: ExchangeDebugEventMessage[] = [ 12 | { 13 | type: "debug-event", 14 | source: "exchange", 15 | data: { 16 | type: "update", 17 | message: "Todo message", 18 | source: "MyComponent", 19 | timestamp: Date.now(), 20 | operation: { 21 | key: 12345, 22 | kind: "query", 23 | variables: { 24 | name: "carl", 25 | address: { 26 | postcode: "E1", 27 | }, 28 | other: { 29 | postcode: "E1", 30 | }, 31 | other2: { 32 | postcode: "E1", 33 | }, 34 | }, 35 | query: gql` 36 | query getTodos($name: String!, $address: Address!) { 37 | todos( 38 | id: 1234 39 | name: $name 40 | address: $address 41 | otherArg: "really long string to cause overflow" 42 | finalArg: "Other long arg to cause overflow" 43 | ) { 44 | id 45 | content 46 | __typename 47 | } 48 | } 49 | `, 50 | context: { 51 | url: "http://asdsad", 52 | requestPolicy: "cache-and-network", 53 | meta: { 54 | source: "MyComponent", 55 | }, 56 | }, 57 | }, 58 | data: { 59 | value: { 60 | todos: [ 61 | { 62 | id: 1234, 63 | content: "My todo", 64 | __typename: "Todo", 65 | }, 66 | { 67 | id: 5678, 68 | content: "My other todo", 69 | __typename: "Todo", 70 | }, 71 | ], 72 | }, 73 | }, 74 | }, 75 | }, 76 | ]; 77 | 78 | const DevtoolsContextMock: FC< 79 | { events?: typeof defaultEvents } & Partial 80 | > = ({ children, events = defaultEvents, ...val }) => { 81 | const value = useMemo( 82 | () => ({ 83 | addMessageHandler: (h) => { 84 | events.forEach(h); 85 | return () => false; 86 | }, 87 | client: { 88 | connected: true, 89 | version: { 90 | mismatch: false, 91 | required: "", 92 | actual: "", 93 | }, 94 | }, 95 | sendMessage: () => false, 96 | ...val, 97 | }), 98 | [] 99 | ); 100 | 101 | return ( 102 | 103 | {children} 104 | 105 | ); 106 | }; 107 | 108 | export default { 109 | basic: ( 110 | 111 | 112 | 113 | ), 114 | updating: ( 115 | { 117 | const event = defaultEvents[0]; 118 | let content = 1; 119 | const update = () => { 120 | const updatedDebugMessage = { 121 | ...event, 122 | data: { 123 | ...event.data, 124 | data: { 125 | value: { 126 | todos: [ 127 | { 128 | id: 123, 129 | content: content.toString(), 130 | __typename: "Todo", 131 | }, 132 | ], 133 | }, 134 | }, 135 | }, 136 | }; 137 | 138 | h(updatedDebugMessage); 139 | }; 140 | 141 | update(); 142 | setInterval(() => { 143 | update(); 144 | content += 1; 145 | }, 1500); 146 | 147 | return () => false; 148 | }} 149 | > 150 | 151 | 152 | ), 153 | }; 154 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/Explorer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, FC, ComponentProps } from "react"; 2 | import styled from "styled-components"; 3 | import { Background } from "../../components"; 4 | import { ExplorerContext } from "../../context"; 5 | import { Tree, NodeInfoPane } from "./components"; 6 | 7 | export const Explorer: FC> = (props) => { 8 | const { operations } = useContext(ExplorerContext); 9 | 10 | return ( 11 | 12 | 13 | {Object.keys(operations).length ? ( 14 | 15 | ) : ( 16 | 17 | Responses will be shown here 18 | Make a new request or refresh the page 19 | 20 | )} 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | const Container = styled(Background)` 28 | background-color: ${(p) => p.theme.colors.canvas.base}; 29 | `; 30 | 31 | const ListContainer = styled.section` 32 | flex: 2; 33 | flex-basis: 70%; 34 | overflow: auto; 35 | `; 36 | 37 | const TitleWrapper = styled.div` 38 | padding: ${(p) => p.theme.space[6]}; 39 | color: ${(p) => p.theme.colors.textDimmed.base}; 40 | font-weight: normal; 41 | `; 42 | 43 | const Title = styled.h2` 44 | font-weight: normal; 45 | font-size: ${(p) => p.theme.fontSizes.body.xl}; 46 | line-height: ${(p) => p.theme.lineHeights.body.xl}; 47 | color: ${(p) => p.theme.colors.text.base}; 48 | margin: 0; 49 | `; 50 | 51 | const Description = styled.p` 52 | margin-top: ${(p) => p.theme.space[3]}; 53 | `; 54 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/Arguments.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { Arguments } from "./Arguments"; 4 | 5 | const Wrapper = styled.div``; 6 | 7 | export default { 8 | empty: ( 9 | 10 | 11 | 12 | ), 13 | basic: ( 14 | 15 | 25 | 26 | ), 27 | }; 28 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/Arguments.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo, Fragment, ComponentProps } from "react"; 2 | import styled from "styled-components"; 3 | import { ParsedFieldNode } from "../../../context/Explorer/ast"; 4 | import { InlineCodeHighlight } from "../../../components"; 5 | 6 | export const Arguments: FC< 7 | { 8 | args?: ParsedFieldNode["args"]; 9 | } & ComponentProps 10 | > = ({ args, ...props }) => { 11 | if (!args) { 12 | return null; 13 | } 14 | 15 | const entries = useMemo(() => Object.entries(args), [args]); 16 | 17 | return ( 18 | 19 | ( 20 | {entries.map(([key, value], index) => ( 21 | 22 | {`${key}: `} 23 | 27 | {index !== entries.length - 1 && ", "} 28 | 29 | ))} 30 | ) 31 | 32 | ); 33 | }; 34 | 35 | const ArgumentText = styled.div` 36 | flex-basis: 0; 37 | flex-grow: 1; 38 | color: ${(p) => p.theme.colors.text.base}; 39 | `; 40 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import { useTheme } from "styled-components"; 3 | 4 | export const SeeMoreIcon: FC = (props) => ( 5 | 6 | 7 | 17 | 21 | 22 | ); 23 | 24 | export const CacheOutcomeIcon: FC< 25 | JSX.IntrinsicElements["svg"] & { 26 | state?: "hit" | "miss" | "partial"; 27 | } 28 | > = ({ state, ...props }) => { 29 | const { colors } = useTheme(); 30 | const fillColor = colors.secondary.base; 31 | 32 | return ( 33 | 34 | 40 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useContext, 3 | useMemo, 4 | useCallback, 5 | FC, 6 | useEffect, 7 | useRef, 8 | } from "react"; 9 | import { animated } from "react-spring"; 10 | import styled from "styled-components"; 11 | import { ParsedFieldNode } from "../../../context/Explorer/ast"; 12 | import { ExplorerContext } from "../../../context"; 13 | import { useFlash } from "../hooks"; 14 | import { InlineCodeHighlight, Arrow } from "../../../components"; 15 | import { Arguments } from "./Arguments"; 16 | import { Tree } from "./Tree"; 17 | 18 | interface ListItemProps { 19 | node: ParsedFieldNode; 20 | depth?: number; 21 | } 22 | 23 | export const ListItem: FC = ({ node, depth = 0 }) => { 24 | const previousNode = useRef(node); 25 | const [flashStyle, flash] = useFlash(); 26 | const { expandedNodes, setExpandedNodes, setFocusedNode } = useContext( 27 | ExplorerContext 28 | ); 29 | const isExpanded = useMemo( 30 | () => expandedNodes.some((n) => n._id === node._id), 31 | [node, expandedNodes] 32 | ); 33 | 34 | useEffect(() => { 35 | // Child changed 36 | if (!isExpanded && previousNode.current.children !== node.children) { 37 | flash(); 38 | } 39 | 40 | // Value changed 41 | if ( 42 | !previousNode.current.children && 43 | previousNode.current.value !== node.value 44 | ) { 45 | flash(); 46 | } 47 | 48 | previousNode.current = node; 49 | }, [isExpanded, flash, node]); 50 | 51 | const handleFieldContainerClick = useCallback(() => { 52 | if (isExpanded) { 53 | setExpandedNodes((n) => 54 | n.slice( 55 | 0, 56 | n.findIndex((n) => n._id === node._id) 57 | ) 58 | ); 59 | setFocusedNode(undefined); 60 | return; 61 | } 62 | 63 | setExpandedNodes((n) => [...n, node]); 64 | setFocusedNode(node); 65 | }, [isExpanded, node, setExpandedNodes]); 66 | 67 | if ( 68 | (Array.isArray(node.children) && node.children.length > 0) || 69 | node.children 70 | ) { 71 | return ( 72 | 73 | 78 | 79 | {node.name} 80 | 81 | 82 | {isExpanded && } 83 | 84 | ); 85 | } 86 | 87 | const contents = ( 88 | 89 | {node.name} 90 | {": "} 91 | 92 | 96 | 97 | 98 | ); 99 | 100 | if (node.args) { 101 | return ( 102 | 103 | 104 | {contents} 105 | 106 | 107 | ); 108 | } 109 | return ( 110 | 111 | {contents} 112 | 113 | ); 114 | }; 115 | 116 | const ListItemKeyVal = styled.div` 117 | margin: 0; 118 | `; 119 | 120 | export const SystemListItem: React.FC<{ 121 | node: ParsedFieldNode; 122 | index?: number; 123 | }> = ({ node, index }) => ( 124 | 125 | 126 | {`${node.value}`} 127 | {typeof index === "number" ? ` #${index}` : null} 128 | 129 | 130 | ); 131 | 132 | const Item = styled.li<{ withChildren: boolean }>` 133 | padding-left: ${({ theme, withChildren }) => 134 | withChildren ? "0" : theme.space[4]}; 135 | font-size: ${(p) => p.theme.fontSizes.body.m}; 136 | line-height: ${(p) => p.theme.lineHeights.body.m}; 137 | color: ${(p) => p.theme.colors.textDimmed.base}; 138 | 139 | & + & { 140 | margin-top: ${(p) => p.theme.space[2]}; 141 | } 142 | `; 143 | 144 | const OutlineContainer = styled(animated.div)` 145 | cursor: pointer; 146 | display: flex; 147 | white-space: nowrap; 148 | overflow: hidden; 149 | align-items: baseline; 150 | width: 100%; 151 | `; 152 | 153 | const Name = styled.span` 154 | color: ${(p) => p.theme.colors.text.base}; 155 | `; 156 | 157 | const ChildrenName = styled.span` 158 | flex-shrink: 0; 159 | margin-right: ${(p) => p.theme.space[2]}; 160 | color: ${(p) => p.theme.colors.text.base}; 161 | font-weight: bold; 162 | font-size: ${(p) => p.theme.fontSizes.body.m}; 163 | line-height: ${(p) => p.theme.lineHeights.body.m}; 164 | `; 165 | 166 | const Typename = styled.div` 167 | display: inline-block; 168 | padding: ${(p) => `${p.theme.space[1]} ${p.theme.space[2]}`}; 169 | border: 1px solid ${(p) => `${p.theme.colors.divider.base}`}; 170 | border-radius: ${(p) => p.theme.radii.s}; 171 | background-color: ${(p) => p.theme.colors.canvas.elevated05}; 172 | color: ${(p) => p.theme.colors.text.base}; 173 | font-size: ${(p) => p.theme.fontSizes.body.s}; 174 | line-height: ${(p) => p.theme.lineHeights.body.s}; 175 | `; 176 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/NodeInfoPane.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ExplorerContext } from "../../../context"; 3 | import { NodeInfoPane } from "./NodeInfoPane"; 4 | 5 | const explorerContextValue = { 6 | operations: {}, 7 | expandedNodes: [] as any, 8 | setExpandedNodes: () => false, 9 | setFocusedNode: () => false, 10 | focusedNode: { 11 | _id: "1234", 12 | _owner: {}, 13 | name: "Hello", 14 | key: "1234", 15 | }, 16 | } as const; 17 | 18 | export default { 19 | empty: ( 20 | 23 | 24 | 25 | ), 26 | "cache hit": ( 27 | 36 | 37 | 38 | ), 39 | "cache miss": ( 40 | 49 | 50 | 51 | ), 52 | "cache partial": ( 53 | 62 | 63 | 64 | ), 65 | "with args": ( 66 | 78 | 79 | 80 | ), 81 | "with value (object)": ( 82 | 93 | 94 | 95 | ), 96 | "with value (string)": ( 97 | 106 | 107 | 108 | ), 109 | "with children": ( 110 | 124 | 125 | 126 | ), 127 | }; 128 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/NodeInfoPane.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useContext, 4 | useMemo, 5 | ComponentProps, 6 | useState, 7 | useCallback, 8 | useRef, 9 | } from "react"; 10 | import styled from "styled-components"; 11 | import { rem } from "polished"; 12 | import { ParsedFieldNode } from "../../../context/Explorer/ast"; 13 | import { Pane, CodeHighlight } from "../../../components"; 14 | import { ExplorerContext } from "../../../context"; 15 | import { CacheOutcomeIcon } from "./Icons"; 16 | 17 | export const NodeInfoPane: FC> = (props) => { 18 | const { focusedNode } = useContext(ExplorerContext); 19 | 20 | const content = useMemo(() => { 21 | if (!focusedNode) { 22 | return ( 23 | 24 | Select a node to see more information... 25 | 26 | ); 27 | } 28 | 29 | return ; 30 | }, [focusedNode]); 31 | 32 | return ( 33 | 34 | {content} 35 | 36 | ); 37 | }; 38 | 39 | const NodeInfoContent: FC<{ node: ParsedFieldNode }> = ({ node }) => { 40 | const previousNode = useRef(node); 41 | const [expanded, setExpanded] = useState(false); 42 | 43 | const value = useMemo( 44 | () => 45 | node.value || node.children 46 | ? JSON.stringify(node.value || node.children, null, 2) 47 | : false, 48 | [node.value, node.children] 49 | ); 50 | 51 | const isExpanded = useMemo( 52 | () => 53 | (value && value.length < 10000) || 54 | (expanded && previousNode.current._id == node._id), 55 | [node._id, expanded, value] 56 | ); 57 | 58 | const handleReveal = useCallback(() => setExpanded(true), []); 59 | 60 | previousNode.current = node; 61 | 62 | return ( 63 | <> 64 | 65 | Name 66 | {node.name} 67 | 68 | {node.cacheOutcome ? ( 69 | 70 | Cache Outcome 71 | 72 | {node.cacheOutcome} 73 | {getDescription(node.cacheOutcome)} 74 | 75 | ) : null} 76 | {node.args ? ( 77 | 78 | Arguments 79 | 83 | 84 | ) : null} 85 | {value ? ( 86 | 87 | Value 88 | {isExpanded ? ( 89 | 90 | ) : ( 91 | 92 | Click to expand 93 | 94 | )} 95 | 96 | ) : null} 97 | 98 | ); 99 | }; 100 | 101 | const getDescription = (status: ParsedFieldNode["cacheOutcome"]) => { 102 | switch (status) { 103 | case "hit": { 104 | return {"This result was served from cache."}; 105 | } 106 | case "partial": { 107 | return ( 108 | 109 | {"Some values for this result were served from cache."} 110 | 111 | ); 112 | } 113 | case "miss": { 114 | return ( 115 | {"This result wasn't served from cache"} 116 | ); 117 | } 118 | default: { 119 | return null; 120 | } 121 | } 122 | }; 123 | 124 | const ExpandPrompt = styled.div` 125 | text-align: center; 126 | padding: ${(p) => p.theme.space[5]}; 127 | background: ${(p) => p.theme.colors.canvas.elevated05}; 128 | color: ${(p) => p.theme.colors.textDimmed.base}; 129 | cursor: pointer; 130 | `; 131 | 132 | const Name = styled.code` 133 | color: ${(p) => p.theme.colors.textDimmed.base}; 134 | `; 135 | 136 | const Description = styled.p` 137 | color: ${(p) => p.theme.colors.textDimmed.base}; 138 | margin-bottom: 0; 139 | margin-top: ${(p) => p.theme.space[2]}; 140 | `; 141 | 142 | const TextContainer = styled.div` 143 | padding: ${(p) => p.theme.space[6]}; 144 | `; 145 | 146 | const Text = styled.p` 147 | margin: 0; 148 | text-align: center; 149 | color: ${(p) => p.theme.colors.textDimmed.base}; 150 | `; 151 | 152 | const CacheIcon = styled(CacheOutcomeIcon)` 153 | position: relative; 154 | top: ${rem(1)}; 155 | margin-right: ${(p) => p.theme.space[3]}; 156 | `; 157 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/Tree.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | import { ParsedNodeMap, ParsedFieldNode } from "../../../context/Explorer/ast"; 4 | import { ListItem, SystemListItem } from "./ListItem"; 5 | 6 | interface TreeProps { 7 | nodeMap: ParsedNodeMap | (ParsedNodeMap | null)[] | undefined; 8 | depth?: number; 9 | index?: number; 10 | } 11 | 12 | export const Tree: FC = ({ nodeMap, depth = 0, index }) => { 13 | if (!nodeMap || (Array.isArray(nodeMap) && nodeMap.length === 0)) { 14 | return null; 15 | } 16 | 17 | if (Array.isArray(nodeMap)) { 18 | return ( 19 | <> 20 | {nodeMap.map( 21 | (map, index) => 22 | map && ( 23 | 24 | ) 25 | )} 26 | 27 | ); 28 | } 29 | 30 | const fields = Object.values(nodeMap); 31 | const typenameField = fields.find((x) => x.name === "__typename"); 32 | const childrenFields = sortFields( 33 | fields.filter((x) => x.children !== undefined) 34 | ); 35 | const scalarFields = sortFields( 36 | fields.filter((x) => x.children === undefined && x.name !== "__typename") 37 | ); 38 | const role = depth === 0 ? "tree" : "group"; 39 | 40 | return ( 41 | 42 | {typenameField && } 43 | {[...scalarFields, ...childrenFields].map((node) => ( 44 | 45 | ))} 46 | 47 | ); 48 | }; 49 | 50 | const sortFields = (nodes: ParsedFieldNode[]) => { 51 | return nodes.sort((a, b) => { 52 | if (a.name === "id") { 53 | return -nodes.length; 54 | } 55 | 56 | if (b.name === "id") { 57 | return nodes.length; 58 | } 59 | 60 | return a.name.localeCompare(b.name); 61 | }); 62 | }; 63 | 64 | const List = styled.ul` 65 | margin: 0; 66 | padding: ${(p) => p.theme.space[3]}; 67 | margin-left: ${(p) => p.theme.space[2]}; 68 | border-left: 3px solid ${(p) => p.theme.colors.divider.base}; 69 | list-style: none; 70 | font-size: ${(p) => p.theme.fontSizes.body.l}; 71 | line-height: ${(p) => p.theme.lineHeights.body.l}; 72 | color: ${(p) => p.theme.colors.textDimmed.base}; 73 | 74 | &:last-of-type { 75 | margin-bottom: 0; 76 | } 77 | 78 | &[role="tree"] { 79 | border-left: none; 80 | 81 | & > li { 82 | border-left: none; 83 | padding: 0; 84 | } 85 | } 86 | `; 87 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./NodeInfoPane"; 2 | export * from "./Tree"; 3 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useFlash"; 2 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/hooks/useFlash.ts: -------------------------------------------------------------------------------- 1 | import { useSpring } from "react-spring"; 2 | import { useCallback } from "react"; 3 | 4 | const defaultState = { background: "rgba(255, 255, 255, 0)" }; 5 | const flashState = { background: "rgba(255, 255, 255, 1)" }; 6 | 7 | type Flash = () => void; 8 | 9 | type UseFlashResponse = [React.CSSProperties, Flash]; 10 | 11 | /** Hook for flashing a screen element */ 12 | export const useFlash = (): UseFlashResponse => { 13 | const [props, setSpring] = useSpring(() => ({ 14 | config: { duration: 300 }, 15 | })); 16 | 17 | const flash = useCallback( 18 | () => 19 | setSpring({ 20 | from: defaultState, 21 | to: [flashState, defaultState], 22 | } as any), 23 | [setSpring] 24 | ); 25 | 26 | return [props as React.CSSProperties, flash]; 27 | }; 28 | -------------------------------------------------------------------------------- /src/panel/pages/explorer/index.ts: -------------------------------------------------------------------------------- 1 | export { Explorer } from "./Explorer"; 2 | -------------------------------------------------------------------------------- /src/panel/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./disconnected"; 2 | export * from "./error"; 3 | export * from "./events"; 4 | export * from "./explorer"; 5 | export * from "./mismatch"; 6 | export * from "./request"; 7 | -------------------------------------------------------------------------------- /src/panel/pages/mismatch/Mismatch.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import { DevtoolsContext } from "../../context"; 3 | import { Mismatch } from "./Mismatch"; 4 | 5 | const MockProvider: FC = (props) => ( 6 | 17 | ); 18 | 19 | export default { 20 | basic: ( 21 | 22 | 23 | 24 | ), 25 | }; 26 | -------------------------------------------------------------------------------- /src/panel/pages/mismatch/Mismatch.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ComponentProps } from "react"; 2 | import styled from "styled-components"; 3 | import { rem } from "polished"; 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 | import { faBomb } from "@fortawesome/free-solid-svg-icons"; 6 | import { CodeHighlight } from "../../components"; 7 | import { useDevtoolsContext } from "../../context"; 8 | 9 | export const Mismatch: FC> = (props) => { 10 | const { client } = useDevtoolsContext(); 11 | 12 | if (!client.connected) { 13 | return null; 14 | } 15 | 16 | return ( 17 | 18 | 19 | 20 |
Version Mismatch
21 | 22 | Expected devtools exchange (@urql/devtools) version{" "} 23 | {`>=${client.version.required}`} but got{" "} 24 | {`${client.version.actual}.`} 25 | 26 |
27 | 28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | const code = `\ 35 | # Yarn 36 | yarn add @urql/devtools 37 | 38 | # Npm 39 | npm update @urql/devtools 40 | `; 41 | 42 | const Content = styled.div` 43 | display: flex; 44 | flex-direction: column; 45 | align-items: center; 46 | width: ${rem(300)}; 47 | margin: ${(p) => p.theme.space[3]}; 48 | `; 49 | 50 | const Container = styled.div` 51 | width: 100%; 52 | height: 100%; 53 | background: ${(p) => p.theme.colors.canvas.base}; 54 | display: flex; 55 | flex-direction: column; 56 | justify-content: center; 57 | align-items: center; 58 | overflow: auto; 59 | 60 | @media (min-width: 768px) { 61 | flex-direction: row; 62 | 63 | & > ${Content} { 64 | margin: ${(p) => p.theme.space[6]}; 65 | } 66 | } 67 | `; 68 | 69 | const Header = styled.h1` 70 | color: ${(p) => p.theme.colors.text.base}; 71 | font-weight: 400; 72 | margin: 0; 73 | `; 74 | 75 | const Hint = styled.p` 76 | text-align: center; 77 | color: ${(p) => p.theme.colors.textDimmed.base}; 78 | `; 79 | 80 | const Icon = styled(FontAwesomeIcon)` 81 | font-size: ${(p) => p.theme.fontSizes.display.l}; 82 | margin-bottom: ${(p) => p.theme.space[9]}; 83 | color: ${(p) => p.theme.colors.error.base}; 84 | `; 85 | 86 | const Code = styled(CodeHighlight)` 87 | width: 100%; 88 | box-sizing: border-box; 89 | `; 90 | -------------------------------------------------------------------------------- /src/panel/pages/mismatch/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Mismatch"; 2 | -------------------------------------------------------------------------------- /src/panel/pages/request/Request.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ContextType, useState, useMemo } from "react"; 2 | import { buildSchema } from "graphql"; 3 | import { RequestContext } from "../../context"; 4 | import { Request } from "./Request"; 5 | 6 | export const schema = buildSchema(` 7 | directive @populate on FIELD 8 | 9 | type Post { 10 | id: ID 11 | title: String! 12 | content: String! 13 | } 14 | 15 | type Query { 16 | posts: [Post] 17 | } 18 | 19 | type Mutation { 20 | createPost(title: String!, content: String!): Post! 21 | } 22 | `); 23 | 24 | const RequestProviderMock: FC>> = ({ 25 | children, 26 | ...value 27 | }) => { 28 | const [query, setQuery] = useState(""); 29 | 30 | const state = useMemo( 31 | () => ({ 32 | query, 33 | setQuery, 34 | error: undefined, 35 | execute: () => null as any, 36 | fetching: false, 37 | response: undefined, 38 | schema, 39 | ...value, 40 | }), 41 | [value] 42 | ); 43 | 44 | return ; 45 | }; 46 | 47 | export default { 48 | basic: ( 49 | 50 | 51 | 52 | ), 53 | fetching: ( 54 | 55 | 56 | 57 | ), 58 | error: ( 59 | 60 | 61 | 62 | ), 63 | response: ( 64 | 65 | 66 | 67 | ), 68 | }; 69 | -------------------------------------------------------------------------------- /src/panel/pages/request/Request.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps, FC } from "react"; 2 | import styled from "styled-components"; 3 | import { Background } from "../../components/Background"; 4 | import { Pane } from "../../components"; 5 | import { Query, Schema, Settings, Response } from "./components"; 6 | 7 | export const Request: FC> = (props) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | {/* {TODO: Hack to offset the panels so they aren't on top of each other. 14 | There's definitely better way to do this */} 15 | 16 | 17 | 18 | 19 | 20 | 21 | {/* {TODO: Also a hack to make these panes work together, sorry! */} 22 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | const PaneSection = styled.section` 35 | color: ${(p) => p.theme.colors.text.base}; 36 | background: ${(p) => p.theme.colors.canvas.base}; 37 | overflow: auto; 38 | flex-grow: 1; 39 | flex-basis: 0; 40 | 41 | h1 { 42 | background-color: ${(p) => p.theme.colors.text.base}; 43 | position: sticky; 44 | top: ${(p) => `-${p.theme.space[6]}`}; 45 | margin: ${(p) => `-${p.theme.space[6]}`}; 46 | padding: ${(p) => `${p.theme.space[1]} ${p.theme.space[3]}`}; 47 | font-size: ${(p) => p.theme.fontSizes.body.m}; 48 | line-height: ${(p) => p.theme.lineHeights.body.m}; 49 | font-weight: 400; 50 | border-bottom: solid 1px ${(p) => p.theme.colors.divider.base}; 51 | z-index: 1; 52 | } 53 | 54 | h1 + * { 55 | margin-top: ${(p) => p.theme.space[8]}; 56 | } 57 | `; 58 | 59 | const Page = styled(Background)` 60 | background-color: ${(p) => p.theme.colors.canvas.base}; 61 | @media (min-aspect-ratio: 1/1) { 62 | flex-direction: column; 63 | } 64 | `; 65 | 66 | const SchemaContainer = styled(Pane)` 67 | & > div { 68 | min-width: 100%; 69 | width: 100%; 70 | } 71 | `; 72 | 73 | const PageContent = styled.div` 74 | overflow: hidden; 75 | display: flex; 76 | flex-direction: column; 77 | flex-grow: 1; 78 | 79 | @media (min-aspect-ratio: 1/1) { 80 | flex-direction: row; 81 | } 82 | 83 | .CodeMirror { 84 | font-size: ${(p) => p.theme.fontSizes.body.m}; 85 | height: auto; 86 | width: 100%; 87 | height: 100%; 88 | display: flex; 89 | flex-direction: column; 90 | flex-grow: 1; 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /src/panel/pages/request/components/Collapsible.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactChild } from "react"; 2 | import styled from "styled-components"; 3 | import { Arrow } from "../../../components"; 4 | 5 | interface CollapsibleProps { 6 | title: string; 7 | isActive: boolean; 8 | children: ReactChild; 9 | onClick: () => void; 10 | } 11 | 12 | export const Collapsible: FC = ({ 13 | title, 14 | isActive, 15 | children, 16 | onClick, 17 | }) => { 18 | return ( 19 | <> 20 | 21 | 22 | {title} 23 | 24 | {isActive &&
{children}
} 25 | 26 | ); 27 | }; 28 | 29 | const CollapsibleHeader = styled.button` 30 | display: flex; 31 | align-items: center; 32 | width: 100%; 33 | color: ${(p) => p.theme.colors.textDimmed.base}; 34 | border-top: 1px solid ${(p) => p.theme.colors.divider.base}; 35 | border-bottom: 1px solid ${(p) => p.theme.colors.divider.base}; 36 | font-size: ${(p) => p.theme.fontSizes.body.m}; 37 | line-height: ${(p) => p.theme.lineHeights.body.m}; 38 | padding: ${(p) => p.theme.space[2]}; 39 | 40 | &:hover { 41 | background: ${(p) => p.theme.colors.canvas.hover}; 42 | } 43 | 44 | &:focus { 45 | background: ${(p) => p.theme.colors.canvas.active}; 46 | outline: none; 47 | } 48 | 49 | & + & { 50 | border-top: 0; 51 | } 52 | `; 53 | -------------------------------------------------------------------------------- /src/panel/pages/request/components/Fields.fixture.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { schema } from "./Schema.fixture"; 3 | import { Fields } from "./Fields"; 4 | import { Box } from "./Stack"; 5 | 6 | const typeMap = schema.getTypeMap(); 7 | const setTypeMock = (type: any) => console.log(type); 8 | 9 | const Objects = () => ( 10 | 11 |

Basic

12 | 13 |

Arg and Field Descriptions

14 | 15 |

Default args

16 | 17 |
18 | ); 19 | 20 | export default { 21 | object: , 22 | interface: ( 23 | 24 | 25 | 26 | ), 27 | enum: ( 28 | 29 | 30 | 31 | ), 32 | union: ( 33 | 34 | 35 | 36 | ), 37 | input: ( 38 | 39 | 44 | 45 | ), 46 | }; 47 | -------------------------------------------------------------------------------- /src/panel/pages/request/components/Query.tsx: -------------------------------------------------------------------------------- 1 | import "codemirror/lib/codemirror"; 2 | import "codemirror/lib/codemirror.css"; 3 | import "codemirror/addon/hint/show-hint"; 4 | import "codemirror/addon/edit/closebrackets"; 5 | import "codemirror/addon/edit/matchbrackets"; 6 | import "codemirror/addon/hint/show-hint.css"; 7 | import "codemirror/addon/lint/lint"; 8 | import "codemirror/addon/lint/lint.css"; 9 | import "codemirror-graphql/lint"; 10 | import "codemirror-graphql/hint"; 11 | import "codemirror-graphql/mode"; 12 | import CodeMirror, { ShowHintOptions, LintStateOptions } from "codemirror"; 13 | import React, { useEffect, useState } from "react"; 14 | import styled from "styled-components"; 15 | import { useRequest } from "../../../context"; 16 | 17 | /** Query editor 18 | * Inspired by Graphiql's query editor - https://github.com/graphql/graphiql/blob/master/packages/graphiql/src/components/QueryEditor.js 19 | */ 20 | export const Query: React.FC = () => { 21 | const [codemirror, setCodeMirror] = useState(); 22 | const { query, setQuery, execute, schema } = useRequest(); 23 | 24 | useEffect(() => { 25 | if (codemirror === undefined) { 26 | return; 27 | } 28 | 29 | codemirror.setOption("extraKeys", { 30 | ...(codemirror.getOption("extraKeys") as CodeMirror.KeyMap), 31 | "Ctrl-Enter": execute, 32 | "Cmd-Enter": execute, 33 | }); 34 | }, [codemirror, execute]); 35 | 36 | // Update on schema change 37 | useEffect(() => { 38 | if (codemirror === undefined || schema === undefined) { 39 | return; 40 | } 41 | 42 | // TODO!: Update types 43 | codemirror.setOption("lint", ({ schema } as unknown) as LintStateOptions); 44 | codemirror.setOption("hintOptions", ({ 45 | schema, 46 | } as unknown) as ShowHintOptions); 47 | codemirror.setOption("extraKeys", { 48 | "Ctrl-Space": () => 49 | codemirror.showHint(({ 50 | completeSingle: true, 51 | } as unknown) as ShowHintOptions), 52 | }); 53 | }, [codemirror, schema]); 54 | 55 | // Update on programmatic value change 56 | useEffect(() => { 57 | if (!codemirror) { 58 | return; 59 | } 60 | 61 | if (query !== undefined && query !== codemirror.getValue()) { 62 | codemirror.setValue(query); 63 | } 64 | }, [query, codemirror]); 65 | 66 | const handleRef = (ref: HTMLTextAreaElement) => { 67 | if (ref === null || codemirror !== undefined) { 68 | return; 69 | } 70 | 71 | const editor = CodeMirror.fromTextArea(ref, { 72 | mode: "graphql", 73 | tabSize: 2, 74 | lineNumbers: true, 75 | autoCloseBrackets: "{}[]\"\"''", 76 | matchBrackets: true, 77 | }); 78 | 79 | editor.on("change", () => setQuery(editor.getValue())); 80 | 81 | setCodeMirror(editor); 82 | }; 83 | 84 | return ( 85 | 86 |