├── .circleci └── config.yml ├── .env ├── .env.development ├── .env.production ├── .flowconfig ├── .gitignore ├── LICENSE ├── README.md ├── cypress.config.js ├── cypress ├── e2e │ └── spec.cy.js ├── fixtures │ ├── profile.json │ └── queryResponse.json ├── plugins │ └── index.js └── support │ ├── commands.js │ ├── component-index.html │ ├── component.js │ └── e2e.js ├── docs ├── cb_Build Chart.png ├── cb_Chart Builder_Create Insight.gif ├── cb_Chart Builder_Save and Share.gif ├── cb_Chart Options_Left.gif ├── cb_Name Your Chart.png ├── cb_Vega-Lite Editor.png ├── cb_build-chart.png ├── cb_edit-chart.png ├── cb_view-chart.png ├── dw+cb_light.png └── index.html ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── renovate.json ├── src ├── components │ ├── CopyField.js │ ├── DWDatasetModal.js │ ├── DatasetSelector.js │ ├── DownloadButton.js │ ├── DownloadButton.module.css │ ├── Editor.js │ ├── Editor.module.css │ ├── Encoding.js │ ├── Encoding.module.css │ ├── FieldSelect.js │ ├── GlobalOptions.js │ ├── GlobalOptions.module.css │ ├── Header.js │ ├── LicenseModal.js │ ├── LicenseModal.module.css │ ├── LoadingAnimation.js │ ├── LoadingAnimation.module.css │ ├── Modals.module.css │ ├── ResizableVegaLiteEmbed.js │ ├── ResizableVegaLiteEmbed.module.css │ ├── SaveAsFileModal.js │ ├── SaveAsInsightModal.js │ ├── Sidebar.js │ ├── Sidebar.module.css │ ├── SidebarFooter.js │ ├── SidebarFooter.module.css │ ├── SimpleSelect.js │ ├── VegaLiteEmbed.js │ ├── VegaLiteImage.js │ ├── VizCard.js │ ├── VizCard.module.css │ ├── VizEmpty.js │ ├── VizEmpty.module.css │ ├── __tests__ │ │ ├── CopyField.spec.js │ │ ├── DatasetSelector.spec.js │ │ ├── DownloadButton.spec.js │ │ ├── Encoding.spec.js │ │ ├── FieldSelect.spec.js │ │ ├── GlobalOptions.spec.js │ │ ├── Header.spec.js │ │ ├── LoadingAnimation.spec.js │ │ ├── ResizableVegaLiteEmbed.spec.js │ │ ├── Sidebar.spec.js │ │ ├── SidebarFooter.spec.js │ │ ├── VizCard.spec.js │ │ ├── VizEmpty.spec.js │ │ └── __snapshots__ │ │ │ ├── CopyField.spec.js.snap │ │ │ ├── DatasetSelector.spec.js.snap │ │ │ ├── DownloadButton.spec.js.snap │ │ │ ├── Encoding.spec.js.snap │ │ │ ├── FieldSelect.spec.js.snap │ │ │ ├── GlobalOptions.spec.js.snap │ │ │ ├── Header.spec.js.snap │ │ │ ├── LoadingAnimation.spec.js.snap │ │ │ ├── ResizableVegaLiteEmbed.spec.js.snap │ │ │ ├── Sidebar.spec.js.snap │ │ │ ├── SidebarFooter.spec.js.snap │ │ │ ├── VizCard.spec.js.snap │ │ │ └── VizEmpty.spec.js.snap │ ├── infoIcon.svg │ └── logo.png ├── generated │ └── licenses.txt ├── index.css ├── index.js ├── registerServiceWorker.js ├── service-worker.js ├── setupTests.js ├── util │ ├── Store.js │ ├── __test__ │ │ ├── Store.spec.js │ │ ├── __snapshots__ │ │ │ ├── Store.spec.js.snap │ │ │ ├── urlState.spec.js.snap │ │ │ └── util.spec.js.snap │ │ ├── urlState.spec.js │ │ └── util.spec.js │ ├── constants.js │ ├── selectStyles.js │ ├── sparqlTypeToVegaType.js │ ├── types.js │ ├── urlState.js │ ├── util.js │ └── vega-lite-schema-v5.json └── views │ ├── App.js │ ├── App.module.css │ ├── AuthGate.js │ └── StateRestorationGate.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | commands: 3 | cypress_tests: 4 | description: Setup and run cypress tests 5 | steps: 6 | - run: 7 | name: Run server for tests 8 | command: yarn http-server build/ -p 3500 9 | background: true 10 | - run: 11 | name: Cypress tests 12 | command: | 13 | yarn cypress install 14 | yarn cypress run 15 | - save_cache: 16 | key: v1-npm-deps-{{ .Branch }}-{{ checksum "yarn.lock" }} 17 | paths: 18 | - node_modules 19 | - ~/.cache/Cypress 20 | - store_artifacts: 21 | path: cypress/videos 22 | - store_artifacts: 23 | path: cypress/screenshots 24 | - store_test_results: 25 | path: cypress/junit-results 26 | jobs: 27 | build: 28 | docker: 29 | - image: cypress/base:latest 30 | environment: 31 | BASH_ENV: ~/.env 32 | CI: true 33 | steps: 34 | - checkout 35 | - restore_cache: 36 | keys: 37 | - v1-npm-deps-{{ .Branch }}-{{ checksum "yarn.lock" }} 38 | - v1-npm-deps-{{ .Branch }} 39 | - v1-npm-deps- 40 | - run: yarn install 41 | - run: 42 | name: Jest tests 43 | command: yarn test 44 | - run: yarn build 45 | - save_cache: 46 | key: v1-npm-deps-{{ .Branch }}-{{ checksum "yarn.lock" }} 47 | paths: 48 | - node_modules 49 | - cypress_tests 50 | build_main: 51 | docker: 52 | - image: cypress/base:latest 53 | environment: 54 | BASH_ENV: ~/.env 55 | steps: 56 | - checkout 57 | - restore_cache: 58 | keys: 59 | - v1-npm-deps-{{ .Branch }}-{{ checksum "yarn.lock" }} 60 | - v1-npm-deps-{{ .Branch }} 61 | - v1-npm-deps- 62 | - run: yarn install 63 | - run: 64 | name: Jest tests 65 | command: yarn test 66 | - run: yarn build 67 | - cypress_tests 68 | deploy_main: 69 | docker: 70 | - image: cimg/node:20.7.0 71 | environment: 72 | BASH_ENV: ~/.env 73 | steps: 74 | - checkout 75 | - run: 76 | name: Clone build-scripts repo 77 | command: git clone git@github.com:datadotworld/build-scripts.git 78 | - run: 79 | name: Setup aws creds 80 | command: build-scripts/cicd/setup_aws_credentials.sh 81 | - restore_cache: 82 | keys: 83 | - v1-npm-deps-{{ .Branch }}-{{ checksum "yarn.lock" }} 84 | - v1-npm-deps-{{ .Branch }} 85 | - v1-npm-deps- 86 | - run: yarn install 87 | - run: 88 | name: Generate license text 89 | command: yarn licenses generate-disclaimer --silent > src/generated/licenses.txt 90 | - run: yarn build 91 | - run: 92 | name: Install AWS Cli 93 | command: | 94 | cd /tmp 95 | curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" 96 | unzip awscliv2.zip 97 | ./aws/install -i ~/.local/aws-cli -b ~/.local/bin 98 | - run: 99 | name: Sync to s3 100 | command: | 101 | aws s3 sync build s3://dataworld-chartbuilder-us-east-1 --exclude "index.html" --exclude "manifest.json" --exclude "service-worker.js" --exclude "asset-manifest.json" 102 | aws s3 cp build/index.html s3://dataworld-chartbuilder-us-east-1/index.html --cache-control max-age=300 103 | aws s3 cp build/manifest.json s3://dataworld-chartbuilder-us-east-1/manifest.json --cache-control max-age=300 104 | aws s3 cp build/service-worker.js s3://dataworld-chartbuilder-us-east-1/service-worker.js --cache-control max-age=300 105 | aws s3 cp build/asset-manifest.json s3://dataworld-chartbuilder-us-east-1/asset-manifest.json --cache-control max-age=300 106 | environment: 107 | AWS_PROFILE: artifacts 108 | workflows: 109 | build: 110 | jobs: 111 | - build: 112 | filters: 113 | branches: 114 | ignore: 115 | - main 116 | build_and_deploy_main: 117 | jobs: 118 | - build_main: 119 | filters: 120 | branches: 121 | only: 122 | - main 123 | - deploy_main: 124 | requires: 125 | - build_main 126 | context: 127 | - aws-artifacts 128 | filters: 129 | branches: 130 | only: 131 | - main 132 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PORT=3500 2 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_CLIENT_ID=dw-chart-builder-dev 2 | REACT_APP_CLIENT_SECRET=zj1WfD8KUu6XYRlpvIbfc781auX8FseE 3 | REACT_APP_REDIRECT_URI=http://localhost:3500/ 4 | REACT_APP_API_HOST=https://api.data.world 5 | REACT_APP_OAUTH_HOST=https://auth.data.world 6 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_CLIENT_ID=dw-chart-builder 2 | REACT_APP_CLIENT_SECRET=xrIT33t4ElEs5qhlclKuy1bSsof30cys 3 | REACT_APP_REDIRECT_URI=https://chart-builder.data.world/ 4 | REACT_APP_API_HOST=https://api.data.world 5 | REACT_APP_OAUTH_HOST=https://auth.data.world 6 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/cypress/.* 3 | 4 | [include] 5 | 6 | [libs] 7 | 8 | [lints] 9 | all=warn 10 | untyped-import=off 11 | unsafe-getters-setters=off 12 | unclear-type=off 13 | 14 | [options] 15 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 16 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 17 | 18 | [strict] 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .npmrc 3 | *.log 4 | /build 5 | .DS_Store 6 | .idea/ 7 | 8 | cypress/videos/* 9 | cypress/screenshots/* 10 | cypress/junit-results/* 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 data.world, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chart-builder 2 | 3 | [![CircleCI](https://circleci.com/gh/datadotworld/chart-builder.svg?style=svg)](https://circleci.com/gh/datadotworld/chart-builder) 4 | 5 | This is an app to generate Vega-Lite visualizations using data from a data.world query. 6 | 7 | ### Getting started 8 | 9 | 1. Install dependencies: `yarn` 10 | 1. Start the server: `yarn start` 11 | 1. Navigate to: http://localhost:3500 12 | 13 | ### Building for production 14 | 15 | 1. `yarn build` 16 | 17 | ### Testing 18 | 19 | 1. Ensure the app is running on `:3500` 20 | 1. For jest tests: `yarn test` 21 | 1. For integration tests: `yarn cypress run` 22 | 23 | ### Adding tests 24 | 25 | 1. Ensure the app is running on `:3500` 26 | 1. For jest tests: `yarn test` 27 | 1. For integration tests: `yarn cypress open` 28 | 29 | --- 30 | 31 | This app was bootstrapped with `create-react-app` 32 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | blockHosts: ['*.google-analytics.com', '*.mxpnl.com'], 5 | 6 | hosts: { 7 | 'api.data.world': '127.0.0.1' 8 | }, 9 | 10 | reporter: 'junit', 11 | 12 | reporterOptions: { 13 | mochaFile: 'cypress/junit-results/my-test-output.xml', 14 | toConsole: true 15 | }, 16 | 17 | e2e: { 18 | // We've imported your old cypress plugins here. 19 | // You may want to clean this up later by importing these. 20 | setupNodeEvents(on, config) { 21 | return require('./cypress/plugins/index.js')(on, config) 22 | }, 23 | baseUrl: 'http://localhost:3500' 24 | }, 25 | 26 | component: { 27 | devServer: { 28 | framework: 'create-react-app', 29 | bundler: 'webpack' 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /cypress/e2e/spec.cy.js: -------------------------------------------------------------------------------- 1 | const fetchMock = require('fetch-mock') 2 | 3 | describe('Chart Builder', function() { 4 | let visitOptions 5 | beforeEach(function() { 6 | visitOptions = { 7 | onBeforeLoad: win => { 8 | let fetch = fetchMock.sandbox() 9 | 10 | // Mock the POST request with the correct endpoint and parameters 11 | fetch.post( 12 | `https://api.data.world/v0/sql/data-society/iris-species`, 13 | this.queryResponse 14 | ) 15 | 16 | // Mock other requests 17 | fetch.get(`https://api.data.world/v0/user`, this.profile) 18 | fetch.post( 19 | `https://api.data.world/v0/insights/data-society/iris-species`, 20 | { 21 | message: 'Insight created successfully.', 22 | saving: false, 23 | uri: `https://api.data.world/data-society/iris-species/insights/abcd-1234` 24 | } 25 | ) 26 | fetch.get( 27 | `https://api.data.world/v0/datasets/data-society/iris-species`, 28 | { 29 | accessLevel: 'WRITE', 30 | isProject: true 31 | } 32 | ) 33 | fetch.put( 34 | `begin:https://api.data.world/v0/uploads/data-society/iris-species/files/test-title`, 35 | { message: 'File uploaded.' } 36 | ) 37 | fetch.get('glob:*/static/media/licenses*', 'cypress license text') 38 | 39 | cy.stub(win, 'fetch', fetch).as('fetch') 40 | win.localStorage.setItem('token', 'foo') 41 | } 42 | } 43 | 44 | cy.once('uncaught:exception', () => false) 45 | 46 | cy.fixture('profile').as('profile') 47 | cy.fixture('queryResponse').as('queryResponse') 48 | 49 | cy.visit('/', visitOptions) 50 | 51 | // load example data 52 | cy.get('[data-test=example-link]').click() 53 | 54 | // set some chart options 55 | cy.get( 56 | '[data-test=encoding-container-0] > [data-test=encoding-bar]' 57 | ).click() 58 | 59 | cy.get('#react-select-4-option-1').click() 60 | 61 | cy.get('[data-test=encoding-container-1]> [data-test=encoding-bar]').click() 62 | 63 | cy.get('#react-select-6-option-2').click() 64 | 65 | cy.get('[data-test=chart-type-selector]').click() 66 | 67 | cy.get('#react-select-2-option-3').click() 68 | }) 69 | 70 | it('Should toggle advanced options and ensure chart and title exist', function() { 71 | // toggle some advanced options 72 | cy.get('[data-test=encoding-container-0]').within(() => { 73 | cy.get('[data-test=toggle-adv-config]').click() 74 | cy.get('[data-test=bin-yes]').click() 75 | cy.get('[data-test=toggle-adv-config]').click() 76 | }) 77 | 78 | cy.get('[data-test=encoding-container-1]').within(() => { 79 | cy.get('[data-test=toggle-adv-config]').click() 80 | cy.get('[data-test=zero-no]').click() 81 | cy.get('[data-test=toggle-adv-config]').click() 82 | }) 83 | 84 | // add and remove encoding 85 | cy.get('[data-test=add-encoding]').click() 86 | 87 | cy.get( 88 | '[data-test=encoding-container-3]> [data-test=encoding-bar] > [data-test=toggle-adv-config]' 89 | ).click() 90 | 91 | cy.get('[data-test=rm-encoding]').click() 92 | cy.get( 93 | '[data-test=encoding-container-1] > [data-test=encoding-bar] > [data-test=toggle-adv-config]' 94 | ) 95 | // check that titles work 96 | cy.get('[data-test=chart-title]').type('test title') 97 | cy.get('svg .role-title').contains('test title') 98 | 99 | // check that chart exists 100 | cy.get('[data-test=vega-embed]') 101 | 102 | cy.get('[data-test=license-open]').click() 103 | cy.get('[data-test=license-text]').contains('cypress license text') 104 | }) 105 | 106 | it('Should be able to edit chart using editor', function() { 107 | cy.get('#configure-tabs-tab-editor').click() 108 | 109 | cy.get('.inputarea') 110 | .type('{rightarrow}') 111 | .type('"title": "test 123",') 112 | 113 | cy.get('#configure-tabs-tab-builder').click() 114 | 115 | cy.get('svg .role-title').contains('test 123') 116 | }) 117 | 118 | it('Should be able to download a chart', function() { 119 | cy.get('#dropdown-download').click() 120 | 121 | // download as a vega-lite file format 122 | cy.get('[data-test=download-vega-lite]').trigger('mousedown') 123 | cy.get('[data-test=download-vega-lite]').should($m => { 124 | expect($m).to.have.length(1) 125 | expect($m.attr('href')).to.match(/^blob/) 126 | }) 127 | 128 | // download as a vega file format 129 | cy.get('[data-test=download-vega]').trigger('mousedown') 130 | cy.get('[data-test=download-vega]').should($m => { 131 | expect($m).to.have.length(1) 132 | expect($m.attr('href')).to.match(/^blob/) 133 | }) 134 | 135 | // download as a png file format 136 | cy.get('[data-test=download-png]').trigger('mousedown') 137 | cy.get('[data-test=download-png]').should($m => { 138 | expect($m).to.have.length(1) 139 | expect($m.attr('href')).to.match(/^data:image\/png/) 140 | }) 141 | 142 | // download as a svg file format 143 | cy.get('[data-test=download-svg]').trigger('mousedown') 144 | cy.get('[data-test=download-svg]').should($m => { 145 | expect($m).to.have.length(1) 146 | expect($m.attr('href')).to.match(/^blob/) 147 | }) 148 | 149 | // download as a HTML file format 150 | cy.get('[data-test=download-html]').trigger('mousedown') 151 | cy.get('[data-test=download-html]').should($m => { 152 | expect($m).to.have.length(1) 153 | expect($m.attr('href')).to.match(/^blob/) 154 | }) 155 | }) 156 | 157 | it('Should be able to be shared', function() { 158 | cy.get('[data-test=chart-title]').type('test title') 159 | 160 | // share as an insight 161 | cy.get('#dropdown-save-ddw').click() 162 | cy.get('[data-test=share-insight]').click() 163 | cy.get('.modal') 164 | 165 | cy.get('.btn-primary').click() 166 | 167 | cy.get('.alert-link').should( 168 | 'have.attr', 169 | 'href', 170 | 'https://data.world/data-society/iris-species/insights/abcd-1234' 171 | ) 172 | 173 | cy.get('.modal-footer > .btn').click() 174 | 175 | // share as a file 176 | cy.get('#dropdown-save-ddw').click() 177 | cy.get('[data-test=share-file]').click() 178 | cy.get('.modal') 179 | 180 | cy.get('.btn-primary').click() 181 | 182 | cy.get('.alert-link').should( 183 | 'have.attr', 184 | 'href', 185 | 'https://data.world/data-society/iris-species/workspace/file?filename=test-title.vl.json' 186 | ) 187 | 188 | cy.get('.modal-footer > .btn').click() 189 | 190 | // share as a markdown comment 191 | cy.get('#dropdown-save-ddw').click() 192 | cy.get('[data-test=share-markdown]').click() 193 | cy.get('.modal') 194 | 195 | cy.get('[data-test=share-markdown-embed] input').then($i => { 196 | expect($i.val()).to.have.string('test title') 197 | expect($i.val()).to.have.string('```') 198 | }) 199 | 200 | cy.get('.modal-footer > .btn').click() 201 | 202 | // share as a URL 203 | cy.get('#dropdown-save-ddw').click() 204 | cy.get('[data-test=share-url]').click() 205 | 206 | cy.get('[data-test=share-url-text] input').then($i => { 207 | cy.visit($i.val(), visitOptions) 208 | }) 209 | }) 210 | }) 211 | -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "avatarUrl": 3 | "https://chart-builder.data.world/static/media/logo.ef710179.svg", 4 | "displayName": "Test User", 5 | "id": "testuser", 6 | "created": "2018-03-16T16:10:46.109Z", 7 | "updated": "2018-03-16T16:10:46.109Z" 8 | } 9 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /cypress/support/component.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/react' 23 | 24 | Cypress.Commands.add('mount', mount) 25 | 26 | // Example use: 27 | // cy.mount() 28 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /docs/cb_Build Chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_Build Chart.png -------------------------------------------------------------------------------- /docs/cb_Chart Builder_Create Insight.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_Chart Builder_Create Insight.gif -------------------------------------------------------------------------------- /docs/cb_Chart Builder_Save and Share.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_Chart Builder_Save and Share.gif -------------------------------------------------------------------------------- /docs/cb_Chart Options_Left.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_Chart Options_Left.gif -------------------------------------------------------------------------------- /docs/cb_Name Your Chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_Name Your Chart.png -------------------------------------------------------------------------------- /docs/cb_Vega-Lite Editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_Vega-Lite Editor.png -------------------------------------------------------------------------------- /docs/cb_build-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_build-chart.png -------------------------------------------------------------------------------- /docs/cb_edit-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_edit-chart.png -------------------------------------------------------------------------------- /docs/cb_view-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/cb_view-chart.png -------------------------------------------------------------------------------- /docs/dw+cb_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/docs/dw+cb_light.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | data.world and Chart Builder 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 |
23 | 24 |
25 |

You've enabled Chart Builder, now what?

26 |

With Chart Builder by data.world, you can easily create embeddable vega-lite visualizations without having to write JSON code. 27 |

28 | 29 |
30 | 31 |
32 |
33 |
34 |

Start building a chart.

35 |

With the Build Chart button, you can quickly start building a chart right from your dataset:

36 |
    37 |
  • Navigate to the dataset that you want to visualize with Chart Builder 38 |
  • 39 |
  • Click the Build Chart button in the top right corner of the screen 40 |
41 | 42 | 43 |
44 |
45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 |

Edit chart contents & settings.

54 |

You can easily edit your chart with the Visual Editor - simply choose your axes, point size, color, and any other encodings (fields) that you want. You can also further edit your choices by selecting “Options” next to each encoding.

55 |
56 |
57 |
58 | 59 |
60 |
61 |
62 |
    63 |
  1. Choose your chart type, set X & Y axes, and configure optional details like color and chart size. 64 |
  2. 65 |
    66 |
    67 | 68 |
    69 |
    70 |
    71 | You can edit your chart directly using Vega-Lite by selecting the Vega-Lite Editor tab in your chart settings. 72 |
    73 |
  3. View your chart & add a title 74 |
  4. 75 |
76 |
77 | 78 |
79 | 80 |
81 |
82 |
83 | 84 |
85 |
86 |
87 |
88 | 89 |

What next?

90 |

Here are a few things you can do with Chart Builder and data.world:

91 |
    92 |
  • Use the Save As option to save your chart as a file or an Insight to your project. Saving as an Insight allows viewers to see at-a-glance trends and information. 93 |
  • 94 |
  • 95 | Upload any vega (.vg.json) or vega-lite (.vl.json) file to your project to view and edit within data.world. 96 |
  • 97 |
  • Embed your chart anywhere data.world supports markdown (like in comments), using the Share URL option from the Downloads menu. 98 |
    99 | Use @[vega](link goes here) or @[vega-lite](link goes here) to embed your file. 100 |
    101 |
  • 102 |
103 |
104 |
105 |
106 | 107 |
108 |

Want to see more information on the data.world + Chart Builder Integration? check it out here. 109 |

110 |
111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chart-builder", 3 | "version": "0.1.0", 4 | "private": true, 5 | "author": "data.world (https://data.world/)", 6 | "homepage": "https://chart-builder.data.world", 7 | "repository": { 8 | "type": "git", 9 | "url": "github:datadotworld/chart-builder" 10 | }, 11 | "license": "Apache-2.0", 12 | "dependencies": { 13 | "classnames": "2.2.6", 14 | "filepicker-js": "2.4.18", 15 | "filesize": "3.6.1", 16 | "flow-bin": "0.80.0", 17 | "history": "4.7.2", 18 | "little-loader": "0.2.0", 19 | "lz-string": "1.4.4", 20 | "mobx": "4.3.1", 21 | "mobx-react": "5.2.6", 22 | "mobx-state-tree": "3.2.4", 23 | "react": "16.4.2", 24 | "react-bootstrap": "0.32.3", 25 | "react-copy-to-clipboard": "5.0.1", 26 | "react-dom": "16.4.2", 27 | "react-draggable": "3.0.5", 28 | "react-measure": "3.0.0-rc.3", 29 | "react-monaco-editor": "0.14.1", 30 | "react-router-dom": "4.3.1", 31 | "react-scripts": "^5.0.1", 32 | "react-select": "2.0.0", 33 | "workbox-background-sync": "^6.6.0", 34 | "workbox-broadcast-update": "^6.6.0", 35 | "workbox-cacheable-response": "^6.5.4", 36 | "workbox-core": "^6.6.0", 37 | "workbox-expiration": "^6.6.0", 38 | "workbox-google-analytics": "^6.6.1", 39 | "workbox-navigation-preload": "^6.6.0", 40 | "workbox-precaching": "^6.6.0", 41 | "workbox-range-requests": "^6.6.0", 42 | "workbox-routing": "^6.6.0", 43 | "workbox-strategies": "^6.6.0", 44 | "workbox-streams": "^6.6.0", 45 | "vega": "5.28.0", 46 | "vega-lite": "5.18.0", 47 | "vega-tooltip": "0.34.0" 48 | }, 49 | "scripts": { 50 | "start": "react-scripts start", 51 | "dev": "react-scripts start", 52 | "build": "react-scripts build", 53 | "test": "react-scripts test", 54 | "eject": "react-scripts eject", 55 | "flow": "flow", 56 | "precommit": "pretty-quick --staged" 57 | }, 58 | "devDependencies": { 59 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 60 | "cypress": "13.8.1", 61 | "fetch-mock": "6.5.2", 62 | "http-server": "0.11.1", 63 | "husky": "0.14.3", 64 | "mobx-react-devtools": "6.0.3", 65 | "prettier": "1.14.2", 66 | "pretty-quick": "1.6.0", 67 | "react-test-renderer": "16.4.2" 68 | }, 69 | "prettier": { 70 | "semi": false, 71 | "singleQuote": true 72 | }, 73 | "browserslist": { 74 | "development": [ 75 | "last 2 chrome versions", 76 | "last 2 firefox versions", 77 | "last 2 edge versions" 78 | ], 79 | "production": [ 80 | ">1%", 81 | "last 4 versions", 82 | "Firefox ESR", 83 | "not ie < 11" 84 | ] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Chart Builder | data.world 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 25 | 26 | 27 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Vega-Lite Explorer", 3 | "name": "Vega-Lite Explorer | data.world", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>datadotworld/renovate-config:public" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/components/CopyField.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* eslint no-unused-vars: ["warn", { "args": "after-used" }] */ 3 | import React, { Component } from 'react' 4 | import { 5 | FormGroup, 6 | InputGroup, 7 | FormControl, 8 | Button, 9 | Tooltip, 10 | Overlay 11 | } from 'react-bootstrap' 12 | import { decorate, observable } from 'mobx' 13 | import { CopyToClipboard } from 'react-copy-to-clipboard' 14 | import { observer } from 'mobx-react' 15 | 16 | class CopyField extends Component<{ getValue: () => string }> { 17 | copied: null | string = null 18 | value: string = this.props.getValue() 19 | 20 | copyButton: ?Button 21 | 22 | handleCopy = (text: string, result: boolean) => { 23 | this.copied = result ? 'Copied!' : 'Ctrl+C to copy' 24 | this.input && this.input.select() 25 | setTimeout(() => (this.copied = null), 700) 26 | } 27 | 28 | input: ?HTMLInputElement 29 | handleInputRef = (r: HTMLInputElement) => { 30 | this.input = r 31 | if (r) { 32 | r.select() 33 | } 34 | } 35 | 36 | render() { 37 | const { value } = this 38 | 39 | const copiedTooltip = this.copied != null && ( 40 | 41 | {this.copied} 42 | 43 | ) 44 | 45 | return ( 46 | 47 | 48 | 54 | 55 | 56 | 59 | 60 | 61 | {this.copied != null && ( 62 | this.copyButton} 67 | > 68 | {copiedTooltip} 69 | 70 | )} 71 | 72 | 73 | ) 74 | } 75 | } 76 | 77 | decorate(CopyField, { 78 | copied: observable, 79 | value: observable 80 | }) 81 | 82 | export default observer(CopyField) 83 | -------------------------------------------------------------------------------- /src/components/DWDatasetModal.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PureComponent } from 'react' 3 | import load from 'little-loader' 4 | import { CLIENT_ID } from '../util/constants' 5 | 6 | export type SelectedDatasetType = { id: string, owner: string } 7 | type P = { 8 | onSelect: SelectedDatasetType => mixed, 9 | onCancel: () => mixed, 10 | limitToProjects?: boolean 11 | } 12 | 13 | const WIDGET_JS = 'https://widgets.data.world/dataworld-widgets.js' 14 | let loadedScript = false 15 | export default class LoadScriptWrapper extends PureComponent< 16 | P, 17 | { loaded: boolean } 18 | > { 19 | state = { 20 | loaded: loadedScript 21 | } 22 | 23 | componentDidMount() { 24 | if (this.state.loaded) return 25 | 26 | load(WIDGET_JS, { 27 | callback: err => { 28 | if (err) return 29 | loadedScript = true 30 | this.setState({ 31 | loaded: true 32 | }) 33 | } 34 | }) 35 | } 36 | render() { 37 | if (!this.state.loaded) return null 38 | 39 | return 40 | } 41 | } 42 | 43 | type DatasetSelector = { 44 | success: (fn: (Array) => mixed) => void, 45 | cancel: (fn: () => void) => void, 46 | show: () => mixed, 47 | close: () => mixed 48 | } 49 | 50 | class DWDatasetModal extends PureComponent

{ 51 | datasetSelector: DatasetSelector 52 | 53 | componentDidMount() { 54 | this.datasetSelector = new window.dataworldWidgets.DatasetSelector({ 55 | client_id: CLIENT_ID, 56 | linkText: 'Select', 57 | resourceFilter: this.props.limitToProjects ? 'project' : undefined 58 | }) 59 | 60 | this.datasetSelector.success(selectedDatasets => { 61 | const [dataset] = selectedDatasets 62 | if (dataset) { 63 | this.props.onSelect(dataset) 64 | } else { 65 | this.props.onCancel() 66 | } 67 | }) 68 | 69 | this.datasetSelector.cancel(() => { 70 | this.props.onCancel() 71 | }) 72 | 73 | // Shows the dataset selector 74 | this.datasetSelector.show() 75 | } 76 | componentWillUnmount() { 77 | this.datasetSelector.close() 78 | } 79 | render() { 80 | return null 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/components/DatasetSelector.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react' 3 | import { decorate, observable } from 'mobx' 4 | import { observer } from 'mobx-react' 5 | import { FormGroup, InputGroup, FormControl, Button } from 'react-bootstrap' 6 | import { API_HOST } from '../util/constants' 7 | import DWDatasetModal, { type SelectedDatasetType } from './DWDatasetModal' 8 | 9 | type Props = { 10 | token: string, 11 | defaultValue: string, 12 | value: string, 13 | limitToProjects?: boolean, 14 | onChange: (id: string) => mixed 15 | } 16 | 17 | function toID(d: SelectedDatasetType) { 18 | return d.owner + '/' + d.id 19 | } 20 | 21 | class DatasetSelector extends Component { 22 | loadingInitial: boolean = true 23 | modalOpen: boolean = false 24 | 25 | componentDidMount() { 26 | this.setValueIfValid() 27 | } 28 | 29 | async setValueIfValid() { 30 | const { token, defaultValue, limitToProjects } = this.props 31 | if (!defaultValue) return 32 | 33 | try { 34 | const resp = await fetch(`${API_HOST}/v0/datasets/${defaultValue}`, { 35 | method: 'GET', 36 | headers: { 37 | Accept: 'application/json', 38 | Authorization: `Bearer ${token}` 39 | } 40 | }) 41 | if (resp.ok) { 42 | const jsonResponse = await resp.json() 43 | if ( 44 | jsonResponse.accessLevel === 'WRITE' || 45 | jsonResponse.accessLevel === 'ADMIN' 46 | ) { 47 | if ((limitToProjects && jsonResponse.isProject) || !limitToProjects) { 48 | this.props.onChange(defaultValue) 49 | } 50 | } 51 | } 52 | } catch (e) {} 53 | 54 | this.loadingInitial = false 55 | } 56 | 57 | handleSelect = (d: SelectedDatasetType) => { 58 | this.props.onChange(toID(d)) 59 | this.modalOpen = false 60 | } 61 | 62 | handleSelectClick = () => { 63 | this.modalOpen = true 64 | } 65 | 66 | handleCancel = () => { 67 | this.modalOpen = false 68 | } 69 | 70 | render() { 71 | const { limitToProjects } = this.props 72 | const value = this.loadingInitial 73 | ? 'Loading...' 74 | : this.props.value || 75 | `Select ${limitToProjects ? 'project' : 'dataset/project'}` 76 | 77 | return ( 78 | <> 79 | 80 | 81 | 82 | 83 | 90 | 91 | 92 | 93 | {this.modalOpen && ( 94 | 99 | )} 100 | 101 | ) 102 | } 103 | } 104 | 105 | decorate(DatasetSelector, { 106 | modalOpen: observable, 107 | loadingInitial: observable 108 | }) 109 | 110 | export default observer(DatasetSelector) 111 | -------------------------------------------------------------------------------- /src/components/DownloadButton.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react' 3 | import { DropdownButton, MenuItem } from 'react-bootstrap' 4 | import { observer, inject } from 'mobx-react' 5 | import { getDownloadName } from '../util/util' 6 | import classes from './DownloadButton.module.css' 7 | 8 | import type { Node } from 'react' 9 | import type { StoreType } from '../util/Store' 10 | 11 | function urlFromObject(obj) { 12 | const blob = new Blob([JSON.stringify(obj, null, 2)], { 13 | type: 'application/json' 14 | }) 15 | return URL.createObjectURL(blob) 16 | } 17 | 18 | type DownloadMenuItemProps = { 19 | extension: string, 20 | baseName: string, 21 | children: Node, 22 | getDownloadUrl: () => string, 23 | 'data-test'?: string 24 | } 25 | 26 | export class DownloadMenuItem extends Component { 27 | handleMouseDown = async (e: SyntheticMouseEvent) => { 28 | const { currentTarget } = e 29 | const { getDownloadUrl, baseName, extension } = this.props 30 | 31 | const url = await getDownloadUrl() 32 | currentTarget.setAttribute('href', url) 33 | currentTarget.setAttribute('download', getDownloadName(baseName, extension)) 34 | } 35 | 36 | render() { 37 | const { children, baseName, extension } = this.props 38 | 39 | return ( 40 | 46 | {children} 47 | 48 | ) 49 | } 50 | } 51 | 52 | type Props = { 53 | store: StoreType, 54 | getVegaView: () => Object, 55 | getData: () => Array 56 | } 57 | 58 | export class DownloadButton extends Component { 59 | render() { 60 | const { store, getVegaView, getData } = this.props 61 | 62 | return ( 63 | 72 | JSON 73 | { 78 | return urlFromObject( 79 | store.config.getSpecWithMinimumAmountOfData(getData()) 80 | ) 81 | }} 82 | href="##" 83 | > 84 | Vega-Lite (.vl.json) 85 | 86 | { 91 | return urlFromObject( 92 | require('vega-lite').compile( 93 | store.config.getSpecWithMinimumAmountOfData(getData()) 94 | ).spec 95 | ) 96 | }} 97 | href="##" 98 | > 99 | Vega (.vg.json) 100 | 101 | Image 102 | { 107 | return getVegaView().toImageURL('png') 108 | }} 109 | href="##" 110 | > 111 | PNG (.png) 112 | 113 | { 118 | return getVegaView().toImageURL('svg') 119 | }} 120 | href="##" 121 | > 122 | SVG (.svg) 123 | 124 | HTML 125 | { 130 | const vlSpec = JSON.stringify( 131 | store.config.getSpecWithMinimumAmountOfData(getData()) 132 | ) 133 | let html = ` 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 |
143 | 144 | 148 | 149 | 150 | ` 151 | const blob = new Blob([html], { 152 | type: 'application/html' 153 | }) 154 | return URL.createObjectURL(blob) 155 | }} 156 | href="##" 157 | > 158 | HTML (.html) 159 |
160 |
161 | ) 162 | } 163 | } 164 | 165 | export default inject('store')(observer(DownloadButton)) 166 | -------------------------------------------------------------------------------- /src/components/DownloadButton.module.css: -------------------------------------------------------------------------------- 1 | .dropdownButton :global(.dropdown-menu) { 2 | min-width: 10rem; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Editor.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react' 3 | import debounce from 'lodash/debounce' 4 | import MonacoEditor from 'react-monaco-editor' 5 | import { Measure } from 'react-measure' 6 | import vegaLiteSchema from '../util/vega-lite-schema-v5.json' 7 | import classes from './Editor.module.css' 8 | 9 | const monacoJsonSchema = { 10 | uri: 'https://vega.github.io/schema/vega-lite/v5.json', 11 | schema: vegaLiteSchema, 12 | fileMatch: ['*'] 13 | } 14 | 15 | const requireConfig = { 16 | url: 'https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.1/require.min.js', 17 | paths: { 18 | vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.10.1/min/vs' 19 | } 20 | } 21 | 22 | const MONACO_OPTIONS = { 23 | folding: true, 24 | scrollBeyondLastLine: true, 25 | wordWrap: true, 26 | wrappingIndent: 'same', 27 | automaticLayout: true, 28 | autoIndent: true, 29 | cursorBlinking: 'smooth', 30 | lineNumbersMinChars: 4 31 | } 32 | 33 | type Props = { 34 | trackValueChanges: boolean, 35 | value: string, 36 | onChange: (s: string) => mixed 37 | } 38 | 39 | type State = { 40 | hasMadeLocalModifications: boolean, 41 | code: string 42 | } 43 | 44 | export default class Editor extends Component { 45 | state = { 46 | hasMadeLocalModifications: false, 47 | code: this.props.value 48 | } 49 | 50 | editor: any 51 | editorDidMount = (editor: any) => { 52 | editor.focus() 53 | this.editor = editor 54 | } 55 | 56 | componentDidUpdate(prevProps: Props) { 57 | if ( 58 | prevProps.value !== this.props.value && 59 | this.props.trackValueChanges && 60 | !this.state.hasMadeLocalModifications 61 | ) { 62 | this.setState({ code: this.props.value }) 63 | } 64 | } 65 | 66 | changedDebounced = debounce((spec: string) => { 67 | this.props.onChange(spec) 68 | this.editor.focus() 69 | }, 700) 70 | 71 | handleEditorChange = (spec: string) => { 72 | this.setState({ code: spec, hasMadeLocalModifications: true }) 73 | this.changedDebounced(spec) 74 | } 75 | 76 | editorWillMount = (monaco: any) => { 77 | monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ 78 | validate: true, 79 | allowComments: true, 80 | schemas: [monacoJsonSchema] 81 | }) 82 | } 83 | 84 | render() { 85 | const { code } = this.state 86 | 87 | return ( 88 | 89 | {({ bind, measurements }) => ( 90 |
91 | {measurements && measurements.container.height ? ( 92 | 103 | ) : null} 104 |
105 | )} 106 |
107 | ) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/components/Editor.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow: hidden; 3 | flex: 1 1 auto; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Encoding.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 0.25rem 0; 3 | } 4 | 5 | .bar { 6 | display: flex; 7 | align-items: center; 8 | } 9 | 10 | .bar :global(.btn) { 11 | flex-shrink: 0; 12 | padding-right: 0.5rem; 13 | } 14 | 15 | .bar :global(.btn) svg { 16 | display: inline-block; 17 | fill: currentColor; 18 | line-height: 1; 19 | stroke: currentColor; 20 | stroke-width: 0; 21 | vertical-align: sub; 22 | } 23 | 24 | .advanced { 25 | margin: 0.5rem; 26 | } 27 | 28 | .advanced :global(.radio-inline) { 29 | padding-top: 0; 30 | } 31 | 32 | .advanced label { 33 | font-size: 14px; 34 | } 35 | 36 | .removeGroup { 37 | margin-right: -0.5rem; 38 | margin-top: 8px; 39 | } 40 | 41 | .channelSelect { 42 | width: 80px; 43 | flex-shrink: 0; 44 | } 45 | 46 | .advancedFormGroup { 47 | display: flex; 48 | align-items: baseline; 49 | margin-right: -0.5rem; 50 | } 51 | 52 | .inlineFormGroup { 53 | padding-left: 16px; 54 | display: inline-block; 55 | } 56 | 57 | .advancedFormGroup :global(.help-block) { 58 | margin-bottom: 0px; 59 | } 60 | 61 | .labelText { 62 | display: inline-block; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/FieldSelect.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react' 3 | import Select from 'react-select' 4 | import selectStyles from '../util/selectStyles' 5 | import type { FieldType } from '../util/Store' 6 | 7 | type Props = { 8 | fields: Array, 9 | value: ?FieldType, 10 | disabled: boolean, 11 | onChange: (f: null | FieldType) => mixed 12 | } 13 | 14 | const FIELD_SELECT_STYLES = selectStyles({ 15 | control: () => ({ 16 | margin: '0 0.5rem', 17 | marginLeft: 11 18 | }), 19 | container: () => ({ 20 | flexGrow: 1 21 | }), 22 | menu: () => ({ 23 | width: 292 24 | }) 25 | }) 26 | 27 | export default class FieldSelect extends Component { 28 | render() { 29 | const { fields, value, disabled } = this.props 30 | const options = fields.map(f => { 31 | /* flowlint-next-line sketchy-null:off */ 32 | return { value: f.name, label: f.label || f.name, field: f } 33 | }) 34 | return ( 35 | o.value === value)} 44 | onChange={e => { 45 | onChange(e ? e.value : null) 46 | }} 47 | styles={NORMAL_SELECT_STYLES} 48 | isDisabled={disabled} 49 | placeholder={placeholder} 50 | isClearable={isClearable} 51 | menuPortalTarget={document.body} 52 | /> 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/VegaLiteEmbed.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react' 3 | import * as vega from 'vega' 4 | import * as vl from 'vega-lite' 5 | import VegaTooltip from 'vega-tooltip/build/vega-tooltip' 6 | 7 | /* 8 | expects a spec with a named source called `source` 9 | */ 10 | type Props = { 11 | spec: Object, 12 | data: Array, 13 | onViewRender: (v: Object) => void 14 | } 15 | 16 | export default class VegaLiteEmbed extends Component { 17 | nodeRef: { current: null | HTMLDivElement } = React.createRef() 18 | 19 | shouldComponentUpdate() { 20 | return false 21 | } 22 | 23 | componentDidMount() { 24 | this.constructView() 25 | } 26 | 27 | view: ?Object 28 | 29 | async constructView() { 30 | const { spec, data, onViewRender } = this.props 31 | const loader = vega.loader() 32 | const logLevel = vega.Warn 33 | const renderer = 'svg' 34 | 35 | try { 36 | const vgSpec = vl.compile(spec).spec 37 | 38 | const runtime = vega.parse(vgSpec) 39 | 40 | const view = new vega.View(runtime, { 41 | loader, 42 | logLevel, 43 | renderer 44 | }) 45 | .initialize(this.nodeRef.current) 46 | .change('source', vega.changeset().insert(data)) 47 | 48 | try { 49 | VegaTooltip(view) 50 | } catch (e) {} 51 | 52 | this.view = view 53 | await view.runAsync() 54 | onViewRender && onViewRender(view) 55 | } catch (e) {} 56 | } 57 | 58 | componentWillUnmount() { 59 | this.view && this.view.finalize() 60 | } 61 | 62 | render() { 63 | return
64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/VegaLiteImage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react' 3 | import { decorate, observable } from 'mobx' 4 | import * as vega from 'vega' 5 | import * as vl from 'vega-lite' 6 | import { observer } from 'mobx-react' 7 | 8 | type Props = { 9 | spec: Object, 10 | data: Array, 11 | alt?: string, 12 | className?: string, 13 | onRender?: Blob => mixed 14 | } 15 | 16 | class VegaLiteImage extends Component { 17 | image: string = '' 18 | 19 | componentDidMount() { 20 | this.constructView() 21 | } 22 | 23 | componentWillUnmount() { 24 | if (this.image) { 25 | window.URL.revokeObjectURL(this.image) 26 | } 27 | } 28 | 29 | async constructView() { 30 | let { spec, data } = this.props 31 | const loader = vega.loader() 32 | const logLevel = vega.Warn 33 | const renderer = 'svg' 34 | 35 | spec = vl.compile(spec).spec 36 | 37 | const runtime = vega.parse(spec) 38 | const view = new vega.View(runtime, { 39 | loader, 40 | logLevel, 41 | renderer 42 | }).initialize() 43 | 44 | view.change('source', vega.changeset().insert(data)) 45 | 46 | const svgString: string = await view.toSVG() 47 | 48 | const blob = new Blob([svgString], { type: 'image/svg+xml' }) 49 | const url = window.URL.createObjectURL(blob) 50 | 51 | this.image = url 52 | view.finalize() 53 | 54 | this.props.onRender && this.props.onRender(blob) 55 | } 56 | 57 | render() { 58 | if (!this.image) return null 59 | 60 | return ( 61 | {this.props.alt} 66 | ) 67 | } 68 | } 69 | 70 | decorate(VegaLiteImage, { 71 | image: observable 72 | }) 73 | 74 | export default observer(VegaLiteImage) 75 | -------------------------------------------------------------------------------- /src/components/VizCard.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { PureComponent } from 'react' 3 | import type { Node } from 'react' 4 | import classes from './VizCard.module.css' 5 | 6 | export default class VizCard extends PureComponent<{ children: Node }> { 7 | render() { 8 | return
{this.props.children}
9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/VizCard.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 700px; 3 | overflow: auto; 4 | flex-grow: 1; 5 | padding: 1rem; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/VizEmpty.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Component } from 'react' 3 | import classes from './VizEmpty.module.css' 4 | 5 | const svg = ( 6 | 12 | 13 | 21 | 22 | 27 | 33 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 53 | 54 | 58 | 62 | 66 | 70 | 74 | 75 | 76 | 77 | ) 78 | 79 | export default class VizEmpty extends Component<{}> { 80 | render() { 81 | return ( 82 |
83 | {svg} 84 |
85 | Choose a chart type and columns
to the left and your chart will 86 | appear.
Like magic ✨ 87 |
88 |
89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/VizEmpty.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | height: 100%; 7 | } 8 | 9 | .text { 10 | margin-top: 1rem; 11 | height: 66px; 12 | width: 310px; 13 | color: #5d6f85; 14 | font-family: Lato; 15 | font-size: 16px; 16 | line-height: 22px; 17 | text-align: center; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/__tests__/CopyField.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CopyField from '../CopyField' 3 | 4 | it('renders', () => { 5 | snap( 'test copy'} />) 6 | }) 7 | -------------------------------------------------------------------------------- /src/components/__tests__/DatasetSelector.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DatasetSelector from '../DatasetSelector' 3 | 4 | it('renders loading', () => { 5 | snap( 6 | {}} 12 | /> 13 | ) 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/__tests__/DownloadButton.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Store from '../../util/Store' 3 | import { DownloadButton, DownloadMenuItem } from '../DownloadButton' 4 | 5 | it('renders', () => { 6 | const store = Store.create() 7 | snap( 8 | {}} getData={() => []} /> 9 | ) 10 | }) 11 | 12 | it('DownloadMenuItem renders', () => { 13 | snap( 14 | 'foo.test'} 18 | > 19 | hello 20 | 21 | ) 22 | }) 23 | -------------------------------------------------------------------------------- /src/components/__tests__/Encoding.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { EncLine, Field } from '../../util/Store' 3 | import Encoding from '../Encoding' 4 | 5 | it('renders', () => { 6 | const enc = EncLine.create() 7 | snap( 8 | 20 | ) 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/__tests__/FieldSelect.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Field } from '../../util/Store' 3 | import FieldSelect from '../FieldSelect' 4 | 5 | it('renders', () => { 6 | snap( 7 | {}} 18 | /> 19 | ) 20 | }) 21 | -------------------------------------------------------------------------------- /src/components/__tests__/GlobalOptions.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Store from '../../util/Store' 3 | import GlobalOptions from '../GlobalOptions' 4 | 5 | it('renders', () => { 6 | const store = Store.create({ 7 | config: { 8 | width: 100, 9 | height: 100 10 | } 11 | }) 12 | 13 | snap() 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/__tests__/Header.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Store from '../../util/Store' 3 | import Header from '../Header' 4 | 5 | it('renders', () => { 6 | const store = Store.create({ 7 | location: { 8 | search: '' 9 | } 10 | }) 11 | 12 | snap(
) 13 | }) 14 | 15 | it('renders with dataset', () => { 16 | const store = Store.create({ 17 | location: { 18 | search: 'dataset=foo/bar' 19 | } 20 | }) 21 | 22 | snap(
) 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/__tests__/LoadingAnimation.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LoadingAnimation from '../LoadingAnimation' 3 | 4 | it('renders', () => { 5 | snap() 6 | }) 7 | 8 | it('renders with a label', () => { 9 | snap() 10 | }) 11 | 12 | it('renders without overlay', () => { 13 | snap() 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/__tests__/ResizableVegaLiteEmbed.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ResizableVegaLiteEmbed from '../ResizableVegaLiteEmbed' 3 | 4 | jest.mock('react-draggable', () => ({ DraggableCore: 'draggable-core' })) 5 | jest.mock('../VegaLiteEmbed', () => 'vega-lite-embed') 6 | 7 | it('renders', () => { 8 | snap( 9 | {}} 13 | showResize 14 | setDimensions={() => {}} 15 | /> 16 | ) 17 | }) 18 | 19 | it('renders without resize', () => { 20 | snap( 21 | {}} 25 | showResize={false} 26 | setDimensions={() => {}} 27 | /> 28 | ) 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/__tests__/Sidebar.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Store, { Field } from '../../util/Store' 3 | import Sidebar from '../Sidebar' 4 | 5 | jest.mock('../GlobalOptions', () => 'global-options') 6 | 7 | it('renders', () => { 8 | const store = Store.create({ 9 | fields: [ 10 | Field.create({ 11 | name: 'foo', 12 | label: 'bar', 13 | rdfType: 'http://www.w3.org/2001/XMLSchema#float' 14 | }) 15 | ] 16 | }) 17 | 18 | snap() 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/__tests__/SidebarFooter.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SidebarFooter from '../SidebarFooter' 3 | 4 | it('renders', () => { 5 | snap() 6 | }) 7 | -------------------------------------------------------------------------------- /src/components/__tests__/VizCard.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import VizCard from '../VizCard' 3 | 4 | it('renders', () => { 5 | snap( 6 | 7 |
test
8 |
9 | ) 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/__tests__/VizEmpty.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import VizEmpty from '../VizEmpty' 3 | 4 | it('renders', () => { 5 | snap() 6 | }) 7 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/CopyField.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
12 | 15 | 21 | 24 | 32 | 33 | 34 |
35 | `; 36 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/DatasetSelector.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders loading 1`] = ` 4 |
7 | 10 | 16 | 19 | 33 | 34 | 35 |
36 | `; 37 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/DownloadButton.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DownloadMenuItem renders 1`] = ` 4 |
  • 8 | 17 | 18 | hello 19 | 20 | 21 |
  • 22 | `; 23 | 24 | exports[`renders 1`] = ` 25 |
    28 | 41 | 178 |
    179 | `; 180 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/Encoding.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    7 |
    11 | } 15 | onChange={[Function]} 16 | options={ 17 | Array [ 18 | Object { 19 | "label": "Position", 20 | "options": Array [ 21 | Object { 22 | "label": "X", 23 | "value": "x", 24 | }, 25 | Object { 26 | "label": "Y", 27 | "value": "y", 28 | }, 29 | Object { 30 | "label": "X2", 31 | "value": "x2", 32 | }, 33 | Object { 34 | "label": "Y2", 35 | "value": "y2", 36 | }, 37 | ], 38 | }, 39 | Object { 40 | "label": "Mark Properties", 41 | "options": Array [ 42 | Object { 43 | "label": "Color", 44 | "value": "color", 45 | }, 46 | Object { 47 | "label": "Opacity", 48 | "value": "opacity", 49 | }, 50 | Object { 51 | "label": "Size", 52 | "value": "size", 53 | }, 54 | Object { 55 | "label": "Shape", 56 | "value": "shape", 57 | }, 58 | ], 59 | }, 60 | Object { 61 | "label": "Text and Tooltip", 62 | "options": Array [ 63 | Object { 64 | "label": "Text", 65 | "value": "text", 66 | }, 67 | Object { 68 | "label": "Tooltip", 69 | "value": "tooltip", 70 | }, 71 | ], 72 | }, 73 | Object { 74 | "label": "Hyperlink", 75 | "options": Array [ 76 | Object { 77 | "label": "Href", 78 | "value": "href", 79 | }, 80 | ], 81 | }, 82 | Object { 83 | "label": "Order", 84 | "options": Array [ 85 | Object { 86 | "label": "Order", 87 | "value": "order", 88 | }, 89 | ], 90 | }, 91 | Object { 92 | "label": "Level of Detail", 93 | "options": Array [ 94 | Object { 95 | "label": "Detail", 96 | "value": "detail", 97 | }, 98 | ], 99 | }, 100 | Object { 101 | "label": "Facet", 102 | "options": Array [ 103 | Object { 104 | "label": "Row", 105 | "value": "row", 106 | }, 107 | Object { 108 | "label": "Column", 109 | "value": "column", 110 | }, 111 | ], 112 | }, 113 | ] 114 | } 115 | styles={ 116 | Object { 117 | "clearIndicator": [Function], 118 | "control": [Function], 119 | "dropdownIndicator": [Function], 120 | "menu": [Function], 121 | "menuPortal": [Function], 122 | "option": [Function], 123 | "singleValue": [Function], 124 | "valueContainer": [Function], 125 | } 126 | } 127 | value={ 128 | Object { 129 | "label": "X", 130 | "value": "x", 131 | } 132 | } 133 | /> 134 | } 139 | onChange={[Function]} 140 | options={ 141 | Array [ 142 | Object { 143 | "field": Object { 144 | "label": "bar", 145 | "name": "foo", 146 | "rdfType": "http://www.w3.org/2001/XMLSchema#float", 147 | }, 148 | "label": "bar", 149 | "value": "foo", 150 | }, 151 | ] 152 | } 153 | placeholder="Select a field..." 154 | styles={ 155 | Object { 156 | "clearIndicator": [Function], 157 | "container": [Function], 158 | "control": [Function], 159 | "dropdownIndicator": [Function], 160 | "menu": [Function], 161 | "menuPortal": [Function], 162 | "option": [Function], 163 | "singleValue": [Function], 164 | "valueContainer": [Function], 165 | } 166 | } 167 | /> 168 | 196 |
    197 |
    198 | `; 199 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/FieldSelect.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 | } 9 | onChange={[Function]} 10 | options={ 11 | Array [ 12 | Object { 13 | "field": Object { 14 | "label": "bar", 15 | "name": "foo", 16 | "rdfType": "http://www.w3.org/2001/XMLSchema#float", 17 | }, 18 | "label": "bar", 19 | "value": "foo", 20 | }, 21 | ] 22 | } 23 | placeholder="Select a field..." 24 | styles={ 25 | Object { 26 | "clearIndicator": [Function], 27 | "container": [Function], 28 | "control": [Function], 29 | "dropdownIndicator": [Function], 30 | "menu": [Function], 31 | "menuPortal": [Function], 32 | "option": [Function], 33 | "singleValue": [Function], 34 | "valueContainer": [Function], 35 | } 36 | } 37 | /> 38 | `; 39 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/GlobalOptions.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    5 |
    8 |
    11 | 14 | 22 | 25 | Width 26 | 27 | 28 |
    29 |
    32 | 43 |
    44 |
    47 | 50 | 58 | 61 | Height 62 | 63 | 64 |
    65 |
    66 |
    67 | `; 68 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/Header.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 | 68 | `; 69 | 70 | exports[`renders with dataset 1`] = ` 71 | 144 | `; 145 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/LoadingAnimation.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    7 |
    10 |
    13 | Loading... 14 |
    15 |
    16 |
    17 | `; 18 | 19 | exports[`renders with a label 1`] = ` 20 |
    23 |
    26 |
    29 | Loading... 30 |
    31 | 32 | foo 33 | 34 |
    35 |
    36 | `; 37 | 38 | exports[`renders without overlay 1`] = ` 39 |
    42 |
    45 |
    48 | Loading... 49 |
    50 |
    51 |
    52 | `; 53 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/ResizableVegaLiteEmbed.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    7 | 24 | 27 | 30 | 31 |
    32 | `; 33 | 34 | exports[`renders without resize 1`] = ` 35 |
    38 | 55 |
    56 | `; 57 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/Sidebar.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    7 |
    11 | 49 |
    52 |
    59 | 62 |
    65 | Marks 66 |
    67 | data.world info icon 83 |
    84 |
    87 | } 91 | onChange={[Function]} 92 | options={ 93 | Array [ 94 | Object { 95 | "label": "Area", 96 | "value": "area", 97 | }, 98 | Object { 99 | "label": "Bar", 100 | "value": "bar", 101 | }, 102 | Object { 103 | "label": "Line", 104 | "value": "line", 105 | }, 106 | Object { 107 | "label": "Point", 108 | "value": "point", 109 | }, 110 | Object { 111 | "label": "Tick", 112 | "value": "tick", 113 | }, 114 | Object { 115 | "label": "Rect", 116 | "value": "rect", 117 | }, 118 | Object { 119 | "label": "Circle", 120 | "value": "circle", 121 | }, 122 | Object { 123 | "label": "Square", 124 | "value": "square", 125 | }, 126 | ] 127 | } 128 | styles={ 129 | Object { 130 | "clearIndicator": [Function], 131 | "control": [Function], 132 | "dropdownIndicator": [Function], 133 | "menu": [Function], 134 | "menuPortal": [Function], 135 | "option": [Function], 136 | "singleValue": [Function], 137 | "valueContainer": [Function], 138 | } 139 | } 140 | value={ 141 | Object { 142 | "label": "Bar", 143 | "value": "bar", 144 | } 145 | } 146 | /> 147 |
    148 |
    151 | Configuration 152 | 171 |
    172 |
    175 |
    176 |
    177 |
    180 | Set chart size 181 |
    182 | 183 |
    184 |
    185 |
    186 |
    189 | 214 | 217 | © data.world 218 | 219 |
    220 |
    221 | `; 222 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/SidebarFooter.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    7 | 32 | 35 | © data.world 36 | 37 |
    38 | `; 39 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/VizCard.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    7 |
    8 | test 9 |
    10 |
    11 | `; 12 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/VizEmpty.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders 1`] = ` 4 |
    7 | 13 | 14 | 22 | 28 | 33 | 39 | 44 | 45 | 48 | 51 | 52 | 53 | 54 | 58 | 66 | 70 | 74 | 78 | 82 | 86 | 90 | 91 | 92 | 93 |
    96 | Choose a chart type and columns 97 |
    98 | to the left and your chart will appear. 99 |
    100 | Like magic ✨ 101 |
    102 |
    103 | `; 104 | -------------------------------------------------------------------------------- /src/components/infoIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | icon/info/blue copy 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadotworld/chart-builder/451bb761747f4516a7091c7fb2b1f80124193bc9/src/components/logo.png -------------------------------------------------------------------------------- /src/generated/licenses.txt: -------------------------------------------------------------------------------- 1 | !!! DO NOT MODIFY !!! 2 | 3 | THIS FILE IS AUTOMATICALLY GENERATED IN CIRCLECI 4 | 5 | !!! DO NOT MODIFY !!! 6 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | overflow: hidden; 5 | } 6 | 7 | #root { 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | 13 | .popover-title { 14 | margin: 0; 15 | padding: 4px 14px; 16 | font-size: 14px; 17 | background-color: #f7f7f7; 18 | border-bottom: 1px solid #ebebeb; 19 | border-radius: 5px 5px 0 0; 20 | font-weight: bold; 21 | } 22 | 23 | .form-horizontal .control-label { 24 | padding-top: 7px; 25 | text-align: left; 26 | } 27 | .form-horizontal .form-group { 28 | margin-bottom: 0; 29 | } 30 | 31 | .navbar-right { 32 | margin-right: 0; 33 | } 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { Router, Route } from 'react-router-dom' 5 | import { Provider } from 'mobx-react' 6 | import { createBrowserHistory } from 'history' 7 | 8 | import 'regenerator-runtime/runtime' 9 | import AuthGate from './views/AuthGate' 10 | import { unregister } from './registerServiceWorker' 11 | import Store from './util/Store' 12 | 13 | import './index.css' 14 | 15 | const history = createBrowserHistory() 16 | 17 | const store = Store.create({ 18 | token: '', 19 | 20 | config: { 21 | encodings: [] 22 | } 23 | }) 24 | store.addBrowserHistoryListener(history) 25 | 26 | const rootElement = document.getElementById('root') 27 | if (rootElement) { 28 | ReactDOM.render( 29 | 30 | 31 | 32 | 33 | , 34 | rootElement 35 | ) 36 | } 37 | 38 | unregister() 39 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | 3 | // This service worker can be customized! 4 | // See https://developers.google.com/web/tools/workbox/modules 5 | // for the list of available Workbox modules, or add any other 6 | // code you'd like. 7 | // You can also remove this file if you'd prefer not to use a 8 | // service worker, and the Workbox build step will be skipped. 9 | 10 | import { clientsClaim } from 'workbox-core' 11 | import { ExpirationPlugin } from 'workbox-expiration' 12 | import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching' 13 | import { registerRoute } from 'workbox-routing' 14 | import { StaleWhileRevalidate } from 'workbox-strategies' 15 | 16 | clientsClaim() 17 | 18 | // Precache all of the assets generated by your build process. 19 | // Their URLs are injected into the manifest variable below. 20 | // This variable must be present somewhere in your service worker file, 21 | // even if you decide not to use precaching. See https://cra.link/PWA 22 | precacheAndRoute(self.__WB_MANIFEST) 23 | 24 | // Set up App Shell-style routing, so that all navigation requests 25 | // are fulfilled with your index.html shell. Learn more at 26 | // https://developers.google.com/web/fundamentals/architecture/app-shell 27 | const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$') 28 | registerRoute( 29 | // Return false to exempt requests from being fulfilled by index.html. 30 | ({ request, url }) => { 31 | // If this isn't a navigation, skip. 32 | if (request.mode !== 'navigate') { 33 | return false 34 | } // If this is a URL that starts with /_, skip. 35 | 36 | if (url.pathname.startsWith('/_')) { 37 | return false 38 | } // If this looks like a URL for a resource, because it contains // a file extension, skip. 39 | 40 | if (url.pathname.match(fileExtensionRegexp)) { 41 | return false 42 | } // Return true to signal that we want to use the handler. 43 | 44 | return true 45 | }, 46 | createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html') 47 | ) 48 | 49 | // An example runtime caching route for requests that aren't handled by the 50 | // precache, in this case same-origin .png requests like those from in public/ 51 | registerRoute( 52 | // Add in any other file extensions or routing criteria as needed. 53 | ({ url }) => 54 | url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst. 55 | new StaleWhileRevalidate({ 56 | cacheName: 'images', 57 | plugins: [ 58 | // Ensure that once this runtime cache reaches a maximum size the 59 | // least-recently used images are removed. 60 | new ExpirationPlugin({ maxEntries: 50 }) 61 | ] 62 | }) 63 | ) 64 | 65 | // This allows the web app to trigger skipWaiting via 66 | // registration.waiting.postMessage({type: 'SKIP_WAITING'}) 67 | self.addEventListener('message', event => { 68 | if (event.data && event.data.type === 'SKIP_WAITING') { 69 | self.skipWaiting() 70 | } 71 | }) 72 | 73 | // Any other custom service worker logic can go here. 74 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | global.snap = (element: mixed) => { 2 | const renderer = require('react-test-renderer') 3 | 4 | const tree = renderer.create(element).toJSON() 5 | expect(tree).toMatchSnapshot() 6 | } 7 | 8 | jest.mock('react-select', () => 'react-select') 9 | -------------------------------------------------------------------------------- /src/util/Store.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { types } from 'mobx-state-tree' 3 | import sparqlTypeToVegaType from './sparqlTypeToVegaType' 4 | import { parseParams, encodeFieldName } from './util' 5 | import type { 6 | EncodingChannel, 7 | EncodingType, 8 | TimeUnitType, 9 | SortType 10 | } from './types' 11 | 12 | type ModelType = { 13 | create: (o?: Object) => T 14 | } 15 | 16 | export type FieldType = { 17 | // model 18 | name: string, 19 | label: string, 20 | rdfType: string, 21 | 22 | // views 23 | typeDerivedFromRdfType: string 24 | } 25 | 26 | export const Field: ModelType = types 27 | .model('Field', { 28 | name: types.identifier, 29 | label: types.maybeNull(types.string), 30 | rdfType: types.string 31 | }) 32 | .views(self => ({ 33 | get typeDerivedFromRdfType() { 34 | return sparqlTypeToVegaType(self.rdfType) 35 | } 36 | })) 37 | 38 | export type EncLineType = { 39 | _id: number, 40 | field: null | FieldType, 41 | channel: EncodingChannel, 42 | type: EncodingType, 43 | 44 | bin: boolean, 45 | aggregate: string, 46 | zero: boolean, 47 | scale: string, 48 | sort: SortType, 49 | timeUnit: TimeUnitType, 50 | sortField: ?EncLineType, 51 | 52 | // views 53 | autoType: string, 54 | appliedType: string, 55 | 56 | // actions 57 | setField: (f: null | FieldType) => void, 58 | setChannel: (channel: string) => void, 59 | setType: (type: string) => void, 60 | setAggregate: (aggregate: string) => void, 61 | setBin: (bin: boolean) => void, 62 | setZero: (zero: boolean) => void, 63 | setScale: (scale: string) => void, 64 | setSort: (sort: string) => void, 65 | setTimeUnit: (t: null | string) => void, 66 | setSortField: (e: null | EncLineType) => void 67 | } 68 | 69 | let currentEncLineID = 0 70 | 71 | export const EncLine: ModelType = types 72 | .model('EncLine', { 73 | _id: types.identifierNumber, 74 | 75 | field: types.maybeNull(types.reference(Field)), 76 | channel: types.optional(types.string, 'x'), 77 | type: types.optional(types.string, 'auto'), 78 | 79 | bin: types.optional(types.boolean, false), 80 | aggregate: types.maybeNull(types.string), 81 | zero: types.optional(types.boolean, true), 82 | scale: types.optional(types.string, 'linear'), 83 | sort: types.optional( 84 | types.enumeration(['none', 'ascending', 'descending']), 85 | 'ascending' 86 | ), 87 | timeUnit: types.maybeNull(types.string), 88 | sortField: types.maybeNull(types.reference(types.late(() => EncLine))) 89 | }) 90 | .views(self => ({ 91 | get autoType() { 92 | return self.field ? self.field.typeDerivedFromRdfType : null 93 | }, 94 | get appliedType() { 95 | return self.type === 'auto' ? self.autoType : self.type 96 | } 97 | })) 98 | .actions(self => ({ 99 | setField(f: null | FieldType) { 100 | if (self.field && self.field.name === '*' && self.aggregate === 'count') { 101 | self.aggregate = null 102 | } 103 | self.field = f 104 | if (f && f.name === '*') { 105 | self.aggregate = 'count' 106 | } 107 | }, 108 | setChannel(channel: string) { 109 | self.channel = channel 110 | }, 111 | setType(type: EncodingType) { 112 | self.type = type 113 | }, 114 | setAggregate(aggregate: string) { 115 | self.aggregate = aggregate 116 | }, 117 | setBin(bin: boolean) { 118 | self.bin = bin 119 | }, 120 | setZero(zero: boolean) { 121 | self.zero = zero 122 | }, 123 | setScale(scale: string) { 124 | self.scale = scale 125 | }, 126 | setSort(sort: string) { 127 | self.sort = sort 128 | }, 129 | setTimeUnit(t: null | string) { 130 | self.timeUnit = t 131 | }, 132 | setSortField(e: null | EncLineType) { 133 | self.sortField = e 134 | } 135 | })) 136 | .preProcessSnapshot(snapshot => ({ 137 | ...snapshot, 138 | aggregate: snapshot.aggregate === 'none' ? null : snapshot.aggregate, 139 | _id: snapshot._id == null ? currentEncLineID++ : snapshot._id 140 | })) 141 | 142 | export type ChartConfigType = { 143 | title: null | string, 144 | mark: string, 145 | encodings: Array, 146 | manualSpec: null | string, 147 | width: null | number, 148 | height: null | number, 149 | 150 | // views 151 | hasPossiblyValidChart: boolean, 152 | hasManualSpec: boolean, 153 | hasFacetField: boolean, 154 | generatedSpec: Object, 155 | getMinimumAmountOfData: (data: Array) => Array, 156 | getSpecWithMinimumAmountOfData: (data: Array) => Object, 157 | 158 | // actions 159 | setTitle: (title: string) => void, 160 | setMark: (mark: string) => void, 161 | addEncoding: () => void, 162 | setManualSpec: (s: null | string) => void, 163 | setDimensions: (w: null | number, h: null | number) => void, 164 | removeEncoding: (e: EncLineType) => void 165 | } 166 | 167 | export const ChartConfig = types 168 | .model('ChartConfig', { 169 | title: types.maybeNull(types.string), 170 | mark: types.optional(types.string, 'bar'), 171 | encodings: types.optional(types.array(EncLine), () => []), 172 | manualSpec: types.maybeNull(types.string), 173 | width: types.maybeNull(types.number), 174 | height: types.maybeNull(types.number) 175 | }) 176 | .views((self: ChartConfigType) => ({ 177 | get hasPossiblyValidChart() { 178 | return self.encodings.some(e => e.field) 179 | }, 180 | get hasManualSpec() { 181 | return !!self.manualSpec 182 | }, 183 | get hasFacetField() { 184 | return self.encodings.some( 185 | e => e.field && (e.channel === 'row' || e.channel === 'column') 186 | ) 187 | }, 188 | get generatedSpec() { 189 | if (self.manualSpec != null && self.manualSpec !== '') { 190 | try { 191 | const obj = JSON.parse(self.manualSpec) 192 | return obj 193 | } catch (e) {} 194 | } 195 | 196 | const encoding = {} 197 | self.encodings.forEach(e => { 198 | if (e.field) { 199 | const sortOrder = 200 | e.sort === 'none' 201 | ? null 202 | : e.sort === 'ascending' 203 | ? undefined 204 | : e.sort 205 | 206 | const sort = 207 | e.sortField && e.sortField.field 208 | ? { 209 | field: e.sortField.field.name, 210 | op: 211 | e.sortField.aggregate === null 212 | ? 'sum' 213 | : e.sortField.aggregate, 214 | order: sortOrder 215 | } 216 | : sortOrder 217 | 218 | const enc = { 219 | field: encodeFieldName(e.field.name), 220 | type: e.appliedType, 221 | bin: e.bin || undefined, 222 | aggregate: e.aggregate === null ? undefined : e.aggregate, 223 | sort, 224 | 225 | timeUnit: e.timeUnit || undefined, 226 | scale: { 227 | type: e.scale, 228 | zero: e.zero 229 | } 230 | } 231 | 232 | // concat tooltips instead of overrriding 233 | if (e.channel === 'tooltip' && encoding[e.channel]) { 234 | const existing = encoding[e.channel] 235 | encoding[e.channel] = [ 236 | ...(Array.isArray(existing) ? existing : [existing]), 237 | enc 238 | ] 239 | } else { 240 | encoding[e.channel] = enc 241 | } 242 | } 243 | }) 244 | 245 | return { 246 | $schema: 'https://vega.github.io/schema/vega-lite/v5.json', 247 | title: self.title != null ? self.title : undefined, 248 | width: self.hasFacetField 249 | ? undefined 250 | : self.width != null 251 | ? self.width 252 | : undefined, 253 | height: self.hasFacetField 254 | ? undefined 255 | : self.height != null 256 | ? self.height 257 | : undefined, 258 | autosize: 259 | !self.hasFacetField && (self.width != null || self.height != null) 260 | ? { 261 | type: 'fit', 262 | contains: 'padding' 263 | } 264 | : undefined, 265 | mark: { type: self.mark }, 266 | encoding, 267 | data: { name: 'source' }, 268 | config: { background: '#ffffff', padding: 20 } 269 | } 270 | }, 271 | getMinimumAmountOfData(data: Array) { 272 | // if they've defined a manual spec, they could be using anything 273 | if (self.hasManualSpec) return data 274 | 275 | // otherwise, they should only be using fields that we know about 276 | const wantedFields: Array = [ 277 | ...new Set( 278 | self.encodings 279 | .filter(e => e.field) 280 | .map(e => (e.field ? e.field.name : '')) 281 | ) 282 | ] 283 | 284 | return data.map(d => { 285 | const obj = {} 286 | wantedFields.forEach(w => { 287 | obj[w] = d[w] 288 | }) 289 | return obj 290 | }) 291 | }, 292 | getSpecWithMinimumAmountOfData(data: Array) { 293 | const spec = self.generatedSpec 294 | let data_block = {} 295 | 296 | // if the user is still using the runtime bound data block, inject values, otherwise whatever is there 297 | if (spec.data && spec.data.name && spec.data.name === 'source') { 298 | data_block.values = self.getMinimumAmountOfData(data) 299 | } else if (spec.data) { 300 | data_block = { 301 | ...spec.data 302 | } 303 | } 304 | return { 305 | ...spec, 306 | data: data_block 307 | } 308 | } 309 | })) 310 | .actions((self: ChartConfigType) => ({ 311 | setMark(mark: string) { 312 | self.mark = mark 313 | }, 314 | addEncoding() { 315 | const encoding = EncLine.create() 316 | self.encodings.push(encoding) 317 | return encoding 318 | }, 319 | removeEncoding(e: EncLineType) { 320 | self.encodings.forEach(enc => { 321 | if (enc.sortField === e) enc.sortField = null 322 | }) 323 | ;(self.encodings: any).remove(e) 324 | }, 325 | setManualSpec(s: null | string) { 326 | self.manualSpec = s 327 | }, 328 | setDimensions(w: null | number, h: null | number) { 329 | self.width = w 330 | self.height = h 331 | }, 332 | setTitle(t: string) { 333 | self.title = t || null 334 | } 335 | })) 336 | 337 | export type StoreType = { 338 | token: string, 339 | location: Object, 340 | fields: Array, 341 | config: ChartConfigType, 342 | 343 | // views 344 | parsedUrlQuery: Object, 345 | hasValidParams: boolean, 346 | query: string, 347 | queryType: 'sql' | 'sparql', 348 | savedQueryId: string | null, 349 | dataset: string, 350 | 351 | // actions 352 | syncQueryParams: Object => void, 353 | reset: () => void, 354 | setToken: string => void, 355 | setFields: (Array) => void, 356 | setBrowserLocation: Object => void, 357 | addBrowserHistoryListener: Object => void 358 | } 359 | 360 | const Store: ModelType = types 361 | .model('Store', { 362 | token: types.optional(types.string, ''), 363 | 364 | location: types.frozen(), 365 | 366 | fields: types.optional(types.array(Field), []), 367 | config: types.optional(ChartConfig, {}) 368 | }) 369 | .views((self: StoreType) => ({ 370 | get hasValidParams() { 371 | return !!self.dataset && (!!self.query || !!self.savedQueryId) 372 | }, 373 | 374 | get parsedUrlQuery() { 375 | return parseParams(self.location.search) 376 | }, 377 | 378 | get query() { 379 | return self.parsedUrlQuery.query 380 | }, 381 | get queryType() { 382 | const providedQueryType = self.parsedUrlQuery.query_type 383 | if (providedQueryType) { 384 | const lowered = providedQueryType.toLowerCase() 385 | if (lowered === 'sql' || lowered === 'sparql') return lowered 386 | } 387 | return 'sql' 388 | }, 389 | get savedQueryId() { 390 | const providedSavedQueryId = self.parsedUrlQuery.saved_query 391 | if (!providedSavedQueryId || providedSavedQueryId.includes('sample')) { 392 | return null 393 | } 394 | return providedSavedQueryId 395 | }, 396 | get dataset() { 397 | const providedDataset = self.parsedUrlQuery.dataset 398 | return providedDataset 399 | } 400 | })) 401 | .actions((self: StoreType) => ({ 402 | syncQueryParams(obj) { 403 | Object.assign(self, obj) 404 | }, 405 | reset() { 406 | self.config = ({ 407 | mark: 'bar', 408 | encodings: [ 409 | EncLine.create({ channel: 'x' }), 410 | EncLine.create({ channel: 'y' }), 411 | EncLine.create({ channel: 'color' }) 412 | ] 413 | }: any) 414 | }, 415 | setToken(token: string) { 416 | self.token = token 417 | }, 418 | setFields(f: Array) { 419 | self.fields = f 420 | }, 421 | setBrowserLocation(location: Object) { 422 | self.location = location 423 | }, 424 | addBrowserHistoryListener(history: any) { 425 | self.setBrowserLocation(history.location) 426 | 427 | history.listen(self.setBrowserLocation) 428 | } 429 | })) 430 | 431 | export default Store 432 | -------------------------------------------------------------------------------- /src/util/__test__/Store.spec.js: -------------------------------------------------------------------------------- 1 | import Store, { ChartConfig, Field } from '../Store' 2 | 3 | describe('Store', () => { 4 | it('works', () => { 5 | const store = Store.create() 6 | store.reset() 7 | expect(store).toMatchSnapshot() 8 | }) 9 | 10 | it('detects valid params', () => { 11 | const store = Store.create({ 12 | location: { 13 | search: 'dataset=foo/bar&query=select' 14 | } 15 | }) 16 | expect(store.hasValidParams).toBe(true) 17 | expect({ 18 | dataset: store.dataset, 19 | query: store.query 20 | }).toMatchSnapshot() 21 | }) 22 | 23 | it('detects invalid params', () => { 24 | const store = Store.create({ 25 | location: { 26 | search: 'dataset=foo/bar' 27 | } 28 | }) 29 | expect(store.hasValidParams).toBe(false) 30 | }) 31 | ;[ 32 | ['sql', 'sql'], 33 | ['sqL', 'sql'], 34 | ['blah', 'sql'], 35 | ['', 'sql'], 36 | ['sparql', 'sparql'], 37 | ['sparqL', 'sparql'] 38 | ].forEach(([type, expected]) => { 39 | it(`standardizes queryType from ${type} to ${expected}`, () => { 40 | const store = Store.create({ 41 | location: { 42 | search: `dataset=foo/bar&query=select&query_type=${type}` 43 | } 44 | }) 45 | expect(store.queryType).toBe(expected) 46 | }) 47 | }) 48 | }) 49 | 50 | describe('ChartConfig', () => { 51 | it('works', () => { 52 | const config = ChartConfig.create() 53 | expect(config).toMatchSnapshot() 54 | }) 55 | it('sets a few encodings', () => { 56 | const store = Store.create() 57 | store.reset() 58 | expect(store.config.hasPossiblyValidChart).toBe(false) 59 | store.setFields([ 60 | { 61 | name: 'foo', 62 | rdfType: 'http://www.w3.org/2001/XMLSchema#float' 63 | } 64 | ]) 65 | store.config.encodings[0].setField(store.fields[0]) 66 | expect(store.config.hasPossiblyValidChart).toBe(true) 67 | 68 | expect(store).toMatchSnapshot() 69 | expect(store.config.generatedSpec).toMatchSnapshot() 70 | expect( 71 | store.config.getMinimumAmountOfData([{ foo: 1, bar: 2 }]) 72 | ).toMatchSnapshot() 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/util/__test__/__snapshots__/Store.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ChartConfig sets a few encodings 1`] = ` 4 | Object { 5 | "config": Object { 6 | "encodings": Array [ 7 | Object { 8 | "_id": 7, 9 | "aggregate": null, 10 | "bin": false, 11 | "channel": "x", 12 | "field": "foo", 13 | "scale": "linear", 14 | "sort": "ascending", 15 | "sortField": null, 16 | "timeUnit": null, 17 | "type": "auto", 18 | "zero": true, 19 | }, 20 | Object { 21 | "_id": 9, 22 | "aggregate": null, 23 | "bin": false, 24 | "channel": "y", 25 | "field": null, 26 | "scale": "linear", 27 | "sort": "ascending", 28 | "sortField": null, 29 | "timeUnit": null, 30 | "type": "auto", 31 | "zero": true, 32 | }, 33 | Object { 34 | "_id": 11, 35 | "aggregate": null, 36 | "bin": false, 37 | "channel": "color", 38 | "field": null, 39 | "scale": "linear", 40 | "sort": "ascending", 41 | "sortField": null, 42 | "timeUnit": null, 43 | "type": "auto", 44 | "zero": true, 45 | }, 46 | ], 47 | "height": null, 48 | "manualSpec": null, 49 | "mark": "bar", 50 | "title": null, 51 | "width": null, 52 | }, 53 | "fields": Array [ 54 | Object { 55 | "label": null, 56 | "name": "foo", 57 | "rdfType": "http://www.w3.org/2001/XMLSchema#float", 58 | }, 59 | ], 60 | "location": undefined, 61 | "token": "", 62 | } 63 | `; 64 | 65 | exports[`ChartConfig sets a few encodings 2`] = ` 66 | Object { 67 | "$schema": "https://vega.github.io/schema/vega-lite/v5.json", 68 | "autosize": undefined, 69 | "config": Object { 70 | "background": "#ffffff", 71 | "padding": 20, 72 | }, 73 | "data": Object { 74 | "name": "source", 75 | }, 76 | "encoding": Object { 77 | "x": Object { 78 | "aggregate": undefined, 79 | "bin": undefined, 80 | "field": "foo", 81 | "scale": Object { 82 | "type": "linear", 83 | "zero": true, 84 | }, 85 | "sort": undefined, 86 | "timeUnit": undefined, 87 | "type": "quantitative", 88 | }, 89 | }, 90 | "height": undefined, 91 | "mark": Object { 92 | "type": "bar", 93 | }, 94 | "title": undefined, 95 | "width": undefined, 96 | } 97 | `; 98 | 99 | exports[`ChartConfig sets a few encodings 3`] = ` 100 | Array [ 101 | Object { 102 | "foo": 1, 103 | }, 104 | ] 105 | `; 106 | 107 | exports[`ChartConfig works 1`] = ` 108 | Object { 109 | "encodings": Array [], 110 | "height": null, 111 | "manualSpec": null, 112 | "mark": "bar", 113 | "title": null, 114 | "width": null, 115 | } 116 | `; 117 | 118 | exports[`Store detects valid params 1`] = ` 119 | Object { 120 | "dataset": "foo/bar", 121 | "query": "select", 122 | } 123 | `; 124 | 125 | exports[`Store works 1`] = ` 126 | Object { 127 | "config": Object { 128 | "encodings": Array [ 129 | Object { 130 | "_id": 1, 131 | "aggregate": null, 132 | "bin": false, 133 | "channel": "x", 134 | "field": null, 135 | "scale": "linear", 136 | "sort": "ascending", 137 | "sortField": null, 138 | "timeUnit": null, 139 | "type": "auto", 140 | "zero": true, 141 | }, 142 | Object { 143 | "_id": 3, 144 | "aggregate": null, 145 | "bin": false, 146 | "channel": "y", 147 | "field": null, 148 | "scale": "linear", 149 | "sort": "ascending", 150 | "sortField": null, 151 | "timeUnit": null, 152 | "type": "auto", 153 | "zero": true, 154 | }, 155 | Object { 156 | "_id": 5, 157 | "aggregate": null, 158 | "bin": false, 159 | "channel": "color", 160 | "field": null, 161 | "scale": "linear", 162 | "sort": "ascending", 163 | "sortField": null, 164 | "timeUnit": null, 165 | "type": "auto", 166 | "zero": true, 167 | }, 168 | ], 169 | "height": null, 170 | "manualSpec": null, 171 | "mark": "bar", 172 | "title": null, 173 | "width": null, 174 | }, 175 | "fields": Array [], 176 | "location": undefined, 177 | "token": "", 178 | } 179 | `; 180 | -------------------------------------------------------------------------------- /src/util/__test__/__snapshots__/urlState.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getStateString works 1`] = `"N4IgbgpgTgzglgewHYgFwEYA0IDGyBmcA5mqAC5xkA2EaSArlVdgLYCGUA1miAEYchsEJHgAmcJERhoA2gF1WbBmyoBlAA4QcdRsxAB3OKLIALHU2wmIxE2XNUAvg6A"`; 4 | 5 | exports[`getStateUrl works 1`] = `"http://localhost/?s=N4IgbgpgTgzglgewHYgFwEYA0IDGyBmcA5mqAC5xkA2EaSArlVdgLYCGUA1miAEYchsEJHgAmcJERhoA2gF1WbBmyoBlAA4QcdRsxAB3OKLIALHU2wmIxE2XNUAvg6A"`; 6 | 7 | exports[`restoreFromStateString works 1`] = ` 8 | Object { 9 | "config": Object { 10 | "encodings": Array [], 11 | "height": null, 12 | "manualSpec": null, 13 | "mark": "line", 14 | "title": null, 15 | "width": null, 16 | }, 17 | "fields": Array [], 18 | "location": undefined, 19 | "token": "", 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /src/util/__test__/__snapshots__/util.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createParams works with object 1`] = ` 4 | Map { 5 | "a" => "1", 6 | "b" => "2", 7 | } 8 | `; 9 | 10 | exports[`createParams works with string 1`] = ` 11 | Map { 12 | "a" => "1", 13 | "b" => "2", 14 | } 15 | `; 16 | 17 | exports[`fixupJsonFields works 1`] = ` 18 | Array [ 19 | Object { 20 | "name": "foo", 21 | "rdfType": "http://www.w3.org/2001/XMLSchema#decimal", 22 | }, 23 | Object { 24 | "name": "bar", 25 | "rdfType": "http://www.w3.org/2001/XMLSchema#string", 26 | }, 27 | ] 28 | `; 29 | 30 | exports[`getDownloadName works 1`] = `"base-name-2018-04-01T18-00-00-000Z.png"`; 31 | 32 | exports[`parseParams works 1`] = ` 33 | Object { 34 | "a": "1", 35 | "b": "2", 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/util/__test__/urlState.spec.js: -------------------------------------------------------------------------------- 1 | import Store from '../Store' 2 | import { 3 | restoreFromStateString, 4 | getStateString, 5 | getStateUrl 6 | } from '../urlState' 7 | 8 | describe('getStateString', () => { 9 | it('works', () => { 10 | const store = Store.create() 11 | expect(getStateString(store)).toMatchSnapshot() 12 | }) 13 | }) 14 | 15 | describe('getStateUrl', () => { 16 | it('works', () => { 17 | const store = Store.create() 18 | expect(getStateUrl(store)).toMatchSnapshot() 19 | }) 20 | }) 21 | 22 | describe('restoreFromStateString', () => { 23 | it('works', () => { 24 | const store = Store.create({ 25 | config: { 26 | mark: 'line' 27 | } 28 | }) 29 | const str = getStateString(store) 30 | 31 | const newStore = Store.create({ 32 | config: { 33 | mark: 'bar' 34 | } 35 | }) 36 | restoreFromStateString(newStore, str, {}) 37 | expect(newStore).toMatchSnapshot() 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/util/__test__/util.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseParams, 3 | createParams, 4 | getDownloadName, 5 | encodeFieldName, 6 | fixupJsonFields 7 | } from '../util' 8 | 9 | describe('parseParams', () => { 10 | it('works', () => { 11 | expect(parseParams('a=1&b=2')).toMatchSnapshot() 12 | }) 13 | }) 14 | 15 | describe('createParams', () => { 16 | it('works with string', () => { 17 | expect(new Map(createParams('a=1&b=2'))).toMatchSnapshot() 18 | }) 19 | 20 | it('works with object', () => { 21 | expect(new Map(createParams({ a: 1, b: 2 }))).toMatchSnapshot() 22 | }) 23 | }) 24 | 25 | describe('getDownloadName', () => { 26 | it('works', () => { 27 | const RealDate = Date 28 | 29 | global.Date = class extends RealDate { 30 | constructor() { 31 | return new RealDate('2018-04-01T18:00:00.000Z') 32 | } 33 | } 34 | 35 | expect(getDownloadName('base-name', 'png')).toMatchSnapshot() 36 | }) 37 | }) 38 | 39 | describe('encodeFieldName', () => { 40 | const specs = [ 41 | [`normal.foo`, `normal\\.foo`], 42 | [`normal.[]foo`, `normal\\.\\[\\]foo`], 43 | [`normal`, `normal`] 44 | ] 45 | 46 | specs.forEach(([input, expected]) => { 47 | it(`works for ${input}`, () => { 48 | expect(encodeFieldName(input)).toBe(expected) 49 | }) 50 | }) 51 | }) 52 | 53 | describe('fixupJsonFields', () => { 54 | it('works', () => { 55 | expect( 56 | fixupJsonFields([ 57 | { 58 | name: 'foo', 59 | rdfType: 'http://www.w3.org/2001/XMLSchema#decimal' 60 | }, 61 | { 62 | name: 'bar' 63 | } 64 | ]) 65 | ).toMatchSnapshot() 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/util/constants.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const CLIENT_ID = String(process.env.REACT_APP_CLIENT_ID) 4 | export const CLIENT_SECRET = String(process.env.REACT_APP_CLIENT_SECRET) 5 | export const REDIRECT_URI = String(process.env.REACT_APP_REDIRECT_URI) 6 | export const API_HOST = String(process.env.REACT_APP_API_HOST) 7 | export const OAUTH_HOST = String(process.env.REACT_APP_OAUTH_HOST) 8 | -------------------------------------------------------------------------------- /src/util/selectStyles.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | type StyleType = { [string]: (base: Object, state?: Object) => Object } 3 | const sharedObj: StyleType = { 4 | option: () => ({ 5 | padding: '4px 12px' 6 | }), 7 | control: () => ({ 8 | minHeight: 28, 9 | height: 28, 10 | fontSize: 13, 11 | backgroundColor: '#fff', 12 | margin: '.25rem 0' 13 | }), 14 | dropdownIndicator: () => ({ 15 | padding: 0 16 | }), 17 | clearIndicator: () => ({ 18 | padding: 0 19 | }), 20 | singleValue: () => ({ 21 | paddingRight: 4 22 | }), 23 | valueContainer: () => ({ 24 | overflowX: 'hidden', 25 | padding: '0 8px' 26 | }), 27 | menu: () => ({ 28 | marginTop: 0 29 | }), 30 | menuPortal: () => ({ 31 | zIndex: 9999 32 | }) 33 | } 34 | 35 | const sharedKeys = Object.keys(sharedObj) 36 | 37 | const decorate = (target: StyleType) => { 38 | const combinedKeys = [...new Set([...Object.keys(target), ...sharedKeys])] 39 | const obj = {} 40 | combinedKeys.forEach(k => { 41 | const s = sharedObj[k] 42 | const t = target[k] 43 | obj[k] = (base, state) => ({ 44 | ...base, 45 | ...(s ? s(base, state) : {}), 46 | ...(t ? t(base, state) : {}) 47 | }) 48 | }) 49 | return obj 50 | } 51 | 52 | export default decorate 53 | -------------------------------------------------------------------------------- /src/util/sparqlTypeToVegaType.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { EncodingType } from './types' 3 | 4 | const typeMap: { [string]: ?EncodingType } = { 5 | string: 'nominal', 6 | boolean: 'nominal', 7 | duration: 'nominal', 8 | time: 'nominal', 9 | gYearMonth: 'nominal', 10 | gYear: 'nominal', 11 | gMonthDay: 'nominal', 12 | gDay: 'nominal', 13 | gMonth: 'nominal', 14 | hexBinary: 'nominal', 15 | base64Binary: 'nominal', 16 | anyURI: 'nominal', 17 | normalizedString: 'nominal', 18 | token: 'nominal', 19 | language: 'nominal', 20 | yearMonthDuration: 'nominal', 21 | dayTimeDuration: 'nominal', 22 | 23 | float: 'quantitative', 24 | double: 'quantitative', 25 | decimal: 'quantitative', 26 | integer: 'quantitative', 27 | nonPositiveInteger: 'quantitative', 28 | negativeInteger: 'quantitative', 29 | long: 'quantitative', 30 | int: 'quantitative', 31 | short: 'quantitative', 32 | byte: 'quantitative', 33 | nonNegativeInteger: 'quantitative', 34 | unsignedLong: 'quantitative', 35 | unsignedInt: 'quantitative', 36 | unsignedShort: 'quantitative', 37 | unsignedByte: 'quantitative', 38 | positiveInteger: 'quantitative', 39 | 40 | date: 'temporal', 41 | dateTime: 'temporal', 42 | dateTimeStamp: 'temporal' 43 | } 44 | 45 | export default function(datatype: string): EncodingType { 46 | const strippedType = datatype.replace('http://www.w3.org/2001/XMLSchema#', '') 47 | 48 | const t = typeMap[strippedType] 49 | return t || 'nominal' 50 | } 51 | -------------------------------------------------------------------------------- /src/util/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export type EncodingChannel = 3 | | 'x' 4 | | 'y' 5 | | 'x2' 6 | | 'y2' 7 | | 'color' 8 | | 'opacity' 9 | | 'size' 10 | | 'shape' 11 | | 'text' 12 | | 'tooltip' 13 | | 'href' 14 | | 'order' 15 | | 'detail' 16 | | 'row' 17 | | 'column' 18 | 19 | export type MarkType = 20 | | 'area' 21 | | 'bar' 22 | | 'line' 23 | | 'point' 24 | | 'tick' 25 | | 'rect' 26 | | 'circle' 27 | | 'square' 28 | 29 | export type EncodingType = 30 | | 'auto' 31 | | 'quantitative' 32 | | 'ordinal' 33 | | 'nominal' 34 | | 'temporal' 35 | 36 | export type TimeUnitType = 37 | | 'year' 38 | | 'quarter' 39 | | 'month' 40 | | 'day' 41 | | 'date' 42 | | 'hours' 43 | | 'minutes' 44 | | 'seconds' 45 | | 'milliseconds' 46 | | 'yearquarter' 47 | | 'yearquartermonth' 48 | | 'yearmonth' 49 | | 'yearmonthdate' 50 | | 'yearmonthdatehours' 51 | | 'yearmonthdatehoursminutes' 52 | | 'yearmonthdatehoursminutesseconds' 53 | | 'quartermonth' 54 | | 'monthdate' 55 | | 'hoursminutes' 56 | | 'hoursminutesseconds' 57 | | 'minutesseconds' 58 | | 'secondsmilliseconds' 59 | 60 | export type SortType = 'ascending' | 'descending' 61 | -------------------------------------------------------------------------------- /src/util/urlState.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import lzString from 'lz-string' 3 | import { applySnapshot, getSnapshot } from 'mobx-state-tree' 4 | 5 | export function restoreFromStateString( 6 | store: Object, 7 | stateString: string, 8 | mergeOpts: Object 9 | ) { 10 | const decompressed: string = lzString.decompressFromEncodedURIComponent( 11 | stateString 12 | ) 13 | const data = { 14 | ...JSON.parse(decompressed), 15 | ...mergeOpts 16 | } 17 | applySnapshot(store, data) 18 | } 19 | 20 | export function getStateString(store: Object): string { 21 | const snap = getSnapshot(store) 22 | const sanitized = { 23 | version: 1, 24 | location: snap.location 25 | ? { 26 | search: snap.location.search 27 | } 28 | : undefined, 29 | config: snap.config 30 | } 31 | 32 | const data = JSON.stringify(sanitized) 33 | return lzString.compressToEncodedURIComponent(data) 34 | } 35 | 36 | export function getStateUrl(store: Object) { 37 | console.log(document.location.origin) 38 | return document.location.origin + '/?s=' + getStateString(store) 39 | } 40 | -------------------------------------------------------------------------------- /src/util/util.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react' 3 | import { Tooltip } from 'react-bootstrap' 4 | 5 | export function parseParams(searchString: String) { 6 | const query = new URLSearchParams(searchString) 7 | const obj = {} 8 | for (let entry of query) { 9 | obj[entry[0]] = entry[1] 10 | } 11 | return obj 12 | } 13 | 14 | export function createParams(objOrString: Object | string) { 15 | if (typeof objOrString === 'string') return new URLSearchParams(objOrString) 16 | 17 | const query = new URLSearchParams() 18 | for (let key in objOrString) { 19 | query.set(key, objOrString[key]) 20 | } 21 | return query 22 | } 23 | 24 | export function getDownloadName(baseName: string, extension: string) { 25 | const dateStr = new Date().toISOString().replace(/[.:]/g, '-') 26 | return `${baseName}-${dateStr}.${extension}` 27 | } 28 | 29 | export function encodeFieldName(name: string) { 30 | return name.replace(/([.[\]])/g, '\\$1') 31 | } 32 | 33 | export function tooltipOverlay(id: string, description: string) { 34 | return {description} 35 | } 36 | 37 | type PossibleFieldType = {| name: string, rdfType?: string |} 38 | // sometimes a `field` doesn't contain an `rdfType`. we'll fallback to `string` in that case 39 | export function fixupJsonFields(fields: Array) { 40 | const mapped: Array = fields.map(f => ({ 41 | rdfType: 'http://www.w3.org/2001/XMLSchema#string', 42 | ...f 43 | })) 44 | 45 | return mapped 46 | } 47 | -------------------------------------------------------------------------------- /src/views/App.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | // @flow 3 | import React, { Fragment, Component } from 'react' 4 | import { decorate, observable, runInAction } from 'mobx' 5 | import { observer, inject } from 'mobx-react' 6 | import { Link } from 'react-router-dom' 7 | import { 8 | Grid, 9 | Row, 10 | Button, 11 | Col, 12 | ButtonToolbar, 13 | DropdownButton, 14 | MenuItem, 15 | Modal 16 | } from 'react-bootstrap' 17 | import filesize from 'filesize' 18 | import DownloadButton from '../components/DownloadButton' 19 | import LoadingAnimation from '../components/LoadingAnimation' 20 | import SaveAsFileModal from '../components/SaveAsFileModal' 21 | import SaveAsInsightModal from '../components/SaveAsInsightModal' 22 | import { API_HOST } from '../util/constants' 23 | import Header from '../components/Header' 24 | import Sidebar from '../components/Sidebar' 25 | import { getStateUrl } from '../util/urlState' 26 | import VizCard from '../components/VizCard' 27 | import VizEmpty from '../components/VizEmpty' 28 | import ResizableVegaLiteEmbed from '../components/ResizableVegaLiteEmbed' 29 | import CopyField from '../components/CopyField' 30 | import { fixupJsonFields } from '../util/util' 31 | import classes from './App.module.css' 32 | import type { StoreType } from '../util/Store' 33 | 34 | type AppP = { 35 | history: Object, 36 | location: Object, 37 | store: StoreType 38 | } 39 | 40 | type SparqlResults = { 41 | head: { 42 | vars: Array 43 | }, 44 | results: { 45 | bindings: Array<{ 46 | [key: string]: { 47 | type: string, 48 | value: string 49 | } 50 | }> 51 | } 52 | } 53 | 54 | class App extends Component { 55 | data: ?Array = null 56 | 57 | loading: boolean = true 58 | errorLoading: boolean = false 59 | bytesDownloaded: number = 0 60 | 61 | saveModalOpen: false | 'insight' | 'file' | 'shareurl' | 'ddwembed' = false 62 | 63 | componentDidMount() { 64 | const { store } = this.props 65 | 66 | if (store.hasValidParams) { 67 | this.fetchQuery() 68 | } else { 69 | console.warn('No valid parameters found.') 70 | } 71 | } 72 | 73 | getQueryHeaders() { 74 | const { store } = this.props 75 | return { 76 | Accept: 77 | store.queryType === 'sql' 78 | ? 'application/json' 79 | : 'application/sparql-results+json', 80 | Authorization: `Bearer ${store.token}`, 81 | 'Content-Type': 'application/x-www-form-urlencoded' 82 | } 83 | } 84 | 85 | fetchQuery = async () => { 86 | const { store } = this.props 87 | runInAction(() => { 88 | store.setFields([]) 89 | this.data = null 90 | this.loading = true 91 | }) 92 | 93 | let res 94 | try { 95 | // Determine URL and method based on savedQueryId 96 | const queryURL = store.savedQueryId 97 | ? `${API_HOST}/v0/queries/${ 98 | store.savedQueryId 99 | }/results?includeTableSchema=true` 100 | : `${API_HOST}/v0/${store.queryType}/${store.dataset}` 101 | 102 | const fetchOptions = store.savedQueryId 103 | ? { method: 'GET', headers: this.getQueryHeaders() } 104 | : { 105 | method: 'POST', 106 | headers: { 107 | ...this.getQueryHeaders(), 108 | 'Content-Type': 'application/json' 109 | }, 110 | body: JSON.stringify({ 111 | query: store.query, 112 | includeTableSchema: true 113 | }) 114 | } 115 | 116 | res = await fetch(queryURL, fetchOptions) 117 | 118 | // Check for response success 119 | if (!res.ok) { 120 | const errorText = await res.text() 121 | console.error('Error Response:', errorText) 122 | throw new Error('Network response was not ok') 123 | } 124 | 125 | // Process response data 126 | const data = await res.json() 127 | this.handleData(data) 128 | } catch (error) { 129 | console.error('Fetch error:', error) 130 | this.errorLoading = true 131 | } finally { 132 | runInAction(() => { 133 | this.loading = false 134 | }) 135 | } 136 | } 137 | 138 | processData(data: Array | SparqlResults) { 139 | if (Array.isArray(data)) { 140 | // we're processing application/json 141 | const [dschema, ...rows] = data 142 | return { 143 | fields: fixupJsonFields(dschema.fields), 144 | rows 145 | } 146 | } 147 | 148 | const sparqlFields: any = data.head.vars.map(v => ({ 149 | name: v, 150 | rdfType: 'http://www.w3.org/2001/XMLSchema#string' 151 | })) 152 | 153 | const sparqlRows = data.results.bindings.map(b => { 154 | const obj = {} 155 | for (let k in b) { 156 | obj[k] = b[k].value 157 | } 158 | return obj 159 | }) 160 | 161 | return { 162 | fields: sparqlFields, 163 | rows: sparqlRows 164 | } 165 | } 166 | 167 | handleData = (data: Array) => { 168 | const { store } = this.props 169 | 170 | const { fields, rows } = this.processData(data) 171 | 172 | runInAction(() => { 173 | store.setFields([ 174 | ...(fields: any).map(f => ({ 175 | name: f.name, 176 | rdfType: f.rdfType 177 | })), 178 | { 179 | name: '*', 180 | label: 'COUNT(*)', 181 | rdfType: 'http://www.w3.org/2001/XMLSchema#integer' 182 | } 183 | ]) 184 | this.data = rows 185 | this.loading = false 186 | if (this.props.store.config.encodings.length === 0) { 187 | this.props.store.reset() 188 | } 189 | }) 190 | } 191 | 192 | vegaView: Object 193 | handleViewRender = v => { 194 | this.vegaView = v 195 | } 196 | 197 | renderEmbed() { 198 | const { data } = this 199 | const { store } = this.props 200 | return ( 201 | (data && 202 | store.fields && 203 | store.config.hasPossiblyValidChart && ( 204 | 213 | )) || 214 | ) 215 | } 216 | 217 | render() { 218 | const { store } = this.props 219 | 220 | if (!store.hasValidParams) { 221 | return ( 222 | 223 |
    224 | 225 | 226 | 227 |

    Valid parameters required

    228 | 236 | Here's an example (with a link to a query) 237 | 238 |
    239 | 248 | Here's another example (with a preconfigured chart) 249 | 250 | 251 |
    252 |
    253 | 254 | ) 255 | } 256 | 257 | if (this.loading || this.errorLoading) { 258 | return ( 259 | 260 |
    261 | 262 | {this.loading ? ( 263 | 272 | ) : ( 273 |

    Error loading data

    274 | )} 275 |
    276 | 277 | ) 278 | } 279 | 280 | return ( 281 | 282 |
    283 | 284 |
    285 | 286 |
    287 | 288 | 289 | 290 |
    291 | { 297 | store.config.setTitle(e.target.value) 298 | }} 299 | /> 300 |
    301 |
    302 | 303 | this.vegaView} 305 | getData={() => this.data} 306 | /> 307 | (this.saveModalOpen = ek)} 316 | > 317 | Share to data.world as... 318 | 319 | Insight 320 | 321 | 322 | File 323 | 324 | 328 | Markdown Embed (Comment) 329 | 330 | Other 331 | 332 | Share URL 333 | 334 | 335 | 336 |
    337 | 338 |
    339 |
    340 | {this.renderEmbed()} 341 |
    342 |
    343 | {this.saveModalOpen === 'insight' && ( 344 | (this.saveModalOpen = false)} 346 | defaultId={store.dataset} 347 | data={this.data} 348 | /> 349 | )} 350 | {this.saveModalOpen === 'file' && ( 351 | (this.saveModalOpen = false)} 353 | defaultId={store.dataset} 354 | data={this.data} 355 | /> 356 | )} 357 | {this.saveModalOpen === 'shareurl' && ( 358 | (this.saveModalOpen = false)} 360 | show 361 | backdrop="static" 362 | className={classes.modal} 363 | > 364 | 365 | Share URL 366 | 367 | 368 | 369 | 370 | 371 | Use this URL to share this chart so other people can 372 | view/edit it in Chart Builder: 373 | 374 | 375 | 376 | 377 | getStateUrl(this.props.store)} /> 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | )} 387 | {this.saveModalOpen === 'ddwembed' && ( 388 | (this.saveModalOpen = false)} 390 | show 391 | backdrop="static" 392 | className={classes.modal} 393 | > 394 | 395 | Share URL 396 | 397 | 398 | 399 | 400 | 401 | Copy and paste this into any Markdown (Comments, Summaries, 402 | Insights) on data.world to render this chart: 403 | 404 | 405 | 406 | 407 | { 409 | const storeConfig = this.props.store.config 410 | const data = this.data || [] 411 | let spec = storeConfig.getSpecWithMinimumAmountOfData( 412 | data 413 | ) 414 | return '```vega-lite\n' + JSON.stringify(spec) 415 | }} 416 | /> 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | )} 426 | 427 | ) 428 | } 429 | } 430 | 431 | decorate(App, { 432 | // data: null, 433 | loading: observable, 434 | errorLoading: observable, 435 | saveModalOpen: observable, 436 | bytesDownloaded: observable 437 | }) 438 | 439 | export default inject('store')(observer(App)) 440 | -------------------------------------------------------------------------------- /src/views/App.module.css: -------------------------------------------------------------------------------- 1 | .topBar { 2 | background-color: #fff; 3 | border-bottom: 1px solid #dfdfdf; 4 | height: 3.25rem; 5 | position: relative; 6 | width: 100%; 7 | 8 | padding-left: 1rem; 9 | } 10 | .topBarButtons { 11 | flex-shrink: 0; 12 | } 13 | .topBarCol { 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | height: 3.25rem; 18 | padding-left: 0; 19 | } 20 | .topBarHeader { 21 | color: #4e5057; 22 | font-weight: 700; 23 | line-height: 1.25rem; 24 | flex-shrink: 0; 25 | flex: 1 1 auto; 26 | display: flex; 27 | } 28 | .topBarTitle { 29 | appearance: none; 30 | border: none; 31 | font-size: 1.25rem; 32 | padding: 0.5rem; 33 | margin-right: 1rem; 34 | flex: 1 1 auto; 35 | transition: outline-color 0.1s; 36 | 37 | outline-offset: -2px; 38 | outline: white auto 5px; 39 | } 40 | .topBarTitle:hover, 41 | .topBarTitle:focus { 42 | outline-color: rgb(59, 153, 252); 43 | } 44 | .topBarTitle::placeholder { 45 | color: #323d48; 46 | opacity: 0.5; 47 | font-style: italic; 48 | font-weight: normal; 49 | } 50 | 51 | .embed { 52 | display: flex; 53 | flex-grow: 1; 54 | flex-direction: column; 55 | min-width: 0px; 56 | } 57 | .main { 58 | flex-grow: 1; 59 | display: flex; 60 | } 61 | .dropdownButton + :global(.dropdown-menu) { 62 | min-width: 10rem; 63 | } 64 | -------------------------------------------------------------------------------- /src/views/AuthGate.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React, { Fragment, Component } from 'react' 3 | import { decorate, observable } from 'mobx' 4 | import { observer, inject } from 'mobx-react' 5 | import DevTools from 'mobx-react-devtools' 6 | import { Grid } from 'react-bootstrap' 7 | import { createParams } from '../util/util' 8 | import App from './App' 9 | import { 10 | REDIRECT_URI, 11 | CLIENT_ID, 12 | OAUTH_HOST, 13 | CLIENT_SECRET, 14 | API_HOST 15 | } from '../util/constants' 16 | import Header from '../components/Header' 17 | import LoadingAnimation from '../components/LoadingAnimation' 18 | import StateRestorationGate from './StateRestorationGate' 19 | import type { StoreType } from '../util/Store' 20 | 21 | // if no token, redirect to oauth 22 | // if token, call /user to verify token 23 | // if failure, redirect to oauth 24 | // if success, show app 25 | 26 | function generateAndStoreChallenge() { 27 | const challenge = 28 | Math.random() 29 | .toString(16) 30 | .substring(2) + 31 | Math.random() 32 | .toString(16) 33 | .substring(2) 34 | sessionStorage.setItem('oauth-challenge', challenge) 35 | return challenge 36 | } 37 | 38 | function getAndClearChallenge() { 39 | const challenge = sessionStorage.getItem('oauth-challenge') 40 | sessionStorage.removeItem('oauth-challenge') 41 | return challenge 42 | } 43 | 44 | function redirectToOauth() { 45 | const params = createParams({ 46 | client_id: CLIENT_ID, 47 | redirect_uri: REDIRECT_URI, 48 | response_type: 'code', 49 | code_challenge_method: 'plain', 50 | code_challenge: generateAndStoreChallenge(), 51 | state: encodeURIComponent(window.location.search) 52 | }) 53 | window.open(`${OAUTH_HOST}/oauth/authorize?${params.toString()}`, '_self') 54 | } 55 | 56 | async function fetchToken(code: string): Promise<{ access_token: string }> { 57 | const challenge = getAndClearChallenge() 58 | if (challenge == null) { 59 | throw new Error('no challenge in storage') 60 | } 61 | const params = createParams({ 62 | client_id: CLIENT_ID, 63 | client_secret: CLIENT_SECRET, 64 | grant_type: 'authorization_code', 65 | code, 66 | code_verifier: challenge 67 | }) 68 | const resp = await fetch(`${OAUTH_HOST}/oauth/access_token`, { 69 | method: 'POST', 70 | body: params 71 | }) 72 | if (!resp.ok) { 73 | throw new Error('cannot fetch token') 74 | } 75 | return resp.json() 76 | } 77 | 78 | async function verifyToken(token: string) { 79 | const resp = await fetch(`${API_HOST}/v0/user`, { 80 | method: 'GET', 81 | headers: { 82 | Accept: 'application/json', 83 | Authorization: `Bearer ${token}` 84 | } 85 | }) 86 | if (!resp.ok) { 87 | throw new Error('token not valid') 88 | } 89 | } 90 | 91 | class AuthGate extends Component<{ 92 | history: Object, 93 | location: Object, 94 | store: StoreType 95 | }> { 96 | hasValidToken: boolean = false 97 | 98 | componentDidMount() { 99 | const token = localStorage.getItem('token') 100 | const parsedParams = createParams(window.location.search) 101 | 102 | if (parsedParams.has('code')) { 103 | this.fetchToken() 104 | } else if (token != null) { 105 | this.verifyToken() 106 | } else { 107 | redirectToOauth() 108 | } 109 | } 110 | 111 | fetchToken = async () => { 112 | const parsedParams = createParams(window.location.search) 113 | const code = parsedParams.get('code') 114 | 115 | try { 116 | const data = await fetchToken(code) 117 | const token = data.access_token 118 | 119 | localStorage.setItem('token', token) 120 | this.props.store.setToken(token) 121 | this.props.history.push({ 122 | path: '/', 123 | search: decodeURIComponent(parsedParams.get('state')) 124 | }) 125 | this.hasValidToken = true 126 | } catch (e) { 127 | localStorage.removeItem('token') 128 | redirectToOauth() 129 | } 130 | } 131 | 132 | verifyToken = async () => { 133 | const token = localStorage.getItem('token') 134 | 135 | try { 136 | if (token == null || token === '') { 137 | throw new Error('no token') 138 | } 139 | await verifyToken(token) 140 | this.props.store.setToken(token) 141 | this.hasValidToken = true 142 | } catch (e) { 143 | localStorage.removeItem('token') 144 | redirectToOauth() 145 | } 146 | } 147 | 148 | render() { 149 | if (this.hasValidToken) { 150 | return ( 151 | 152 | 153 | 154 | ) 155 | } 156 | 157 | return ( 158 | 159 | {process.env.NODE_ENV === 'development' && } 160 |
    161 | 162 | 163 | 164 | 165 | ) 166 | } 167 | } 168 | 169 | decorate(AuthGate, { 170 | hasValidToken: observable 171 | }) 172 | 173 | export default inject('store')(observer(AuthGate)) 174 | -------------------------------------------------------------------------------- /src/views/StateRestorationGate.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react' 3 | import { restoreFromStateString } from '../util/urlState' 4 | import { createParams } from '../util/util' 5 | import type { Node } from 'react' 6 | import type { StoreType } from '../util/Store' 7 | 8 | type Props = { 9 | history: Object, 10 | location: Object, 11 | store: StoreType, 12 | children: Node 13 | } 14 | 15 | export default class StateRestorationGate extends Component { 16 | constructor(props: Props) { 17 | super(props) 18 | 19 | const { history, store } = this.props 20 | const params = createParams(props.history.location.search) 21 | if (params.has('s')) { 22 | try { 23 | restoreFromStateString(store, params.get('s'), { token: store.token }) 24 | history.replace({ 25 | pathname: '/', 26 | search: createParams({ 27 | dataset: store.dataset, 28 | query: store.query 29 | }).toString() 30 | }) 31 | } catch (e) {} 32 | } 33 | } 34 | 35 | render() { 36 | return this.props.children 37 | } 38 | } 39 | --------------------------------------------------------------------------------