From 4a99551034a3f3cced67352b2e9e076ce05154c6 Mon Sep 17 00:00:00 2001 From: Niels de Jong Date: Fri, 16 Dec 2022 20:08:07 +0100 Subject: [PATCH] 2.2.1 Release (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Master to dev Release (#259) * TableChart : Auto-hide columns prefixed with __ * Master to dev Release (#259) * Added prettier (config based on neo4j/nx repository) * Added eslint and husky (config based on neo4j/nx repository) * Updated eslint config to be as light as possible with Typescript * Updated all files with prettier and linter, refactored files to avoid errors * Added Eslint check step in Github workflows * Updated all files with prettier and linter after rebase on Develop branch * Squash Security Bumbs (#281) * Bump loader-utils from 2.0.2 to 2.0.4 in /gallery Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] * Bump loader-utils from 1.4.0 to 1.4.2 Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] * Update yarn.lock * yarn gallery util 3.2.1 * bump * remove lodash 4.17.15 lock * remove minimatch 3.0.4 lock * remove d3 color lock * remove node.fet color lock * ut * no node fetch * lod * lod2 * ncheck * d3 init Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Niels de Jong * Bump loader-utils from 2.0.2 to 2.0.4 in /gallery (#264) Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fixes #160 (#269) * Fix : Replace parameter in iFrame URLs (#276) * Fix : Replace parameter in iFrame URLs * Fix wrong merge conflict Co-authored-by: Marius Conjeaud * Bump loader-utils from 2.0.2 to 2.0.4 in /gallery (#290) * Bump loader-utils from 1.4.0 to 1.4.2 (#265) Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump loader-utils from 2.0.2 to 2.0.4 in /gallery Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Niels de Jong * Crash on Boolean options parameter selection (#285) * bug on non string values * Fix merge conflicts * Fix merge conflicts * Fix merge conflicts * Fix merge conflicts * Fix merge conflicts Co-authored-by: Niels de Jong * Hotfix for Neo4j container issues with 5.3 (#293) * Updated deployment scripts to use minimal build without source maps (#271) * Changed build script to use mimimal (no source map) deployment * Added TODOs based on comments * Changing card image download logic (#273) * feature(): Changing download logic for card download by downloading the entire card instead of just the view. This kind of change adds also the buttons to the downloaded image, that is not ideal. * fix(download report image): added missing ref for card expanded view * Removed package-lock.json Co-authored-by: Alfred Rubin Co-authored-by: Niels de Jong * Dynamic Card titles (#270) * change of names * Resolving conflicts * Bug fix * Refactoring * Fixed replacement of params in card headers Co-authored-by: Niels de Jong * Docs on custom map provider (#282) * Docs on custom map provider * Update docs/modules/ROOT/pages/user-guide/reports/map.adoc Co-authored-by: MariusC Co-authored-by: MariusC Co-authored-by: Niels de Jong * Added release notes, bumped version number Signed-off-by: dependabot[bot] Co-authored-by: Harold Agudelo Co-authored-by: Marius Conjeaud Co-authored-by: “Bastien <“bastien.hubert@hotmail.com”> Co-authored-by: Bastien Hubert <43408420+bastienhubert@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Neil Menezes <37837480+skoarkid@users.noreply.github.com> Co-authored-by: MariusC Co-authored-by: alfredorubin96 <103421036+alfredorubin96@users.noreply.github.com> Co-authored-by: Alfred Rubin --- .eslintrc.json | 170 ++ .github/workflows/develop-deployment.yml | 8 +- .github/workflows/develop-test.yml | 7 +- .github/workflows/master-deployment.yml | 17 +- .github/workflows/master-test.yml | 7 +- .husky/pre-commit | 4 + .lintstagedrc.json | 6 + .prettierignore | 4 + .prettierrc.json | 12 + Dockerfile | 4 +- README.md | 19 +- changelog.md | 23 + cypress/fixtures/cypher_queries.js | 29 +- cypress/integration/start_page.spec.js | 498 +++-- .../ROOT/pages/user-guide/reports/map.adoc | 2 + .../ROOT/pages/user-guide/reports/table.adoc | 3 + gallery/package.json | 6 - gallery/src/App.tsx | 132 +- gallery/src/index.tsx | 5 +- gallery/yarn.lock | 27 +- package.json | 42 +- release-notes.md | 30 +- scripts/config-entrypoint.sh | 3 +- .../docker-neo4j-initializer/docker-neo4j.sh | 2 +- src/application/Application.tsx | 281 ++- src/application/ApplicationActions.ts | 159 +- src/application/ApplicationReducer.ts | 393 ++-- src/application/ApplicationSelectors.ts | 120 +- src/application/ApplicationThunks.ts | 772 ++++--- src/card/Card.tsx | 475 ++-- src/card/CardActions.ts | 64 +- src/card/CardAddButton.tsx | 50 +- src/card/CardReducer.ts | 258 ++- src/card/CardSelectors.ts | 8 +- src/card/CardStyle.ts | 49 +- src/card/CardThunks.ts | 361 ++-- src/card/settings/CardSettings.tsx | 162 +- src/card/settings/CardSettingsContent.tsx | 225 +- src/card/settings/CardSettingsFooter.tsx | 272 +-- src/card/settings/CardSettingsHeader.tsx | 120 +- .../CardSettingsContentPropertySelect.tsx | 458 ++-- src/card/tests/README.md | 2 - src/card/tests/reducers.test.tsx | 25 - src/card/tests/selectors.test.tsx | 16 - src/card/tests/styled.test.tsx | 25 - src/card/tests/thunks.test.tsx | 28 - src/card/view/CardView.tsx | 301 ++- src/card/view/CardViewFooter.tsx | 244 ++- src/card/view/CardViewHeader.tsx | 246 ++- src/chart/Chart.ts | 22 +- src/chart/ChartUtils.ts | 447 ++-- src/chart/Utils.ts | 103 +- src/chart/bar/BarChart.tsx | 402 ++-- src/chart/graph/GraphChart.tsx | 931 ++++---- src/chart/iframe/IFrameChart.tsx | 50 +- src/chart/json/JSONChart.tsx | 29 +- src/chart/line/LineChart.tsx | 425 ++-- src/chart/map/MapChart.tsx | 711 +++--- src/chart/markdown/MarkdownChart.tsx | 29 +- .../parameter/ParameterSelectionChart.tsx | 227 +- src/chart/pie/PieChart.tsx | 250 ++- src/chart/single/SingleValueChart.tsx | 66 +- src/chart/table/TableChart.tsx | 261 ++- src/component/editor/CodeEditorComponent.tsx | 130 +- src/component/editor/CodeViewerComponent.tsx | 57 +- src/component/editor/CypherEditor.tsx | 101 +- src/component/field/ColorPicker.tsx | 50 +- src/component/field/Field.tsx | 100 +- src/component/field/Setting.tsx | 265 ++- .../misc/DashboardConnectionUpdateHandler.tsx | 24 +- src/component/sso/SSOLoginButton.tsx | 88 +- src/component/sso/SSOUtils.ts | 284 ++- src/config/CardConfig.ts | 19 +- src/config/ColorConfig.ts | 59 +- src/config/DashboardConfig.ts | 114 +- src/config/ExampleConfig.ts | 248 +-- src/config/PageConfig.ts | 3 +- src/config/ReportConfig.tsx | 1913 +++++++++-------- src/dashboard/Dashboard.tsx | 109 +- src/dashboard/DashboardActions.ts | 36 +- src/dashboard/DashboardReducer.ts | 186 +- src/dashboard/DashboardSelectors.ts | 14 +- src/dashboard/DashboardThunks.ts | 610 +++--- src/dashboard/drawer/DashboardDrawer.tsx | 282 +-- src/dashboard/header/DashboardHeader.tsx | 147 +- .../header/DashboardHeaderPageAddButton.tsx | 43 +- .../header/DashboardHeaderPageButton.tsx | 136 +- .../header/DashboardHeaderPageList.tsx | 324 +-- .../header/DashboardHeaderTitleBar.tsx | 169 +- .../placeholder/DashboardPlaceholder.tsx | 150 +- src/extensions/ExtensionConfig.ts | 59 +- src/extensions/ExtensionUtils.ts | 32 +- src/extensions/ExtensionsModal.tsx | 246 ++- .../AdvancedChartsExampleConfig.ts | 321 +-- .../AdvancedChartsReportConfig.tsx | 1376 ++++++------ src/extensions/advancedcharts/Utils.ts | 75 +- .../chart/choropleth/ChoroplethMapChart.tsx | 206 +- .../circlepacking/CirclePackingChart.tsx | 197 +- .../advancedcharts/chart/gauge/GaugeChart.tsx | 112 +- .../advancedcharts/chart/radar/RadarChart.tsx | 199 +- .../chart/sankey/SankeyChart.tsx | 415 ++-- .../chart/sunburst/SunburstChart.tsx | 204 +- .../chart/treemap/TreeMapChart.tsx | 200 +- .../styling/StyleRuleCreationModal.tsx | 649 +++--- src/extensions/styling/StyleRuleEvaluator.ts | 257 ++- src/index.tsx | 10 +- src/modal/AboutModal.tsx | 195 +- src/modal/ConnectionModal.tsx | 385 ++-- src/modal/DeletePageModal.tsx | 76 +- src/modal/GraphItemInspectModal.tsx | 91 +- src/modal/LoadModal.tsx | 457 ++-- src/modal/LoadSharedDashboardModal.tsx | 166 +- src/modal/ModalSelectors.tsx | 1 - src/modal/NotificationModal.tsx | 122 +- src/modal/ReportExamplesModal.tsx | 182 +- src/modal/ReportHelpModal.tsx | 86 +- src/modal/SaveModal.tsx | 485 +++-- src/modal/ShareModal.tsx | 687 +++--- src/modal/UpgradeOldDashboardModal.tsx | 100 +- src/modal/WelcomeScreenModal.tsx | 392 ++-- src/page/Page.tsx | 373 ++-- src/page/PageActions.ts | 21 +- src/page/PageReducer.ts | 228 +- src/page/PageSelectors.ts | 15 +- src/page/PageThunks.ts | 80 +- src/report/Report.tsx | 416 ++-- src/report/ReportQueryRunner.ts | 229 +- src/report/ReportRecordProcessing.tsx | 393 ++-- src/settings/SettingsActions.ts | 8 +- src/settings/SettingsModal.tsx | 135 +- src/settings/SettingsReducer.ts | 70 +- src/settings/SettingsSelectors.ts | 37 +- src/settings/SettingsThunks.ts | 128 +- src/store.ts | 20 +- webpack.config.js | 46 +- yarn.lock | 1471 +++++++++++-- 136 files changed, 15423 insertions(+), 12018 deletions(-) create mode 100644 .eslintrc.json create mode 100755 .husky/pre-commit create mode 100644 .lintstagedrc.json create mode 100644 .prettierignore create mode 100644 .prettierrc.json delete mode 100644 src/card/tests/README.md delete mode 100644 src/card/tests/reducers.test.tsx delete mode 100644 src/card/tests/selectors.test.tsx delete mode 100644 src/card/tests/styled.test.tsx delete mode 100644 src/card/tests/thunks.test.tsx diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..6f7a0eccf --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,170 @@ +{ + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "react"], + "extends": ["eslint:recommended", "prettier", "plugin:@typescript-eslint/recommended"], // this is optional + "env": { + "browser": true, + "node": true + }, + "settings": { + "react": { + "version": "detect" + } + }, + "ignorePatterns": ["node_modules/**", "packages/**/dist/**", "packages/**/coverage/**"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", // Off for v1 + "@typescript-eslint/ban-ts-comment": "off", // Off for v1 + "@typescript-eslint/no-empty-function": "off", // Off for v1 + "@typescript-eslint/no-unused-vars": [ + "error", + { "vars": "all", "varsIgnorePattern": "^_*", "args": "after-used", "argsIgnorePattern": "^_" } + ], + "array-callback-return": "off", // Off for v1 + "arrow-body-style": "off", + "block-scoped-var": "error", + "camelcase": "off", // Off for v1 + "consistent-return": "off", // Off for v1 + "consistent-this": ["error", "self"], + "constructor-super": "error", + "curly": ["error", "all"], + "default-case": "error", + "default-param-last": "off", // Off for v1 + "dot-notation": "error", + "eqeqeq": "off", // Off for v1 + "func-names": "error", + "func-style": [ + "error", + "declaration", + { + "allowArrowFunctions": true + } + ], + "grouped-accessor-pairs": "error", + "line-comment-position": "off", // Off for v1 + "lines-between-class-members": "error", + "max-depth": "error", + "max-len": [ + "off", // Off for v1 + { + "code": 120, + "comments": 120, + "ignoreUrls": true, + "ignoreTemplateLiterals": true + } + ], + "max-lines-per-function": ["off"], + "max-nested-callbacks": ["error", 5], + "max-statements": ["off"], + "max-statements-per-line": "error", + "no-alert": "off", // Off for v1 + "no-array-constructor": "error", + "no-await-in-loop": "off", // Off for v1 + "no-buffer-constructor": "error", + "no-caller": "error", + "no-confusing-arrow": "error", + "no-console": "warn", + "no-constructor-return": "error", + "no-constant-condition": "error", + "no-debugger": "warn", + "no-dupe-else-if": "error", + "no-else-return": "error", + "no-empty-function": [ + "off", // Off for v1 + { + "allow": ["constructors"] + } + ], + "no-eq-null": "off", // Off for V1 + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-label": "error", + "no-implicit-coercion": "error", + "no-implicit-globals": "error", + "no-implied-eval": "error", + "no-import-assign": "error", + "no-invalid-this": "off", + "no-iterator": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-magic-numbers": "off", + "no-multi-assign": "error", + "no-multi-str": "error", + "no-nested-ternary": "off", // Off for v1 + "no-new": "error", + "no-new-func": "error", + "no-new-object": "error", + "no-new-wrappers": "error", + "no-octal-escape": "error", + "no-param-reassign": "off", // Off for v1 + "no-path-concat": "error", + "no-plusplus": [ + "error", + { + "allowForLoopAfterthoughts": true + } + ], + "no-proto": "off", // Off for v1 + "no-restricted-globals": "error", + "no-return-assign": "error", + "no-return-await": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-setter-return": "error", + "no-sync": "error", + "no-tabs": "error", + "no-template-curly-in-string": "error", + "no-underscore-dangle": "off", // Off for v1 + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unused-expressions": "off", // Off for v1 + "no-useless-call": "error", + "no-useless-computed-key": "error", + "no-useless-concat": "off", // Off for v1 + "no-useless-rename": "error", + "no-useless-return": "error", + "no-var": "error", + "no-void": ["error", { "allowAsStatement": true }], + "one-var": ["error", "never"], + "operator-assignment": "error", + "padding-line-between-statements": "error", + "prefer-arrow-callback": "warn", + "prefer-const": "off", // Off for v1 + "prefer-destructuring": [ + // Off for v1 + "warn", + { + "VariableDeclarator": { + "array": true, + "object": true + }, + "AssignmentExpression": { + "array": false, + "object": false + } + } + ], + "prefer-numeric-literals": "warn", + "prefer-promise-reject-errors": "warn", + "prefer-rest-params": "warn", + "prefer-spread": "warn", + "prefer-template": "warn", + "radix": "off", // Off for v1 + "require-atomic-updates": "error", + "require-await": "warn", // Warn for v1 + "sort-keys": "off", + "spaced-comment": [ + "warn", + "always", + { + "markers": ["/"] + } + ], + "symbol-description": "error", + "yoda": "error" + } +} diff --git a/.github/workflows/develop-deployment.yml b/.github/workflows/develop-deployment.yml index 44edfe8d2..100d29938 100644 --- a/.github/workflows/develop-deployment.yml +++ b/.github/workflows/develop-deployment.yml @@ -1,9 +1,9 @@ name: Test/Deploy Develop - + on: push: branches: [ develop ] - + jobs: build-test: runs-on: ubuntu-latest @@ -25,6 +25,8 @@ jobs: ./scripts/docker-neo4j-initializer/start-movies-db.sh - run: rm -rf docs - run: yarn install + - name: Eslint check + run: yarn run lint - name: Cypress run uses: cypress-io/github-action@v2 with: @@ -44,7 +46,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: yarn install - - run: PRODUCTION=true && yarn run build + - run: yarn run build-minimal - name: Set AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: diff --git a/.github/workflows/develop-test.yml b/.github/workflows/develop-test.yml index 2e04ae1ff..9533af946 100644 --- a/.github/workflows/develop-test.yml +++ b/.github/workflows/develop-test.yml @@ -1,10 +1,9 @@ name: Test Develop - + on: pull_request: branches: [ develop ] - - + jobs: build-test: runs-on: ubuntu-latest @@ -25,6 +24,8 @@ jobs: chmod +x ./scripts/docker-neo4j-initializer/start-movies-db.sh ./scripts/docker-neo4j-initializer/start-movies-db.sh - run: yarn install + - name: Eslint check + run: yarn run lint - name: Cypress run uses: cypress-io/github-action@v2 with: diff --git a/.github/workflows/master-deployment.yml b/.github/workflows/master-deployment.yml index a6465136d..29df410c2 100644 --- a/.github/workflows/master-deployment.yml +++ b/.github/workflows/master-deployment.yml @@ -1,10 +1,9 @@ name: Test/Deploy Master - + on: push: branches: [ master ] - - + jobs: build-test: runs-on: ubuntu-latest @@ -26,6 +25,8 @@ jobs: ./scripts/docker-neo4j-initializer/start-movies-db.sh - run: rm -rf docs - run: yarn install + - name: Eslint check + run: yarn run lint - name: Cypress run uses: cypress-io/github-action@v2 with: @@ -46,7 +47,7 @@ jobs: # node-version: ${{ matrix.node-version }} # - run: rm -rf docs # - run: yarn install -# - run: yarn run build +# - run: yarn run build-minimal # - name: Set AWS credentials # uses: aws-actions/configure-aws-credentials@v1 # with: @@ -78,7 +79,7 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_USERNAME }}/neodash:2.2.0 + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_USERNAME }}/neodash:2.2.1 # build-npm: # needs: build-test # runs-on: ubuntu-latest @@ -92,8 +93,8 @@ jobs: # with: # node-version: ${{ matrix.node-version }} # - run: rm -rf docs -# - run: yarn install -# - run: yarn run build +# - run: yarn install-minimal +# - run: yarn run build-minimal # - run: curl ${{ secrets.INDEX_HTML_DEPLOYMENT_URL }} > dist/index.html # - run: npm pack # - run: rm -rf target @@ -128,7 +129,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: cd gallery && yarn install - - run: cd gallery && CI=false yarn run build + - run: cd gallery && yarn run build - name: Set AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: diff --git a/.github/workflows/master-test.yml b/.github/workflows/master-test.yml index 305296073..f1873f402 100644 --- a/.github/workflows/master-test.yml +++ b/.github/workflows/master-test.yml @@ -1,10 +1,9 @@ name: Test Master - + on: pull_request: branches: [ master ] - - + jobs: build-test: runs-on: ubuntu-latest @@ -25,6 +24,8 @@ jobs: chmod +x ./scripts/docker-neo4j-initializer/start-movies-db.sh ./scripts/docker-neo4j-initializer/start-movies-db.sh - run: yarn install + - name: Eslint check + run: yarn run lint - name: Cypress run uses: cypress-io/github-action@v2 with: diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..72c88ce0e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +yarn run lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 000000000..f58e15c3d --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,6 @@ +{ + "*.ts": ["prettier --write", "eslint --fix"], + "*.tsx": ["prettier --write", "eslint --fix"], + "*.json": ["prettier --write"], + "*.js": ["prettier --write"] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..4977096c5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +coverage +dist +node_modules +docs \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..50711f7df --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "printWidth": 120, + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "useTabs": false, + "tabWidth": 2, + "arrowParens": "always", + "trailingComma": "es5", + "bracketSpacing": true, + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile index 47816cb62..e355e4107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ COPY ./package.json /usr/local/src/neodash/package.json RUN yarn install COPY ./ /usr/local/src/neodash -RUN PRODUCTION=true && yarn run build +RUN yarn run build-minimal # production stage FROM nginx:alpine AS neodash @@ -38,4 +38,4 @@ RUN chown -R nginx:nginx /usr/share/nginx/html/ USER nginx EXPOSE 5005 HEALTHCHECK cmd curl --fail http://localhost:5005 || exit 1 -LABEL version="2.2.0" +LABEL version="2.2.1" diff --git a/README.md b/README.md index 257615412..eba063b2e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## NeoDash - Neo4j Dashboard Builder -NeoDash is an open source tool for visualizing your Neo4j data. It lets you group visualizations together as dashboards, and allow for interactions between reports. +NeoDash is an open source tool for visualizing your Neo4j data. It lets you group visualizations together as dashboards, and allow for interactions between reports. ![screenshot](public/screenshot.png) @@ -22,6 +22,23 @@ docker run -it --rm -p 5005:5005 nielsdejong/neodash See the [Developer Guide](https://neo4j.com/labs/neodash/2.1/developer-guide/) for more on installing, building, and running the application. +## Coding practices +In order to improve the code quality, we added a Prettier and a Linter to this repository. + +### Behavior +While commiting, a pre-commit hook will be executed in order to prettify and run the Linter on your staged files. Linter warnings are currently accepted. The commands executed by this hook can be found in /.lintstagedrc.json. + +There is also a dedicated linting step in the Github project pipeline in order to catch each potential inconsistency. + +**Don't hesitate to setup your IDE formatting feature to use the Prettier module and our defined rules (.prettierrc.json).** + +### Manual execution +If you want to **manually prettify all the project .ts and .tsx files**, you can run `yarn run format`. + +If you wan to **manually run linting of all your .ts and .tsx files**, you can run `yarn run lint`. + +If you wan to **manually run linting of all your .ts and .tsx staged files**, you can run `yarn run lint-staged`. + ## User Guide NeoDash comes with built-in examples of dashboards and reports. For more details on the types of reports and how to customize them, see the [User Guide]( https://neo4j.com/labs/neodash/2.1/user-guide/). diff --git a/changelog.md b/changelog.md index d87e33076..0084de213 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,26 @@ +## NeoDash 2.2.1 +This update provides a number of usability improves over the 2.2.0 release. +In addition, it entails various improvements to the codebase, including security patches on the dependencies. + +Table: +- Column names prefixed with `__` are now hidden in the table view. + +Map: +- Added documentation for adding a custom map provider. + +Parameter selector: +- Added support for boolean parameters. + +Editor: +- Parameters are now automatically replaced **inside report titles**. +- Image downloads now include the report title alongside the visualization. + +Others: +- Applied security patches for dependencies. +- Set test container for release pipeline to fixed version of Neo4j. +- Aligned code style / linting with Neo4j product standards. +- Updated Docker setup to inject `standaloneDashboardURL` into the application config. + ## NeoDash 2.2.0 This release marks the official arrival of **[Extensions](https://neo4j.com/labs/neodash/2.2/user-guide/extensions/)**, which provide a simple way of extending NeoDash with additional features. Adding your own features to NeoDash just became a lot easier! diff --git a/cypress/fixtures/cypher_queries.js b/cypress/fixtures/cypher_queries.js index e6815f1ad..3daab317e 100644 --- a/cypress/fixtures/cypher_queries.js +++ b/cypress/fixtures/cypher_queries.js @@ -1,13 +1,18 @@ -export const defaultCypherQuery = "MATCH (n) RETURN n LIMIT 25"; -export const tableCypherQuery = "MATCH (n:Movie) RETURN n.title AS title, n.released AS released LIMIT 8"; -export const barChartCypherQuery = "MATCH (n:Movie) RETURN n.released AS released, count(n.title) AS count LIMIT 5"; -export const mapChartCypherQuery = "UNWIND [{id: 'Tilburg', label: 'Cinema', point: point({latitude:51.59444886664065 , longitude:5.088862976119185})}, {id: 'Antwerp', label: 'Cinema', point: point({latitude:51.22065200961528 , longitude:4.414094044161085})}, \n" + -"{id: 'Brussels', label: 'Cinema', point: point({latitude:50.854284724408664, longitude:4.344177490986771})},{id: 'Cologne', label: 'Cinema', point: point({latitude:50.94247712506476 , longitude:6.9699327434361855 })}, \n" + -"{id: 'Nijmegen', label: 'Cinema', point: point({latitude:51.81283449474347 , longitude:5.866804797140869})},{start: 'Tilburg', end: 'Antwerp', type: 'ROUTE_TO', distance: '125km', id: 100}, {start: 'Antwerp', end: 'Brussels', type: 'ROUTE_TO', distance: '70km', id: 101}, \n" + -"{start: 'Brussels', end: 'Cologne', type: 'ROUTE_TO', distance: '259km', id: 102},{start: 'Cologne', end: 'Nijmegen', type: 'ROUTE_TO', distance: '180km', id: 103},{start: 'Nijmegen', end: 'Tilburg', type: 'ROUTE_TO', distance: '92km', id: 104}] as value RETURN value"; -export const sunburstChartCypherQuery = "UNWIND [{path: ['a', 'b'], value: 3}, {path: ['a', 'c'], value: 5},{path: ['a', 'd', 'e'], value: 2},{path: ['a', 'd', 'f'], value: 3}] as x RETURN x.path, x.value"; -export const sankeyChartCypherQuery = "WITH [ { path: { start: {labels: ['Person'], identity: 1, properties: {name: 'Jim'}}, end: {identity: 11}, length: 1, segments: [ { start: {labels: ['Person'], identity: 1, properties: {name: 'Jim'}}, relationship: {type: 'RATES', start: 1, end: 11, identity: 10001, properties: {value: 4.5}}, end: {labels: ['Movie'], identity: 11,properties: {title: 'The Matrix', released: 1999}} } ] }, person: 'Jim', movie: 'The Matrix', value: 4.5 }, { path: { start: {labels: ['Person'], identity: 2, properties: {name: 'Mike'}}, end: {identity: 11}, length: 1, segments: [ { start: {labels: ['Person'], identity: 2, properties: {name: 'Mike'}}, relationship: {type: 'RATES', start: 2, end: 11, identity: 10002, properties: {value: 3.8}}, end: {labels: ['Movie'], identity: 11,properties: {title: 'The Matrix', released: 1999}} } ] }, person: 'Mike', movie: 'The Matrix', value: 3.8 } ] as data UNWIND data as row RETURN row.path as Path" -export const gaugeChartCypherQuery = "RETURN 69"; -export const iFrameText = "https://www.wikipedia.org/"; +export const defaultCypherQuery = 'MATCH (n) RETURN n LIMIT 25'; +export const tableCypherQuery = + 'MATCH (n:Movie) RETURN n.title AS title, n.released AS released, id(n) AS __id LIMIT 8'; +export const barChartCypherQuery = 'MATCH (n:Movie) RETURN n.released AS released, count(n.title) AS count LIMIT 5'; +export const mapChartCypherQuery = + "UNWIND [{id: 'Tilburg', label: 'Cinema', point: point({latitude:51.59444886664065 , longitude:5.088862976119185})}, {id: 'Antwerp', label: 'Cinema', point: point({latitude:51.22065200961528 , longitude:4.414094044161085})}, \n" + + "{id: 'Brussels', label: 'Cinema', point: point({latitude:50.854284724408664, longitude:4.344177490986771})},{id: 'Cologne', label: 'Cinema', point: point({latitude:50.94247712506476 , longitude:6.9699327434361855 })}, \n" + + "{id: 'Nijmegen', label: 'Cinema', point: point({latitude:51.81283449474347 , longitude:5.866804797140869})},{start: 'Tilburg', end: 'Antwerp', type: 'ROUTE_TO', distance: '125km', id: 100}, {start: 'Antwerp', end: 'Brussels', type: 'ROUTE_TO', distance: '70km', id: 101}, \n" + + "{start: 'Brussels', end: 'Cologne', type: 'ROUTE_TO', distance: '259km', id: 102},{start: 'Cologne', end: 'Nijmegen', type: 'ROUTE_TO', distance: '180km', id: 103},{start: 'Nijmegen', end: 'Tilburg', type: 'ROUTE_TO', distance: '92km', id: 104}] as value RETURN value"; +export const sunburstChartCypherQuery = + "UNWIND [{path: ['a', 'b'], value: 3}, {path: ['a', 'c'], value: 5},{path: ['a', 'd', 'e'], value: 2},{path: ['a', 'd', 'f'], value: 3}] as x RETURN x.path, x.value"; +export const sankeyChartCypherQuery = + "WITH [ { path: { start: {labels: ['Person'], identity: 1, properties: {name: 'Jim'}}, end: {identity: 11}, length: 1, segments: [ { start: {labels: ['Person'], identity: 1, properties: {name: 'Jim'}}, relationship: {type: 'RATES', start: 1, end: 11, identity: 10001, properties: {value: 4.5}}, end: {labels: ['Movie'], identity: 11,properties: {title: 'The Matrix', released: 1999}} } ] }, person: 'Jim', movie: 'The Matrix', value: 4.5 }, { path: { start: {labels: ['Person'], identity: 2, properties: {name: 'Mike'}}, end: {identity: 11}, length: 1, segments: [ { start: {labels: ['Person'], identity: 2, properties: {name: 'Mike'}}, relationship: {type: 'RATES', start: 2, end: 11, identity: 10002, properties: {value: 3.8}}, end: {labels: ['Movie'], identity: 11,properties: {title: 'The Matrix', released: 1999}} } ] }, person: 'Mike', movie: 'The Matrix', value: 3.8 } ] as data UNWIND data as row RETURN row.path as Path"; +export const gaugeChartCypherQuery = 'RETURN 69'; +export const iFrameText = 'https://www.wikipedia.org/'; export const markdownText = '# Hello'; -export const loadDashboardURL = 'https://gist.githubusercontent.com/nielsdejong/ee33245256b471f92901ca4073b16ec1/raw/cfaae47e0fcdf430a5de6d0d8e3ac13cfd97742e/dashboard-cypress.json'; +export const loadDashboardURL = + 'https://gist.githubusercontent.com/nielsdejong/ee33245256b471f92901ca4073b16ec1/raw/cfaae47e0fcdf430a5de6d0d8e3ac13cfd97742e/dashboard-cypress.json'; diff --git a/cypress/integration/start_page.spec.js b/cypress/integration/start_page.spec.js index 570cbde59..e7a7dbbe2 100644 --- a/cypress/integration/start_page.spec.js +++ b/cypress/integration/start_page.spec.js @@ -1,241 +1,279 @@ -import { tableCypherQuery, barChartCypherQuery, mapChartCypherQuery, sunburstChartCypherQuery, iFrameText, markdownText, loadDashboardURL, sankeyChartCypherQuery, gaugeChartCypherQuery } from "../fixtures/cypher_queries" +import { + tableCypherQuery, + barChartCypherQuery, + mapChartCypherQuery, + sunburstChartCypherQuery, + iFrameText, + markdownText, + loadDashboardURL, + sankeyChartCypherQuery, + gaugeChartCypherQuery, +} from '../fixtures/cypher_queries'; // Ignore warnings that may appear when using the Cypress dev server Cypress.on('uncaught:exception', (err, runnable) => { - console.log(err, runnable); - return false; + console.log(err, runnable); + return false; }); describe('NeoDash E2E Tests', () => { - beforeEach(() => { - cy.clearLocalStorage() - cy.viewport(1920, 1080) - // Navigate to index - cy.visit('/') - cy.wait(1000) - cy.get('#form-dialog-title').should('contain', 'NeoDash - Neo4j Dashboard Builder') - cy.wait(300) - - // Create new dashboard - cy.contains('New Dashboard').click() - cy.wait(300) - - // If an old dashboard exists in cache, do a check to make sure we clear it. - // if (cy.contains("Create new dashboard")) { - // cy.contains('Yes').click() - // } - - cy.get('#form-dialog-title').should('contain', 'Connect to Neo4j') - - // Connect to Neo4j database - // cy.get('#protocol').click() - // cy.contains('neo4j').click() - cy.get('#url').clear().type('localhost') - cy.wait(100) - // cy.get('#database').type('neo4j') - cy.get('#dbusername').clear().type('neo4j') - cy.get('#dbpassword').type('test') - cy.wait(100) - cy.get('button').contains('Connect').click() - }) - - it('initializes the dashboard', () => { - // Check the starter cards - cy.get('main .react-grid-item:eq(0)').should('contain', "This is your first dashboard!") - cy.get('main .react-grid-item:eq(1) .force-graph-container canvas').should('be.visible') - cy.get('main .react-grid-item:eq(2) button').should('have.attr', 'aria-label', 'add') - }) - - it('creates a new card', () => { - cy.get('main .react-grid-item:eq(2) button').click() - cy.get('main .react-grid-item:eq(2)').should('contain', 'No query specified.') - }) - - // Test each type of card - it('creates a table report', () => { - cy.get('main .react-grid-item:eq(2) button').click() - cy.get('main .react-grid-item:eq(2) button[aria-label="settings"]').click() - cy.get('main .react-grid-item:eq(2) .MuiInputLabel-root').contains("Type").next().should('contain', 'Table') - cy.get('main .react-grid-item:eq(2) .ReactCodeMirror').type(tableCypherQuery) - cy.get('main .react-grid-item:eq(2) button[aria-label="save"]').click() - cy.get('main .react-grid-item:eq(2) .MuiDataGrid-columnHeaders').should('contain', 'title').and('contain', 'released') - cy.get('main .react-grid-item:eq(2) .MuiDataGrid-virtualScroller .MuiDataGrid-row').should('have.length', 5) - cy.get('main .react-grid-item:eq(2) .MuiDataGrid-footerContainer').should('contain', '1–5 of 8') - cy.get('main .react-grid-item:eq(2) .MuiDataGrid-footerContainer button[aria-label="Go to next page"]').click() - cy.get('main .react-grid-item:eq(2) .MuiDataGrid-virtualScroller .MuiDataGrid-row').should('have.length', 3) - cy.get('main .react-grid-item:eq(2) .MuiDataGrid-footerContainer').should('contain', '6–8 of 8') - }) - - it('creates a bar chart report', () => { - createReportOfType('Bar Chart', barChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Category').next() - .should('contain', 'released') - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Value').next() - .should('contain', 'count') - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 8) - }) - - it('creates a pie chart report', () => { - createReportOfType('Pie Chart', barChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Category').next() - .should('contain', 'released') - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Value').next() - .should('contain', 'count') - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 3) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g:nth-child(2) > path').should('have.length', 5) - }) - - it('creates a line chart report', () => { - createReportOfType('Line Chart', barChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('X-value').next() - .should('contain', 'released') - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Y-value').next() - .should('contain', 'count') - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 6) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g:nth-child(2) > line').should('have.length', 11) - }) - - it('creates a map chart report', () => { - createReportOfType('Map', mapChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > path').should('have.length', 5) - }) - - it('creates a single value report', () => { - createReportOfType('Single Value', barChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root > div > div:nth-child(2) > span').contains('1,999') - }) - - it('creates a gauge chart report', () => { - enableAdvancedVisualizations() - createReportOfType('Gauge Chart', gaugeChartCypherQuery) - cy.get('.text-group > text').contains('69') - }) - - it('creates a sunburst chart report', () => { - enableAdvancedVisualizations() - createReportOfType('Sunburst Chart', sunburstChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Path').next() - .should('contain', 'x.path') - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Value').next() - .should('contain', 'x.value') - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g:nth-child(1) > path').should('have.length', 5) - }) - - it('creates a circle packing report', () => { - enableAdvancedVisualizations() - createReportOfType('Circle Packing', sunburstChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Path').next() - .should('contain', 'x.path') - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Value').next() - .should('contain', 'x.value') - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > circle').should('have.length', 6) - }) - - it('creates a tree map report', () => { - enableAdvancedVisualizations() - createReportOfType('Treemap', sunburstChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Path').next() - .should('contain', 'x.path') - cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root').contains('Value').next() - .should('contain', 'x.value') - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 6) - }) - - it('creates a sankey chart report', () => { - enableAdvancedVisualizations() - createReportOfType('Sankey Chart', sankeyChartCypherQuery, true) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > path').should('have.attr', 'fill-opacity', 0.5) - }) - - it('creates a raw json report', () => { - createReportOfType('Raw JSON', barChartCypherQuery) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root textarea:nth-child(1)').should(($div) => { - const text = $div.text() - expect(text.length).to.eq(1387) - }) - }) - - it('creates a parameter select report', () => { - cy.get('main .react-grid-item:eq(2) button').click() - cy.get('main .react-grid-item:eq(2) button[aria-label="settings"]').click() - cy.get('main .react-grid-item:eq(2) .MuiInputLabel-root').contains("Type").next().click() - cy.contains('Parameter Select').click() - cy.wait(300) - cy.get('#autocomplete-label-type').type('Movie') - cy.get('#autocomplete-label-type-option-0').click() - cy.wait(300) - cy.get('#autocomplete-property').type('title') - cy.get('#autocomplete-property-option-0').click() - cy.get('main .react-grid-item:eq(2) button[aria-label="save"]').click() - cy.get('#autocomplete').type('The Matrix') - cy.get('#autocomplete-option-0').click() - }) - - it('creates an iframe report', () => { - createReportOfType('iFrame', iFrameText) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root iframe') - }) - - it('creates a markdown report', () => { - createReportOfType('Markdown', markdownText) - cy.get('main .react-grid-item:eq(2) .MuiCardContent-root h1').should('have.text', 'Hello') - }) - - // it('creates a radar report', () => { - // // TODO - create a test for radar. - // }) - - - // it('creates a sankey report', () => { - // // TODO - create a test for sankey charts. - // }) - - // Test load stress-test dashboard from file - // TODO - this test is flaky, especially in GitHub actions environment. - it.skip('test load dashboard from file and stress test report customizations', () => { - try { - var NUMBER_OF_PAGES_IN_STRESS_TEST_DASHBOARD = 5; - const file = cy.request(loadDashboardURL).should((response) => { - - cy.get('#root .MuiDrawer-root .MuiIconButton-root:eq(2)').click() - cy.get('.MuiDialog-root .MuiPaper-root .MuiDialogContent-root textarea:eq(0)').invoke('val', response.body).trigger('change') - cy.get('.MuiDialog-root .MuiPaper-root .MuiDialogContent-root textarea:eq(0)').type(' ') - cy.get('.MuiDialog-root .MuiDialogContent-root .MuiButtonBase-root:eq(2)').click() - cy.wait(2500) - - // Click on each page and wait ~3 seconds for it to load completely - for (let i = 1; i < NUMBER_OF_PAGES_IN_STRESS_TEST_DASHBOARD; i++) { - cy.get('.MuiAppBar-root .react-grid-item:eq(' + i + ')').click() - cy.wait(3000) - } - }) - - } catch (e) { - console.log("Unable to fetch test dashboard. Skipping test."); + beforeEach(() => { + cy.clearLocalStorage(); + cy.viewport(1920, 1080); + // Navigate to index + cy.visit('/'); + cy.wait(1000); + cy.get('#form-dialog-title').should('contain', 'NeoDash - Neo4j Dashboard Builder'); + cy.wait(300); + + // Create new dashboard + cy.contains('New Dashboard').click(); + cy.wait(300); + + // If an old dashboard exists in cache, do a check to make sure we clear it. + // if (cy.contains("Create new dashboard")) { + // cy.contains('Yes').click() + // } + + cy.get('#form-dialog-title').should('contain', 'Connect to Neo4j'); + + // Connect to Neo4j database + // cy.get('#protocol').click() + // cy.contains('neo4j').click() + cy.get('#url').clear().type('localhost'); + cy.wait(100); + // cy.get('#database').type('neo4j') + cy.get('#dbusername').clear().type('neo4j'); + cy.get('#dbpassword').type('test'); + cy.wait(100); + cy.get('button').contains('Connect').click(); + }); + + it('initializes the dashboard', () => { + // Check the starter cards + cy.get('main .react-grid-item:eq(0)').should('contain', 'This is your first dashboard!'); + cy.get('main .react-grid-item:eq(1) .force-graph-container canvas').should('be.visible'); + cy.get('main .react-grid-item:eq(2) button').should('have.attr', 'aria-label', 'add'); + }); + + it('creates a new card', () => { + cy.get('main .react-grid-item:eq(2) button').click(); + cy.get('main .react-grid-item:eq(2)').should('contain', 'No query specified.'); + }); + + // Test each type of card + it('creates a table report', () => { + cy.get('main .react-grid-item:eq(2) button').click(); + cy.get('main .react-grid-item:eq(2) button[aria-label="settings"]').click(); + cy.get('main .react-grid-item:eq(2) .MuiInputLabel-root').contains('Type').next().should('contain', 'Table'); + cy.get('main .react-grid-item:eq(2) .ReactCodeMirror').type(tableCypherQuery); + cy.get('main .react-grid-item:eq(2) button[aria-label="save"]').click(); + cy.get('main .react-grid-item:eq(2) .MuiDataGrid-columnHeaders') + .should('contain', 'title') + .and('contain', 'released') + .and('not.contain', '__id'); + cy.get('main .react-grid-item:eq(2) .MuiDataGrid-virtualScroller .MuiDataGrid-row').should('have.length', 5); + cy.get('main .react-grid-item:eq(2) .MuiDataGrid-footerContainer').should('contain', '1–5 of 8'); + cy.get('main .react-grid-item:eq(2) .MuiDataGrid-footerContainer button[aria-label="Go to next page"]').click(); + cy.get('main .react-grid-item:eq(2) .MuiDataGrid-virtualScroller .MuiDataGrid-row').should('have.length', 3); + cy.get('main .react-grid-item:eq(2) .MuiDataGrid-footerContainer').should('contain', '6–8 of 8'); + }); + + it('creates a bar chart report', () => { + createReportOfType('Bar Chart', barChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Category') + .next() + .should('contain', 'released'); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Value') + .next() + .should('contain', 'count'); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 8); + }); + + it('creates a pie chart report', () => { + createReportOfType('Pie Chart', barChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Category') + .next() + .should('contain', 'released'); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Value') + .next() + .should('contain', 'count'); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 3); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g:nth-child(2) > path').should('have.length', 5); + }); + + it('creates a line chart report', () => { + createReportOfType('Line Chart', barChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('X-value') + .next() + .should('contain', 'released'); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Y-value') + .next() + .should('contain', 'count'); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 6); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g:nth-child(2) > line').should( + 'have.length', + 11 + ); + }); + + it('creates a map chart report', () => { + createReportOfType('Map', mapChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > path').should('have.length', 5); + }); + + it('creates a single value report', () => { + createReportOfType('Single Value', barChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root > div > div:nth-child(2) > span').contains('1,999'); + }); + + it('creates a gauge chart report', () => { + enableAdvancedVisualizations(); + createReportOfType('Gauge Chart', gaugeChartCypherQuery); + cy.get('.text-group > text').contains('69'); + }); + + it('creates a sunburst chart report', () => { + enableAdvancedVisualizations(); + createReportOfType('Sunburst Chart', sunburstChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Path') + .next() + .should('contain', 'x.path'); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Value') + .next() + .should('contain', 'x.value'); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g:nth-child(1) > path').should('have.length', 5); + }); + + it('creates a circle packing report', () => { + enableAdvancedVisualizations(); + createReportOfType('Circle Packing', sunburstChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Path') + .next() + .should('contain', 'x.path'); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Value') + .next() + .should('contain', 'x.value'); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > circle').should('have.length', 6); + }); + + it('creates a tree map report', () => { + enableAdvancedVisualizations(); + createReportOfType('Treemap', sunburstChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Path') + .next() + .should('contain', 'x.path'); + cy.get('main .react-grid-item:eq(2) .MuiCardActions-root .MuiInputLabel-root') + .contains('Value') + .next() + .should('contain', 'x.value'); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > g').should('have.length', 6); + }); + + it('creates a sankey chart report', () => { + enableAdvancedVisualizations(); + createReportOfType('Sankey Chart', sankeyChartCypherQuery, true); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root svg > g > path').should('have.attr', 'fill-opacity', 0.5); + }); + + it('creates a raw json report', () => { + createReportOfType('Raw JSON', barChartCypherQuery); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root textarea:nth-child(1)').should(($div) => { + const text = $div.text(); + expect(text.length).to.eq(1387); + }); + }); + + it('creates a parameter select report', () => { + cy.get('main .react-grid-item:eq(2) button').click(); + cy.get('main .react-grid-item:eq(2) button[aria-label="settings"]').click(); + cy.get('main .react-grid-item:eq(2) .MuiInputLabel-root').contains('Type').next().click(); + cy.contains('Parameter Select').click(); + cy.wait(300); + cy.get('#autocomplete-label-type').type('Movie'); + cy.get('#autocomplete-label-type-option-0').click(); + cy.wait(300); + cy.get('#autocomplete-property').type('title'); + cy.get('#autocomplete-property-option-0').click(); + cy.get('main .react-grid-item:eq(2) button[aria-label="save"]').click(); + cy.get('#autocomplete').type('The Matrix'); + cy.get('#autocomplete-option-0').click(); + }); + + it('creates an iframe report', () => { + createReportOfType('iFrame', iFrameText); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root iframe'); + }); + + it('creates a markdown report', () => { + createReportOfType('Markdown', markdownText); + cy.get('main .react-grid-item:eq(2) .MuiCardContent-root h1').should('have.text', 'Hello'); + }); + + // it('creates a radar report', () => { + // // TODO - create a test for radar. + // }) + + // it('creates a sankey report', () => { + // // TODO - create a test for sankey charts. + // }) + + // Test load stress-test dashboard from file + // TODO - this test is flaky, especially in GitHub actions environment. + it.skip('test load dashboard from file and stress test report customizations', () => { + try { + var NUMBER_OF_PAGES_IN_STRESS_TEST_DASHBOARD = 5; + const file = cy.request(loadDashboardURL).should((response) => { + cy.get('#root .MuiDrawer-root .MuiIconButton-root:eq(2)').click(); + cy.get('.MuiDialog-root .MuiPaper-root .MuiDialogContent-root textarea:eq(0)') + .invoke('val', response.body) + .trigger('change'); + cy.get('.MuiDialog-root .MuiPaper-root .MuiDialogContent-root textarea:eq(0)').type(' '); + cy.get('.MuiDialog-root .MuiDialogContent-root .MuiButtonBase-root:eq(2)').click(); + cy.wait(2500); + + // Click on each page and wait ~3 seconds for it to load completely + for (let i = 1; i < NUMBER_OF_PAGES_IN_STRESS_TEST_DASHBOARD; i++) { + cy.get('.MuiAppBar-root .react-grid-item:eq(' + i + ')').click(); + cy.wait(3000); } - }) - -}) + }); + } catch (e) { + console.log('Unable to fetch test dashboard. Skipping test.'); + } + }); +}); -function enableAdvancedVisualizations(){ - cy.get('#extensions-sidebar-button').click() - cy.wait(100) - cy.get('#checkbox-advanced-charts').click() - cy.wait(100) - cy.get('#extensions-modal-close-button').click() - cy.wait(200) +function enableAdvancedVisualizations() { + cy.get('#extensions-sidebar-button').click(); + cy.wait(100); + cy.get('#checkbox-advanced-charts').click(); + cy.wait(100); + cy.get('#extensions-modal-close-button').click(); + cy.wait(200); } -function createReportOfType(type, query, fast=false) { - cy.get('main .react-grid-item:eq(2) button').click() - cy.get('main .react-grid-item:eq(2) button[aria-label="settings"]').click() - cy.get('main .react-grid-item:eq(2) .MuiInputLabel-root').contains("Type").next().click() - cy.contains(type).click() - if(fast){ - cy.get('main .react-grid-item:eq(2) .ReactCodeMirror').type(query, { delay:1, parseSpecialCharSequences: false }) - }else{ - cy.get('main .react-grid-item:eq(2) .ReactCodeMirror').type(query, { parseSpecialCharSequences: false }) - } - - cy.get('main .react-grid-item:eq(2) button[aria-label="save"]').click() +function createReportOfType(type, query, fast = false) { + cy.get('main .react-grid-item:eq(2) button').click(); + cy.get('main .react-grid-item:eq(2) button[aria-label="settings"]').click(); + cy.get('main .react-grid-item:eq(2) .MuiInputLabel-root').contains('Type').next().click(); + cy.contains(type).click(); + if (fast) { + cy.get('main .react-grid-item:eq(2) .ReactCodeMirror').type(query, { delay: 1, parseSpecialCharSequences: false }); + } else { + cy.get('main .react-grid-item:eq(2) .ReactCodeMirror').type(query, { parseSpecialCharSequences: false }); + } + + cy.get('main .react-grid-item:eq(2) button[aria-label="save"]').click(); } diff --git a/docs/modules/ROOT/pages/user-guide/reports/map.adoc b/docs/modules/ROOT/pages/user-guide/reports/map.adoc index 5dee80ec1..79c395e01 100644 --- a/docs/modules/ROOT/pages/user-guide/reports/map.adoc +++ b/docs/modules/ROOT/pages/user-guide/reports/map.adoc @@ -109,6 +109,8 @@ relationship property to map to the arrow width. This lets you define widths on a relationship-specific level, if you have a property that directly maps to the width value. +|Map Provider URL|Text|https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png| When specified, overrides Open Street map provider with a custom map tiles provider. + |Intensity Property (for heatmap)|Text|intensity|Optionally, and only for heatmaps, the node property to use as the intensity of that point on the heatmap. If left empty, all points will have the same intensity of 1. If one of the nodes in the results doesn't have the specific property, its intensity will be set to 0. |Hide Property Selection |on/off |off |If enabled, hides the property diff --git a/docs/modules/ROOT/pages/user-guide/reports/table.adoc b/docs/modules/ROOT/pages/user-guide/reports/table.adoc index 435b2904d..91882d678 100644 --- a/docs/modules/ROOT/pages/user-guide/reports/table.adoc +++ b/docs/modules/ROOT/pages/user-guide/reports/table.adoc @@ -9,6 +9,7 @@ The table report supports the following additional features: - automatic pagination of results. - Sorting/filtering by clicking on the table headers. +- Prefixing a column header with __ (double underscore) will make the column hidden - Downloading your data as a CSV file. Double-clicking on a table cell will copy that cell's value to the user's clipboard. @@ -66,3 +67,5 @@ following style rules can be applied to the table: - The text color of an entire row in a table. - The background color of a single cell in the table. - The text color of a single cell in the table. + +If a column is hidden (header prefixed with __ double underscore), it can still be used as an entry point for a styling rule. diff --git a/gallery/package.json b/gallery/package.json index fea72c3b9..c31a9525b 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -25,12 +25,6 @@ "test": "react-scripts test", "eject": "react-scripts eject" }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] - }, "browserslist": { "production": [ ">0.2%", diff --git a/gallery/src/App.tsx b/gallery/src/App.tsx index b6b925352..207942b95 100644 --- a/gallery/src/App.tsx +++ b/gallery/src/App.tsx @@ -3,98 +3,138 @@ import './App.css'; import { Button, TextInput, HeroIcon, Tag } from '@neo4j-ndl/react'; // These are the read-only credentials of the public database where the gallery exists. -const uri = "neo4j+s://acb5b6ae.databases.neo4j.io" -const user = "gallery"; -const password = "gallery"; +const uri = 'neo4j+s://acb5b6ae.databases.neo4j.io'; +const user = 'gallery'; +const password = 'gallery'; async function loadDashboards(setResults: any) { - const neo4j = require('neo4j-driver') - const driver = neo4j.driver(uri, neo4j.auth.basic(user, password)) + // eslint-disable-next-line @typescript-eslint/no-var-requires + const neo4j = require('neo4j-driver'); + const driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); const session = driver.session(); try { const result = await session.run( 'MATCH (n:_Neodash_Dashboard) RETURN properties(n) as entry ORDER BY entry.index ASC' - ) - setResults(result.records.map((r: { _fields: any; }) => { return r._fields[0] })); + ); + setResults( + result.records.map((r: { _fields: any }) => { + return r._fields[0]; + }) + ); } finally { - await session.close() + await session.close(); } - await driver.close() + await driver.close(); } function App() { - const [searchText, setSearchText] = React.useState(""); + const [searchText, setSearchText] = React.useState(''); const [list, setList] = React.useState([]); if (list.length == 0) { loadDashboards(setList); } - const filteredList = list.filter((item: { title: string, author: string, description: string, keywords: any }) => - item['keywords'] && (item['title'] + " " + item['author'] + " " + item['description'] + " " + item['keywords']).toLowerCase().includes(searchText.toLowerCase())); + const filteredList = list.filter( + (item: { title: string; author: string; description: string; keywords: any }) => + item.keywords && + `${item.title} ${item.author} ${item.description} ${item.keywords}` + .toLowerCase() + .includes(searchText.toLowerCase()) + ); return ( -
+
{/* Header */}
-
+

NeoDash Dashboard Gallery 🎨

-

This page contains a set of sample NeoDash dashboards built on public data.

-

This gallery is created and maintained by the NeoDash community.

+

+ This page contains a set of sample NeoDash dashboards built on public data. +

+

+ This gallery is created and maintained by the NeoDash community. +

setSearchText(e.target.value)} - leftIcon={} - placeholder="Filter Dashboards..." - rightIcon={} + onChange={(e) => setSearchText(e.target.value)} + leftIcon={} + placeholder='Filter Dashboards...' + rightIcon={} />
{/* Grid */} -
-
- { - filteredList.map(item => { - return
-
-

{item['language']}{item['logo'] ? : <>}

-

{item['title']}

-

- {item['description']} +

+
+ {filteredList.map((item) => { + return ( +
+
+

+ {item.language} + {item.logo ? ( + + + + ) : ( + <> + )} +

+

{item.title}

+

+ {item.description}
- Author: {item['author']} + Author:{' '} + + {item.author} +

- {("" + item['keywords']).split(' ').map(k => {k})} - + + {`${item.keywords}`.split(' ').map((k) => ( + {k} + ))} + +
- }) - } + ); + })}
- {(list.length == 0) ?

Loading...

: <>} - {(list.length != 0 && filteredList.length == 0) ?

No results.

: <>} + {list.length == 0 ?

Loading...

: <>} + {list.length != 0 && filteredList.length == 0 ? ( +

No results.

+ ) : ( + <> + )}
{/* Footer */}
-
-

Want to add a dashboard to this gallery? Check out the +

+

+ Want to add a dashboard to this gallery? Check out the

on GitHub. + + Guidelines + + + on GitHub.


- {"-- neodash-gallery v0.2 --"} + {'-- neodash-gallery v0.2 --'}
-
+
); } -export default App; \ No newline at end of file +export default App; diff --git a/gallery/src/index.tsx b/gallery/src/index.tsx index ce8d53f71..868678a11 100644 --- a/gallery/src/index.tsx +++ b/gallery/src/index.tsx @@ -8,10 +8,7 @@ import reportWebVitals from './reportWebVitals'; // the app so all of our components can inherit the styles import '@neo4j-ndl/base/lib/neo4j-ds-styles.css'; - -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( diff --git a/gallery/yarn.lock b/gallery/yarn.lock index 39a5d17e6..3aa91a151 100644 --- a/gallery/yarn.lock +++ b/gallery/yarn.lock @@ -2968,7 +2968,7 @@ bfj@^7.0.2: big.js@^5.2.2: version "5.2.2" - resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== binary-extensions@^2.0.0: @@ -3009,7 +3009,7 @@ bonjour-service@^1.0.11: fast-deep-equal "^3.1.3" multicast-dns "^7.2.5" -boolbase@^1.0.0, boolbase@~1.0.0: +boolbase@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz" integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== @@ -4000,7 +4000,7 @@ emoji-regex@^9.2.2: emojis-list@^3.0.0: version "3.0.0" - resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== encodeurl@~1.0.2: @@ -6225,18 +6225,18 @@ loader-runner@^4.2.0: integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== loader-utils@^2.0.0: - version "2.0.2" - resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz" - integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A== + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" json5 "^2.1.2" loader-utils@^3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.0.tgz" - integrity sha512-HVl9ZqccQihZ7JM85dco1MvO9G+ONvxoGa9rkhzFsneGLKSUg1gJf9bWzhRhcvm2qChhWpebQhP44qxjKIUCaQ== + version "3.2.1" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576" + integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw== locate-path@^3.0.0: version "3.0.0" @@ -6441,7 +6441,7 @@ minimalistic-assert@^1.0.0: minimatch@3.0.4: version "3.0.4" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== dependencies: brace-expansion "^1.1.7" @@ -6598,13 +6598,6 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -nth-check@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz" - integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== - dependencies: - boolbase "~1.0.0" - nth-check@^2.0.1: version "2.1.1" resolved "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz" diff --git a/package.json b/package.json index c49928c09..3b8a308fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neodash", - "version": "2.2.0", + "version": "2.2.1", "description": "NeoDash - Neo4j Dashboard Builder", "neo4jDesktop": { "apiVersion": "^1.2.0" @@ -24,6 +24,11 @@ "dev": "yarn webpack-dev-server --mode development", "debug": "yarn --node-options='--inspect' webpack-dev-server --mode development", "build": "yarn webpack --mode production && cp -r public/* dist/", + "build-minimal": "yarn webpack --mode production --env production && cp -r public/* dist/", + "format": "prettier --write \"**/*.{ts,tsx}\"", + "lint": "eslint --ext .ts --ext .tsx .", + "lint-staged": "lint-staged --config .lintstagedrc.json", + "prepare": "husky install", "test": "yarn cypress open", "test-headless": "yarn cypress run" }, @@ -36,16 +41,16 @@ "@material-ui/styles": "^4.11.4", "@mui/material": "^5.3.0", "@mui/x-data-grid": "5.10.0", - "@nivo/bar": "^0.79.1", - "@nivo/circle-packing": "^0.79.1", - "@nivo/core": "^0.79.0", - "@nivo/geo": "^0.79.1", - "@nivo/line": "^0.79.1", - "@nivo/pie": "^0.79.1", - "@nivo/radar": "^0.79.1", - "@nivo/sankey": "^0.79.1", - "@nivo/sunburst": "^0.79.1", - "@nivo/treemap": "^0.79.1", + "@nivo/bar": "^0.80.0", + "@nivo/circle-packing": "^0.80.0", + "@nivo/core": "^0.80.0", + "@nivo/geo": "^0.80.0", + "@nivo/line": "^0.80.0", + "@nivo/pie": "^0.80.0", + "@nivo/radar": "^0.80.0", + "@nivo/sankey": "^0.80.0", + "@nivo/sunburst": "^0.80.0", + "@nivo/treemap": "^0.80.0", "babel-runtime": "^6.26.0", "classnames": "^2.3.1", "codemirror": "^5.65.3", @@ -61,6 +66,7 @@ "react-cool-dimensions": "^2.0.7", "react-dom": "^17.0.2", "react-force-graph-2d": "^1.23.8", + "react-gauge-chart": "^0.4.0", "react-grid-layout": "^1.3.4", "react-leaflet": "^3.2.5", "react-leaflet-cluster": "^1.0.4", @@ -72,8 +78,7 @@ "redux-thunk": "^2.4.1", "remark-gfm": "^3.0.1", "reselect": "^4.1.5", - "use-neo4j": "^0.3.13", - "react-gauge-chart": "^0.4.0" + "use-neo4j": "^0.3.13" }, "devDependencies": { "@babel/cli": "^7.16.8", @@ -86,15 +91,26 @@ "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@redux-devtools/extension": "^3.2.3", + "@typescript-eslint/eslint-plugin": "^5.42.0", + "@typescript-eslint/parser": "^5.42.0", "babel-loader": "^8.2.3", "css-loader": "^3.6.0", "cypress": "^9.5.3", + "eslint": "^8.26.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-react": "^7.30.1", + "eslint-plugin-react-hooks": "^4.6.0", "file-loader": "^6.2.0", + "husky": "^8.0.1", + "lint-staged": "^13.0.3", + "prettier": "^2.7.1", "react-hot-loader": "^4.13.0", "serverless-finch": "^4.0.0", "source-map-loader": "^4.0.0", "style-loader": "^1.1.3", "styled-components": "^5.3.3", + "typescript": "^4.8.4", "webpack": "^5.67.0", "webpack-cli": "^4.9.1", "webpack-dev-server": "^4.7.3" diff --git a/release-notes.md b/release-notes.md index c59cae137..f34048c70 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,14 +1,22 @@ -## NeoDash 2.2.0 -This release marks the official arrival of **[Extensions](https://neo4j.com/labs/neodash/2.2/user-guide/extensions/)**, which provide a simple way of extending NeoDash with additional features. Adding your own features to NeoDash just became a lot easier! +## NeoDash 2.2.1 +This update provides a number of usability improves over the 2.2.0 release. +In addition, it entails various improvements to the codebase, including security patches on the dependencies. -NeoDash 2.2 comes with three in-built extensions. -- **Rule-Based Styling** -- **Advanced Visualizations**: These provide a means to enable complex visualizations in a dashboard. These were previously available as Radar charts, Treemaps, Circle Packing reports, Sankey charts, Choropleth and a Gauge Chart). -- **Report Actions**: Which let you create interactivity in dashboards, using the output of one report as input for another visualization. (Pro Extension) +Table: +- Column names prefixed with `__` are now hidden in the table view. + +Map: +- Added documentation for adding a custom map provider. -You can enable extensions by clicking the 🧩 icon on the left sidebar of the screen. +Parameter selector: +- Added support for boolean parameters. -Other changes include: -- New example dashboards available in the [Dashboard Gallery](https://neodash-gallery.graphapp.io). -- Customizable background colors for all report types. -- Fixing a bug where the Choropleth map chart was unable to parse country-codes. +Editor: +- Parameters are now automatically replaced **inside report titles**. +- Image downloads now include the report title alongside the visualization. + +Others: +- Applied security patches for dependencies. +- Set test container for release pipeline to fixed version of Neo4j. +- Aligned code style / linting with Neo4j product standards. +- Updated Docker setup to inject `standaloneDashboardURL` into the application config. \ No newline at end of file diff --git a/scripts/config-entrypoint.sh b/scripts/config-entrypoint.sh index 7e2671778..25374b647 100644 --- a/scripts/config-entrypoint.sh +++ b/scripts/config-entrypoint.sh @@ -14,5 +14,6 @@ echo " \ \"standaloneUsername\": \"${standaloneUsername:=}\", \ \"standalonePassword\": \"${standalonePassword:=}\", \ \"standaloneDashboardName\": \"${standaloneDashboardName:='My Dashboard'}\", \ - \"standaloneDashboardDatabase\": \"${standaloneDashboardDatabase:='neo4j'}\" \ + \"standaloneDashboardDatabase\": \"${standaloneDashboardDatabase:='neo4j'}\", \ + \"standaloneDashboardURL\": \"${standaloneDashboardURL:=}\" \ }" > /usr/share/nginx/html/config.json diff --git a/scripts/docker-neo4j-initializer/docker-neo4j.sh b/scripts/docker-neo4j-initializer/docker-neo4j.sh index 00513190a..fb1fc862b 100644 --- a/scripts/docker-neo4j-initializer/docker-neo4j.sh +++ b/scripts/docker-neo4j-initializer/docker-neo4j.sh @@ -3,4 +3,4 @@ docker run \ -p7474:7474 -p7687:7687 \ -d \ --env NEO4J_AUTH=neo4j/test \ - neo4j:latest \ No newline at end of file + neo4j:4.4 \ No newline at end of file diff --git a/src/application/Application.tsx b/src/application/Application.tsx index 8b9950151..6f78d3c33 100644 --- a/src/application/Application.tsx +++ b/src/application/Application.tsx @@ -4,9 +4,38 @@ import CssBaseline from '@material-ui/core/CssBaseline'; import NeoNotificationModal from '../modal/NotificationModal'; import NeoWelcomeScreenModal from '../modal/WelcomeScreenModal'; import { connect } from 'react-redux'; -import { applicationGetConnection, applicationGetShareDetails, applicationGetOldDashboard, applicationHasNeo4jDesktopConnection, applicationHasAboutModalOpen, applicationHasCachedDashboard, applicationHasConnectionModalOpen, applicationIsConnected, applicationHasWelcomeScreenOpen, applicationGetDebugState, applicationGetStandaloneSettings, applicationGetSsoSettings, applicationHasReportHelpModalOpen } from '../application/ApplicationSelectors'; -import { createConnectionThunk, createConnectionFromDesktopIntegrationThunk, onConfirmLoadSharedDashboardThunk, loadApplicationConfigThunk } from '../application/ApplicationThunks'; -import { clearNotification, resetShareDetails, setAboutModalOpen, setConnected, setConnectionModalOpen, setOldDashboard, setReportHelpModalOpen, setWaitForSSO, setWelcomeScreenOpen } from '../application/ApplicationActions'; +import { + applicationGetConnection, + applicationGetShareDetails, + applicationGetOldDashboard, + applicationHasNeo4jDesktopConnection, + applicationHasAboutModalOpen, + applicationHasCachedDashboard, + applicationHasConnectionModalOpen, + applicationIsConnected, + applicationHasWelcomeScreenOpen, + applicationGetDebugState, + applicationGetStandaloneSettings, + applicationGetSsoSettings, + applicationHasReportHelpModalOpen, +} from '../application/ApplicationSelectors'; +import { + createConnectionThunk, + createConnectionFromDesktopIntegrationThunk, + onConfirmLoadSharedDashboardThunk, + loadApplicationConfigThunk, +} from '../application/ApplicationThunks'; +import { + clearNotification, + resetShareDetails, + setAboutModalOpen, + setConnected, + setConnectionModalOpen, + setOldDashboard, + setReportHelpModalOpen, + setWaitForSSO, + setWelcomeScreenOpen, +} from '../application/ApplicationActions'; import { resetDashboardState } from '../dashboard/DashboardActions'; import { NeoDashboardPlaceholder } from '../dashboard/placeholder/DashboardPlaceholder'; import NeoConnectionModal from '../modal/ConnectionModal'; @@ -23,126 +52,148 @@ import NeoReportHelpModal from '../modal/ReportHelpModal'; * It contains: * - The Dashboard component * - A number of modals (pop-up windows) that handle connections, loading/saving dashboards, etc. - * + * * Parts of the application state are retrieved here and passed to the relevant compoenents. * State-changing actions are also dispatched from here. See `ApplicationThunks.tsx`, `ApplicationActions.tsx` and `ApplicationSelectors.tsx` for more info. */ -const Application = ({ connection, connected, hasCachedDashboard, oldDashboard, clearOldDashboard, - connectionModalOpen, reportHelpModalOpen, ssoSettings, standaloneSettings, aboutModalOpen, loadDashboard, hasNeo4jDesktopConnection, shareDetails, - createConnection, createConnectionFromDesktopIntegration, onResetShareDetails, onConfirmLoadSharedDashboard, - initializeApplication, resetDashboard, onAboutModalOpen, onAboutModalClose, getDebugState, onReportHelpModalClose, - welcomeScreenOpen, setWelcomeScreenOpen, onConnectionModalOpen, onConnectionModalClose, onSSOAttempt }) => { +const Application = ({ + connection, + connected, + hasCachedDashboard, + oldDashboard, + clearOldDashboard, + connectionModalOpen, + reportHelpModalOpen, + ssoSettings, + standaloneSettings, + aboutModalOpen, + loadDashboard, + hasNeo4jDesktopConnection, + shareDetails, + createConnection, + createConnectionFromDesktopIntegration, + onResetShareDetails, + onConfirmLoadSharedDashboard, + initializeApplication, + resetDashboard, + onAboutModalOpen, + onAboutModalClose, + getDebugState, + onReportHelpModalClose, + welcomeScreenOpen, + setWelcomeScreenOpen, + onConnectionModalOpen, + onConnectionModalClose, + onSSOAttempt, +}) => { + const [initialized, setInitialized] = React.useState(false); - const [initialized, setInitialized] = React.useState(false); + if (!initialized) { + setInitialized(true); + initializeApplication(initialized); + } - if (!initialized) { - setInitialized(true); - initializeApplication(initialized); - } - - const ref = React.useRef(); + const ref = React.useRef(); - // Only render the dashboard component if we have an active Neo4j connection. - return ( -
- - {/* TODO - clean this up. Only draw the placeholder if the connection is not established. */} - - {(connected) ? - downloadComponentAsImage(ref)}> - : <>} - {/* TODO - move all models into a pop-ups (or modals) component. */} - - - - - - - -
- ); -} + // Only render the dashboard component if we have an active Neo4j connection. + return ( +
+ + {/* TODO - clean this up. Only draw the placeholder if the connection is not established. */} + + {connected ? downloadComponentAsImage(ref)}> : <>} + {/* TODO - move all models into a pop-ups (or modals) component. */} + + + + + + + +
+ ); +}; -const mapStateToProps = state => ({ - connected: applicationIsConnected(state), - connection: applicationGetConnection(state), - shareDetails: applicationGetShareDetails(state), - oldDashboard: applicationGetOldDashboard(state), - ssoSettings: applicationGetSsoSettings(state), - standaloneSettings: applicationGetStandaloneSettings(state), - connectionModalOpen: applicationHasConnectionModalOpen(state), - aboutModalOpen: applicationHasAboutModalOpen(state), - reportHelpModalOpen: applicationHasReportHelpModalOpen(state), - welcomeScreenOpen: applicationHasWelcomeScreenOpen(state), - hasCachedDashboard: applicationHasCachedDashboard(state), - getDebugState: () => { return applicationGetDebugState(state) }, // TODO - change this to be variable instead of a function? - hasNeo4jDesktopConnection: applicationHasNeo4jDesktopConnection(state), +const mapStateToProps = (state) => ({ + connected: applicationIsConnected(state), + connection: applicationGetConnection(state), + shareDetails: applicationGetShareDetails(state), + oldDashboard: applicationGetOldDashboard(state), + ssoSettings: applicationGetSsoSettings(state), + standaloneSettings: applicationGetStandaloneSettings(state), + connectionModalOpen: applicationHasConnectionModalOpen(state), + aboutModalOpen: applicationHasAboutModalOpen(state), + reportHelpModalOpen: applicationHasReportHelpModalOpen(state), + welcomeScreenOpen: applicationHasWelcomeScreenOpen(state), + hasCachedDashboard: applicationHasCachedDashboard(state), + getDebugState: () => { + return applicationGetDebugState(state); + }, // TODO - change this to be variable instead of a function? + hasNeo4jDesktopConnection: applicationHasNeo4jDesktopConnection(state), }); -const mapDispatchToProps = dispatch => ({ - createConnection: (protocol, url, port, database, username, password) => { - dispatch(setConnected(false)); - dispatch(createConnectionThunk(protocol, url, port, database, username, password)); - }, - createConnectionFromDesktopIntegration: () => { - dispatch(setConnected(false)); - dispatch(createConnectionFromDesktopIntegrationThunk()); - }, - loadDashboard: text => { - dispatch(clearNotification()); - dispatch(loadDashboardThunk(text)); - }, - resetDashboard: _ => dispatch(resetDashboardState()), - clearOldDashboard: _ => dispatch(setOldDashboard(null)), - initializeApplication: (initialized) => { - if (!initialized) { - dispatch(loadApplicationConfigThunk()); - } - }, - onResetShareDetails: _ => { - dispatch(setWelcomeScreenOpen(true)); - dispatch(resetShareDetails()); - }, - onSSOAttempt: _ => { - dispatch(setWaitForSSO(true)); - }, - onConfirmLoadSharedDashboard: _ => dispatch(onConfirmLoadSharedDashboardThunk()), - onConnectionModalOpen: _ => dispatch(setConnectionModalOpen(true)), - onConnectionModalClose: _ => dispatch(setConnectionModalOpen(false)), - onReportHelpModalClose: _ => dispatch(setReportHelpModalOpen(false)), - onAboutModalOpen: _ => dispatch(setAboutModalOpen(true)), - setWelcomeScreenOpen: open => dispatch(setWelcomeScreenOpen(open)), - onAboutModalClose: _ => dispatch(setAboutModalOpen(false)), +const mapDispatchToProps = (dispatch) => ({ + createConnection: (protocol, url, port, database, username, password) => { + dispatch(setConnected(false)); + dispatch(createConnectionThunk(protocol, url, port, database, username, password)); + }, + createConnectionFromDesktopIntegration: () => { + dispatch(setConnected(false)); + dispatch(createConnectionFromDesktopIntegrationThunk()); + }, + loadDashboard: (text) => { + dispatch(clearNotification()); + dispatch(loadDashboardThunk(text)); + }, + resetDashboard: () => dispatch(resetDashboardState()), + clearOldDashboard: () => dispatch(setOldDashboard(null)), + initializeApplication: (initialized) => { + if (!initialized) { + dispatch(loadApplicationConfigThunk()); + } + }, + onResetShareDetails: (_) => { + dispatch(setWelcomeScreenOpen(true)); + dispatch(resetShareDetails()); + }, + onSSOAttempt: (_) => { + dispatch(setWaitForSSO(true)); + }, + onConfirmLoadSharedDashboard: (_) => dispatch(onConfirmLoadSharedDashboardThunk()), + onConnectionModalOpen: (_) => dispatch(setConnectionModalOpen(true)), + onConnectionModalClose: (_) => dispatch(setConnectionModalOpen(false)), + onReportHelpModalClose: (_) => dispatch(setReportHelpModalOpen(false)), + onAboutModalOpen: (_) => dispatch(setAboutModalOpen(true)), + setWelcomeScreenOpen: (open) => dispatch(setWelcomeScreenOpen(open)), + onAboutModalClose: (_) => dispatch(setAboutModalOpen(false)), }); - -export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Application)); \ No newline at end of file +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Application)); diff --git a/src/application/ApplicationActions.ts b/src/application/ApplicationActions.ts index 7b38b2c29..61c3384e5 100644 --- a/src/application/ApplicationActions.ts +++ b/src/application/ApplicationActions.ts @@ -4,134 +4,187 @@ export const CLEAR_NOTIFICATION = 'APPLICATION/CLEAR_NOTIFICATION'; export const clearNotification = () => ({ - type: CLEAR_NOTIFICATION, - payload: {}, + type: CLEAR_NOTIFICATION, + payload: {}, }); export const CREATE_NOTIFICATION = 'APPLICATION/CREATE_NOTIFICATION'; export const createNotification = (title: any, message: any) => ({ - type: CREATE_NOTIFICATION, - payload: { title, message }, + type: CREATE_NOTIFICATION, + payload: { title, message }, }); export const SET_CONNECTED = 'APPLICATION/SET_CONNECTED'; export const setConnected = (connected: boolean) => ({ - type: SET_CONNECTED, - payload: { connected }, + type: SET_CONNECTED, + payload: { connected }, }); export const SET_CONNECTION_MODAL_OPEN = 'APPLICATION/SET_CONNECTION_MODAL_OPEN'; export const setConnectionModalOpen = (open: boolean) => ({ - type: SET_CONNECTION_MODAL_OPEN, - payload: { open }, + type: SET_CONNECTION_MODAL_OPEN, + payload: { open }, }); export const SET_ABOUT_MODAL_OPEN = 'APPLICATION/SET_ABOUT_MODAL_OPEN'; export const setAboutModalOpen = (open: boolean) => ({ - type: SET_ABOUT_MODAL_OPEN, - payload: { open }, + type: SET_ABOUT_MODAL_OPEN, + payload: { open }, }); export const SET_REPORT_HELP_MODAL_OPEN = 'APPLICATION/SET_REPORT_HELP_MODAL_OPEN'; export const setReportHelpModalOpen = (open: boolean) => ({ - type: SET_REPORT_HELP_MODAL_OPEN, - payload: { open }, + type: SET_REPORT_HELP_MODAL_OPEN, + payload: { open }, }); export const SET_WELCOME_SCREEN_OPEN = 'APPLICATION/SET_WELCOME_SCREEN_OPEN'; export const setWelcomeScreenOpen = (open: boolean) => ({ - type: SET_WELCOME_SCREEN_OPEN, - payload: { open }, + type: SET_WELCOME_SCREEN_OPEN, + payload: { open }, }); export const SET_CONNECTION_PROPERTIES = 'APPLICATION/SET_CONNECTION_PROPERTIES'; -export const setConnectionProperties = (protocol: string, url: string, port: string, database: string, username: string, password: string) => ({ - type: SET_CONNECTION_PROPERTIES, - payload: { protocol, url, port, database, username, password }, +export const setConnectionProperties = ( + protocol: string, + url: string, + port: string, + database: string, + username: string, + password: string +) => ({ + type: SET_CONNECTION_PROPERTIES, + payload: { protocol, url, port, database, username, password }, }); export const SET_BASIC_CONNECTION_PROPERTIES = 'APPLICATION/SET_BASIC_CONNECTION_PROPERTIES'; -export const setBasicConnectionProperties = (protocol: string, url: string, port: string, database: string, username: string, password: string) => ({ - type: SET_CONNECTION_PROPERTIES, - payload: { protocol, url, port, database, username, password }, +export const setBasicConnectionProperties = ( + protocol: string, + url: string, + port: string, + database: string, + username: string, + password: string +) => ({ + type: SET_CONNECTION_PROPERTIES, + payload: { protocol, url, port, database, username, password }, }); export const SET_DESKTOP_CONNECTION_PROPERTIES = 'APPLICATION/SET_DESKTOP_CONNECTION_PROPERTIES'; -export const setDesktopConnectionProperties = (protocol: string, url: string, port: string, database: string, username: string, password: string) => ({ - type: SET_DESKTOP_CONNECTION_PROPERTIES, - payload: { protocol, url, port, database, username, password }, +export const setDesktopConnectionProperties = ( + protocol: string, + url: string, + port: string, + database: string, + username: string, + password: string +) => ({ + type: SET_DESKTOP_CONNECTION_PROPERTIES, + payload: { protocol, url, port, database, username, password }, }); export const CLEAR_DESKTOP_CONNECTION_PROPERTIES = 'APPLICATION/CLEAR_DESKTOP_CONNECTION_PROPERTIES'; export const clearDesktopConnectionProperties = () => ({ - type: CLEAR_DESKTOP_CONNECTION_PROPERTIES, - payload: {}, + type: CLEAR_DESKTOP_CONNECTION_PROPERTIES, + payload: {}, }); // Legacy pre1-v2 dashboard that can be optionally upgraded. export const SET_OLD_DASHBOARD = 'APPLICATION/SET_OLD_DASHBOARD'; export const setOldDashboard = (text: string) => ({ - type: SET_OLD_DASHBOARD, - payload: { text }, + type: SET_OLD_DASHBOARD, + payload: { text }, }); // Legacy pre1-v2 dashboard that can be optionally upgraded. export const RESET_SHARE_DETAILS = 'APPLICATION/RESET_SHARE_DETAILS'; export const resetShareDetails = () => ({ - type: RESET_SHARE_DETAILS, - payload: { }, + type: RESET_SHARE_DETAILS, + payload: {}, }); export const SET_SHARE_DETAILS_FROM_URL = 'APPLICATION/SET_SHARE_DETAILS_FROM_URL'; -export const setShareDetailsFromUrl = (type: string, id: string, standalone: boolean, protocol: string, url: string, port: string, database: string, username: string, password: string, dashboardDatabase : string) => ({ - type: SET_SHARE_DETAILS_FROM_URL, - payload: { type, id, standalone, protocol, url, port, database, username, password, dashboardDatabase }, +export const setShareDetailsFromUrl = ( + type: string, + id: string, + standalone: boolean, + protocol: string, + url: string, + port: string, + database: string, + username: string, + password: string, + dashboardDatabase: string +) => ({ + type: SET_SHARE_DETAILS_FROM_URL, + payload: { type, id, standalone, protocol, url, port, database, username, password, dashboardDatabase }, }); export const SET_STANDALONE_ENABLED = 'APPLICATION/SET_STANDALONE_ENABLED'; -export const setStandaloneEnabled = (standalone: boolean, standaloneProtocol: string, standaloneHost: string, standalonePort: string, standaloneDatabase: string, standaloneDashboardName: string, standaloneDashboardDatabase: string, standaloneDashboardURL: string, standaloneUsername: string, standalonePassword: string) => ({ - type: SET_STANDALONE_ENABLED, - payload: { standalone, standaloneProtocol, standaloneHost, standalonePort, standaloneDatabase, standaloneDashboardName, standaloneDashboardDatabase, standaloneDashboardURL, standaloneUsername, standalonePassword }, +export const setStandaloneEnabled = ( + standalone: boolean, + standaloneProtocol: string, + standaloneHost: string, + standalonePort: string, + standaloneDatabase: string, + standaloneDashboardName: string, + standaloneDashboardDatabase: string, + standaloneDashboardURL: string, + standaloneUsername: string, + standalonePassword: string +) => ({ + type: SET_STANDALONE_ENABLED, + payload: { + standalone, + standaloneProtocol, + standaloneHost, + standalonePort, + standaloneDatabase, + standaloneDashboardName, + standaloneDashboardDatabase, + standaloneDashboardURL, + standaloneUsername, + standalonePassword, + }, }); export const SET_STANDALONE_MODE = 'APPLICATION/SET_STANDALONE_MODE'; -export const setStandaloneMode = (standalone: boolean ) => ({ - type: SET_STANDALONE_ENABLED, - payload: { standalone }, +export const setStandaloneMode = (standalone: boolean) => ({ + type: SET_STANDALONE_ENABLED, + payload: { standalone }, }); export const SET_STANDALONE_DASHBOARD_DATEBASE = 'APPLICATION/SET_STANDALONE_DASHBOARD_DATEBASE'; export const setStandaloneDashboardDatabase = (dashboardDatabase: string) => ({ - type: SET_STANDALONE_DASHBOARD_DATEBASE, - payload: { dashboardDatabase } + type: SET_STANDALONE_DASHBOARD_DATEBASE, + payload: { dashboardDatabase }, }); export const SET_SSO_ENABLED = 'APPLICATION/SET_SSO_ENABLED'; export const setSSOEnabled = (enabled: boolean, discoveryUrl: string) => ({ - type: SET_SSO_ENABLED, - payload: { enabled, discoveryUrl }, + type: SET_SSO_ENABLED, + payload: { enabled, discoveryUrl }, }); export const SET_WAIT_FOR_SSO = 'APPLICATION/SET_WAIT_FOR_SSO'; -export const setWaitForSSO = (wait: boolean ) => ({ - type: SET_WAIT_FOR_SSO, - payload: { wait }, +export const setWaitForSSO = (wait: boolean) => ({ + type: SET_WAIT_FOR_SSO, + payload: { wait }, }); export const SET_SESSION_PARAMETERS = 'APPLICATION/SET_SESSION_PARAMETERS'; -export const setSessionParameters = ( parameters: any ) => ({ - type: SET_SESSION_PARAMETERS, - payload: { parameters }, +export const setSessionParameters = (parameters: any) => ({ + type: SET_SESSION_PARAMETERS, + payload: { parameters }, }); - export const SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING = 'APPLICATION/SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING'; export const setDashboardToLoadAfterConnecting = (id: any) => ({ - type: SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING, - payload: { id }, + type: SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING, + payload: { id }, }); export const SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING = 'APPLICATION/SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING'; export const setParametersToLoadAfterConnecting = (parameters: any) => ({ - type: SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING, - payload: { parameters }, + type: SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING, + payload: { parameters }, }); diff --git a/src/application/ApplicationReducer.ts b/src/application/ApplicationReducer.ts index 98fa091e4..6f09ce76a 100644 --- a/src/application/ApplicationReducer.ts +++ b/src/application/ApplicationReducer.ts @@ -3,189 +3,218 @@ */ import { - CLEAR_DESKTOP_CONNECTION_PROPERTIES, CLEAR_NOTIFICATION, CREATE_NOTIFICATION, - RESET_SHARE_DETAILS, SET_ABOUT_MODAL_OPEN, SET_CONNECTED, - SET_CONNECTION_MODAL_OPEN, SET_CONNECTION_PROPERTIES, - SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING, SET_DESKTOP_CONNECTION_PROPERTIES, SET_OLD_DASHBOARD, - SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING, - SET_REPORT_HELP_MODAL_OPEN, - SET_SESSION_PARAMETERS, - SET_SHARE_DETAILS_FROM_URL, SET_SSO_ENABLED, SET_STANDALONE_DASHBOARD_DATEBASE, SET_STANDALONE_ENABLED, - SET_STANDALONE_MODE, SET_WAIT_FOR_SSO, SET_WELCOME_SCREEN_OPEN -} from "./ApplicationActions"; + CLEAR_DESKTOP_CONNECTION_PROPERTIES, + CLEAR_NOTIFICATION, + CREATE_NOTIFICATION, + RESET_SHARE_DETAILS, + SET_ABOUT_MODAL_OPEN, + SET_CONNECTED, + SET_CONNECTION_MODAL_OPEN, + SET_CONNECTION_PROPERTIES, + SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING, + SET_DESKTOP_CONNECTION_PROPERTIES, + SET_OLD_DASHBOARD, + SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING, + SET_REPORT_HELP_MODAL_OPEN, + SET_SESSION_PARAMETERS, + SET_SHARE_DETAILS_FROM_URL, + SET_SSO_ENABLED, + SET_STANDALONE_DASHBOARD_DATEBASE, + SET_STANDALONE_ENABLED, + SET_STANDALONE_MODE, + SET_WAIT_FOR_SSO, + SET_WELCOME_SCREEN_OPEN, +} from './ApplicationActions'; -const update = (state, mutations) => - Object.assign({}, state, mutations) +const update = (state, mutations) => Object.assign({}, state, mutations); -const initialState = -{ - notificationTitle: null, - notificationMessage: null, - connectionModalOpen: false, - welcomeScreenOpen: true, - aboutModalOpen: false, - connection: { - protocol: "neo4j", - url: "localhost", - port: "7687", - database: "", - username: "neo4j", - password: "" - }, - shareDetails: undefined, - desktopConnection: null, - connected: false, - dashboardToLoadAfterConnecting: null, - waitForSSO: false, - standalone: false -} -export const applicationReducer = (state = initialState, action: { type: any; payload: any; }) => { - const { type, payload } = action; +const initialState = { + notificationTitle: null, + notificationMessage: null, + connectionModalOpen: false, + welcomeScreenOpen: true, + aboutModalOpen: false, + connection: { + protocol: 'neo4j', + url: 'localhost', + port: '7687', + database: '', + username: 'neo4j', + password: '', + }, + shareDetails: undefined, + desktopConnection: null, + connected: false, + dashboardToLoadAfterConnecting: null, + waitForSSO: false, + standalone: false, +}; +export const applicationReducer = (state = initialState, action: { type: any; payload: any }) => { + const { type, payload } = action; - if (!action.type.startsWith('APPLICATION/')) { - return state; - } + if (!action.type.startsWith('APPLICATION/')) { + return state; + } - // Application state updates are handled here. - switch (type) { - case CREATE_NOTIFICATION: { - const { title, message } = payload; - state = update(state, { notificationTitle: title, notificationMessage: message }) - return state; - } - case CLEAR_NOTIFICATION: { - state = update(state, { notificationTitle: null, notificationMessage: null, notificationIsDismissable: null }) - return state; - } - case SET_CONNECTED: { - const { connected } = payload; - state = update(state, { connected: connected }) - return state; - } - case SET_CONNECTION_MODAL_OPEN: { - const { open } = payload; - state = update(state, { connectionModalOpen: open }) - return state; - } - case SET_ABOUT_MODAL_OPEN: { - const { open } = payload; - state = update(state, { aboutModalOpen: open }) - return state; - } - case SET_REPORT_HELP_MODAL_OPEN: { - const { open } = payload; - state = update(state, { reportHelpModalOpen: open }) - return state; - } - case SET_WELCOME_SCREEN_OPEN: { - const { open } = payload; - state = update(state, { welcomeScreenOpen: open }) - return state; - } - case SET_STANDALONE_DASHBOARD_DATEBASE : { - const { dashboardDatabase } = payload; - state = update(state, { standaloneDashboardDatabase: dashboardDatabase }); - return state; - } - case SET_STANDALONE_MODE: { - const { standalone } = payload; - state = update(state, { standalone: standalone }) - return state; - } - case SET_SSO_ENABLED: { - const { enabled, discoveryUrl } = payload; - state = update(state, { ssoEnabled: enabled, ssoDiscoveryUrl: discoveryUrl }) - return state; - } - case SET_WAIT_FOR_SSO: { - const { wait } = payload; - state = update(state, { waitForSSO: wait }) - return state; - } - case SET_SESSION_PARAMETERS: { - const { parameters } = payload; - state = update(state, { sessionParameters: parameters }) - return state; - } - case SET_STANDALONE_ENABLED: { - const { standalone, standaloneProtocol, standaloneHost, standalonePort, standaloneDatabase, standaloneDashboardName, standaloneDashboardDatabase, standaloneDashboardURL, standaloneUsername, standalonePassword } = payload; - state = update(state, { - standalone: standalone, - standaloneProtocol: standaloneProtocol, - standaloneHost: standaloneHost, - standalonePort: standalonePort, - standaloneDatabase: standaloneDatabase, - standaloneDashboardName: standaloneDashboardName, - standaloneDashboardDatabase: standaloneDashboardDatabase, - standaloneDashboardURL: standaloneDashboardURL, - standaloneUsername: standaloneUsername, - standalonePassword: standalonePassword - }) - return state; - } - case SET_OLD_DASHBOARD: { - const { text } = payload; - state = update(state, { oldDashboard: text }) - return state; - } - case SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING: { - const { id } = payload; - state = update(state, { dashboardToLoadAfterConnecting: id }) - return state; - } - case SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING: { - const { parameters } = payload; - state = update(state, { parametersToLoadAfterConnecting: parameters }) - return state; - } - case SET_CONNECTION_PROPERTIES: { - const { protocol, url, port, database, username, password } = payload; - state = update(state, { - connection: { - protocol: protocol, url: url, port: port, - database: database, username: username, password: password - } - }) - return state; - } - case CLEAR_DESKTOP_CONNECTION_PROPERTIES: { - state = update(state, { desktopConnection: null }) - return state; - } - case SET_DESKTOP_CONNECTION_PROPERTIES: { - const { protocol, url, port, database, username, password } = payload; - state = update(state, { - desktopConnection: { - protocol: protocol, url: url, port: port, - database: database, username: username, password: password - } - }) - return state; - } - case RESET_SHARE_DETAILS: { - state = update(state, { shareDetails: undefined }); - return state; - } - case SET_SHARE_DETAILS_FROM_URL: { - const { type, id, standalone, protocol, url, port, database, username, password, dashboardDatabase } = payload; - state = update(state, { - shareDetails: { - type: type, - id: id, - standalone: standalone, - protocol: protocol, - url: url, - port: port, - database: database, - username: username, - password: password, - dashboardDatabase: dashboardDatabase - } - }) - return state; - } - default: { - return state; - } - } -} \ No newline at end of file + // Application state updates are handled here. + switch (type) { + case CREATE_NOTIFICATION: { + const { title, message } = payload; + state = update(state, { notificationTitle: title, notificationMessage: message }); + return state; + } + case CLEAR_NOTIFICATION: { + state = update(state, { notificationTitle: null, notificationMessage: null, notificationIsDismissable: null }); + return state; + } + case SET_CONNECTED: { + const { connected } = payload; + state = update(state, { connected: connected }); + return state; + } + case SET_CONNECTION_MODAL_OPEN: { + const { open } = payload; + state = update(state, { connectionModalOpen: open }); + return state; + } + case SET_ABOUT_MODAL_OPEN: { + const { open } = payload; + state = update(state, { aboutModalOpen: open }); + return state; + } + case SET_REPORT_HELP_MODAL_OPEN: { + const { open } = payload; + state = update(state, { reportHelpModalOpen: open }); + return state; + } + case SET_WELCOME_SCREEN_OPEN: { + const { open } = payload; + state = update(state, { welcomeScreenOpen: open }); + return state; + } + case SET_STANDALONE_DASHBOARD_DATEBASE: { + const { dashboardDatabase } = payload; + state = update(state, { standaloneDashboardDatabase: dashboardDatabase }); + return state; + } + case SET_STANDALONE_MODE: { + const { standalone } = payload; + state = update(state, { standalone: standalone }); + return state; + } + case SET_SSO_ENABLED: { + const { enabled, discoveryUrl } = payload; + state = update(state, { ssoEnabled: enabled, ssoDiscoveryUrl: discoveryUrl }); + return state; + } + case SET_WAIT_FOR_SSO: { + const { wait } = payload; + state = update(state, { waitForSSO: wait }); + return state; + } + case SET_SESSION_PARAMETERS: { + const { parameters } = payload; + state = update(state, { sessionParameters: parameters }); + return state; + } + case SET_STANDALONE_ENABLED: { + const { + standalone, + standaloneProtocol, + standaloneHost, + standalonePort, + standaloneDatabase, + standaloneDashboardName, + standaloneDashboardDatabase, + standaloneDashboardURL, + standaloneUsername, + standalonePassword, + } = payload; + state = update(state, { + standalone: standalone, + standaloneProtocol: standaloneProtocol, + standaloneHost: standaloneHost, + standalonePort: standalonePort, + standaloneDatabase: standaloneDatabase, + standaloneDashboardName: standaloneDashboardName, + standaloneDashboardDatabase: standaloneDashboardDatabase, + standaloneDashboardURL: standaloneDashboardURL, + standaloneUsername: standaloneUsername, + standalonePassword: standalonePassword, + }); + return state; + } + case SET_OLD_DASHBOARD: { + const { text } = payload; + state = update(state, { oldDashboard: text }); + return state; + } + case SET_DASHBOARD_TO_LOAD_AFTER_CONNECTING: { + const { id } = payload; + state = update(state, { dashboardToLoadAfterConnecting: id }); + return state; + } + case SET_PARAMETERS_TO_LOAD_AFTER_CONNECTING: { + const { parameters } = payload; + state = update(state, { parametersToLoadAfterConnecting: parameters }); + return state; + } + case SET_CONNECTION_PROPERTIES: { + const { protocol, url, port, database, username, password } = payload; + state = update(state, { + connection: { + protocol: protocol, + url: url, + port: port, + database: database, + username: username, + password: password, + }, + }); + return state; + } + case CLEAR_DESKTOP_CONNECTION_PROPERTIES: { + state = update(state, { desktopConnection: null }); + return state; + } + case SET_DESKTOP_CONNECTION_PROPERTIES: { + const { protocol, url, port, database, username, password } = payload; + state = update(state, { + desktopConnection: { + protocol: protocol, + url: url, + port: port, + database: database, + username: username, + password: password, + }, + }); + return state; + } + case RESET_SHARE_DETAILS: { + state = update(state, { shareDetails: undefined }); + return state; + } + case SET_SHARE_DETAILS_FROM_URL: { + const { type, id, standalone, protocol, url, port, database, username, password, dashboardDatabase } = payload; + state = update(state, { + shareDetails: { + type: type, + id: id, + standalone: standalone, + protocol: protocol, + url: url, + port: port, + database: database, + username: username, + password: password, + dashboardDatabase: dashboardDatabase, + }, + }); + return state; + } + default: { + return state; + } + } +}; diff --git a/src/application/ApplicationSelectors.ts b/src/application/ApplicationSelectors.ts index f577141f7..3a850c8cd 100644 --- a/src/application/ApplicationSelectors.ts +++ b/src/application/ApplicationSelectors.ts @@ -1,4 +1,4 @@ -import { initialState } from "../dashboard/DashboardReducer"; +import { initialState } from '../dashboard/DashboardReducer'; import isEqual from 'lodash.isequal'; /** @@ -6,99 +6,99 @@ import isEqual from 'lodash.isequal'; */ export const applicationHasNotification = (state: any) => { - return state.application.notificationMessage != null; -} + return state.application.notificationMessage != null; +}; export const getNotification = (state: any) => { - return state.application.notificationMessage; -} + return state.application.notificationMessage; +}; export const getNotificationIsDismissable = (state: any) => { - return state.application.notificationTitle !== "Unable to load application configuration"; -} + return state.application.notificationTitle !== 'Unable to load application configuration'; +}; export const getNotificationTitle = (state: any) => { - return state.application.notificationTitle; -} + return state.application.notificationTitle; +}; export const applicationIsConnected = (state: any) => { - return state.application.connected; -} + return state.application.connected; +}; export const applicationGetConnection = (state: any) => { - return state.application.connection; -} + return state.application.connection; +}; export const applicationGetShareDetails = (state: any) => { - return state.application.shareDetails; -} + return state.application.shareDetails; +}; export const applicationIsStandalone = (state: any) => { - return state.application.standalone; -} + return state.application.standalone; +}; export const applicationHasNeo4jDesktopConnection = (state: any) => { - return state.application.desktopConnection != null; -} + return state.application.desktopConnection != null; +}; export const applicationHasConnectionModalOpen = (state: any) => { - return state.application.connectionModalOpen; -} + return state.application.connectionModalOpen; +}; export const applicationGetOldDashboard = (state: any) => { - return state.application.oldDashboard; -} + return state.application.oldDashboard; +}; export const applicationHasAboutModalOpen = (state: any) => { - return state.application.aboutModalOpen; -} + return state.application.aboutModalOpen; +}; export const applicationHasReportHelpModalOpen = (state: any) => { - return state.application.reportHelpModalOpen; -} + return state.application.reportHelpModalOpen; +}; export const applicationGetSsoSettings = (state: any) => { - return { - 'ssoEnabled': state.application.ssoEnabled, - 'ssoDiscoveryUrl': state.application.ssoDiscoveryUrl - }; -} + return { + ssoEnabled: state.application.ssoEnabled, + ssoDiscoveryUrl: state.application.ssoDiscoveryUrl, + }; +}; export const applicationGetStandaloneSettings = (state: any) => { - return { - "standalone": state.application.standalone, - "standaloneProtocol": state.application.standaloneProtocol, - "standaloneHost": state.application.standaloneHost, - "standalonePort": state.application.standalonePort, - "standaloneDatabase": state.application.standaloneDatabase, - "standaloneDashboardName": state.application.standaloneDashboardName, - "standaloneDashboardDatabase": state.application.standaloneDashboardDatabase, - "standaloneDashboardURL": state.application.standaloneDashboardURL, - "standaloneUsername": state.application.standaloneUsername, - "standalonePassword": state.application.standalonePassword - } -} + return { + standalone: state.application.standalone, + standaloneProtocol: state.application.standaloneProtocol, + standaloneHost: state.application.standaloneHost, + standalonePort: state.application.standalonePort, + standaloneDatabase: state.application.standaloneDatabase, + standaloneDashboardName: state.application.standaloneDashboardName, + standaloneDashboardDatabase: state.application.standaloneDashboardDatabase, + standaloneDashboardURL: state.application.standaloneDashboardURL, + standaloneUsername: state.application.standaloneUsername, + standalonePassword: state.application.standalonePassword, + }; +}; export const applicationHasWelcomeScreenOpen = (state: any) => { - return state.application.welcomeScreenOpen; -} + return state.application.welcomeScreenOpen; +}; export const applicationHasCachedDashboard = (state: any) => { - // Avoid this expensive check when the application is connected, as it's only for the welcome screen. - if (state.application.connected) { - return false; - } - return !isEqual(state.dashboard, initialState); -} + // Avoid this expensive check when the application is connected, as it's only for the welcome screen. + if (state.application.connected) { + return false; + } + return !isEqual(state.dashboard, initialState); +}; /** * Deep-copy the current state, and remove the password. */ export const applicationGetDebugState = (state: any) => { - const copy = JSON.parse(JSON.stringify(state)); - copy.application.connection.password = "************"; - if (copy.application.desktopConnection) { - copy.application.desktopConnection.password = "************"; - } - return copy; -} \ No newline at end of file + const copy = JSON.parse(JSON.stringify(state)); + copy.application.connection.password = '************'; + if (copy.application.desktopConnection) { + copy.application.desktopConnection.password = '************'; + } + return copy; +}; diff --git a/src/application/ApplicationThunks.ts b/src/application/ApplicationThunks.ts index 7c9224493..b19254db8 100644 --- a/src/application/ApplicationThunks.ts +++ b/src/application/ApplicationThunks.ts @@ -1,21 +1,41 @@ -import { createDriver } from "use-neo4j"; -import { initializeSSO } from "../component/sso/SSOUtils"; -import { setDashboard } from "../dashboard/DashboardActions"; -import { NEODASH_VERSION } from "../dashboard/DashboardReducer"; -import { loadDashboardFromNeo4jByNameThunk, loadDashboardFromNeo4jByUUIDThunk, loadDashboardThunk, upgradeDashboardVersion } from "../dashboard/DashboardThunks"; -import { createNotificationThunk } from "../page/PageThunks"; -import { QueryStatus, runCypherQuery } from "../report/ReportQueryRunner"; +import { createDriver } from 'use-neo4j'; +import { initializeSSO } from '../component/sso/SSOUtils'; +import { setDashboard } from '../dashboard/DashboardActions'; +import { NEODASH_VERSION } from '../dashboard/DashboardReducer'; import { - setPageNumberThunk, - updateGlobalParametersThunk, - updateSessionParameterThunk -} from "../settings/SettingsThunks"; + loadDashboardFromNeo4jByNameThunk, + loadDashboardFromNeo4jByUUIDThunk, + loadDashboardThunk, + upgradeDashboardVersion, +} from '../dashboard/DashboardThunks'; +import { createNotificationThunk } from '../page/PageThunks'; +import { runCypherQuery } from '../report/ReportQueryRunner'; import { - setConnected, setConnectionModalOpen, setConnectionProperties, setDesktopConnectionProperties, - resetShareDetails, setShareDetailsFromUrl, setWelcomeScreenOpen, setDashboardToLoadAfterConnecting, - setOldDashboard, clearDesktopConnectionProperties, clearNotification, setSSOEnabled, setStandaloneEnabled, - setAboutModalOpen, setStandaloneMode, setStandaloneDashboardDatabase, setWaitForSSO, setParametersToLoadAfterConnecting, setReportHelpModalOpen -} from "./ApplicationActions"; + setPageNumberThunk, + updateGlobalParametersThunk, + updateSessionParameterThunk, +} from '../settings/SettingsThunks'; +import { + setConnected, + setConnectionModalOpen, + setConnectionProperties, + setDesktopConnectionProperties, + resetShareDetails, + setShareDetailsFromUrl, + setWelcomeScreenOpen, + setDashboardToLoadAfterConnecting, + setOldDashboard, + clearDesktopConnectionProperties, + clearNotification, + setSSOEnabled, + setStandaloneEnabled, + setAboutModalOpen, + setStandaloneMode, + setStandaloneDashboardDatabase, + setWaitForSSO, + setParametersToLoadAfterConnecting, + setReportHelpModalOpen, +} from './ApplicationActions'; /** * Application Thunks (https://redux.js.org/usage/writing-logic-thunks) handle complex state manipulations. @@ -31,371 +51,499 @@ import { * @param username - Neo4j username. * @param password - Neo4j password. */ -export const createConnectionThunk = (protocol, url, port, database, username, password) => (dispatch: any, getState: any) => { +export const createConnectionThunk = + (protocol, url, port, database, username, password) => (dispatch: any, getState: any) => { try { - const driver = createDriver(protocol, url, port, username, password) - console.log("Attempting to connect...") - const validateConnection = (records) => { - console.log("Confirming connection was established...") - if (records && records[0] && records[0]["error"]) { - dispatch(createNotificationThunk("Unable to establish connection", records[0]["error"])); - } else if (records && records[0] && records[0].keys[0] == "connected") { - - dispatch(setConnectionProperties(protocol, url, port, database, username, password)); - dispatch(setConnectionModalOpen(false)); - dispatch(setConnected(true)); - dispatch(updateSessionParameterThunk("session_uri", protocol + "://" + url + ":" + port)); - dispatch(updateSessionParameterThunk("session_database", database)); - dispatch(updateSessionParameterThunk("session_username", username)); - // If we have remembered to load a specific dashboard after connecting to the database, take care of it here. - const application = getState().application; - if (application.dashboardToLoadAfterConnecting && (application.dashboardToLoadAfterConnecting.startsWith("http") || application.dashboardToLoadAfterConnecting.startsWith("./") || application.dashboardToLoadAfterConnecting.startsWith("/"))) { - fetch(application.dashboardToLoadAfterConnecting) - .then(response => response.text()) - .then(data => dispatch(loadDashboardThunk(data))); - dispatch(setDashboardToLoadAfterConnecting(null)); - } else if (application.dashboardToLoadAfterConnecting) { - const setDashboardAfterLoadingFromDatabase = (value) => { - dispatch(loadDashboardThunk(value)); - } - - // If we specify a dashboard by name, load the latest version of it. - // If we specify a dashboard by UUID, load it directly. - if (application.dashboardToLoadAfterConnecting.startsWith('name:')) { - dispatch(loadDashboardFromNeo4jByNameThunk(driver, application.standaloneDashboardDatabase, application.dashboardToLoadAfterConnecting.substring(5), setDashboardAfterLoadingFromDatabase)); - } else { - dispatch(loadDashboardFromNeo4jByUUIDThunk(driver, application.standaloneDashboardDatabase, application.dashboardToLoadAfterConnecting, setDashboardAfterLoadingFromDatabase)); - } - dispatch(setDashboardToLoadAfterConnecting(null)); - } + const driver = createDriver(protocol, url, port, username, password); + // eslint-disable-next-line no-console + console.log('Attempting to connect...'); + const validateConnection = (records) => { + // eslint-disable-next-line no-console + console.log('Confirming connection was established...'); + if (records && records[0] && records[0].error) { + dispatch(createNotificationThunk('Unable to establish connection', records[0].error)); + } else if (records && records[0] && records[0].keys[0] == 'connected') { + dispatch(setConnectionProperties(protocol, url, port, database, username, password)); + dispatch(setConnectionModalOpen(false)); + dispatch(setConnected(true)); + dispatch(updateSessionParameterThunk('session_uri', `${protocol}://${url}:${port}`)); + dispatch(updateSessionParameterThunk('session_database', database)); + dispatch(updateSessionParameterThunk('session_username', username)); + // If we have remembered to load a specific dashboard after connecting to the database, take care of it here. + const { application } = getState(); + if ( + application.dashboardToLoadAfterConnecting && + (application.dashboardToLoadAfterConnecting.startsWith('http') || + application.dashboardToLoadAfterConnecting.startsWith('./') || + application.dashboardToLoadAfterConnecting.startsWith('/')) + ) { + fetch(application.dashboardToLoadAfterConnecting) + .then((response) => response.text()) + .then((data) => dispatch(loadDashboardThunk(data))); + dispatch(setDashboardToLoadAfterConnecting(null)); + } else if (application.dashboardToLoadAfterConnecting) { + const setDashboardAfterLoadingFromDatabase = (value) => { + dispatch(loadDashboardThunk(value)); + }; + + // If we specify a dashboard by name, load the latest version of it. + // If we specify a dashboard by UUID, load it directly. + if (application.dashboardToLoadAfterConnecting.startsWith('name:')) { + dispatch( + loadDashboardFromNeo4jByNameThunk( + driver, + application.standaloneDashboardDatabase, + application.dashboardToLoadAfterConnecting.substring(5), + setDashboardAfterLoadingFromDatabase + ) + ); } else { - dispatch(createNotificationThunk("Unknown Connection Error", "Check the browser console.")); + dispatch( + loadDashboardFromNeo4jByUUIDThunk( + driver, + application.standaloneDashboardDatabase, + application.dashboardToLoadAfterConnecting, + setDashboardAfterLoadingFromDatabase + ) + ); } + dispatch(setDashboardToLoadAfterConnecting(null)); + } + } else { + dispatch(createNotificationThunk('Unknown Connection Error', 'Check the browser console.')); } - const query = "RETURN true as connected"; - const parameters = {} - runCypherQuery(driver, database, query, parameters, 1, () => { return }, (records) => validateConnection(records)) + }; + const query = 'RETURN true as connected'; + const parameters = {}; + runCypherQuery( + driver, + database, + query, + parameters, + 1, + () => {}, + (records) => validateConnection(records) + ); } catch (e) { - dispatch(createNotificationThunk("Unable to establish connection", e)); + dispatch(createNotificationThunk('Unable to establish connection', e)); } -} + }; /** * Establish a connection directly from the Neo4j Desktop integration (if running inside Neo4j Desktop) */ export const createConnectionFromDesktopIntegrationThunk = () => (dispatch: any, getState: any) => { - try { - const desktopConnectionDetails = getState().application.desktopConnection; - const protocol = desktopConnectionDetails.protocol; - const url = desktopConnectionDetails.url; - const port = desktopConnectionDetails.port; - const database = desktopConnectionDetails.database; - const username = desktopConnectionDetails.username; - const password = desktopConnectionDetails.password; - dispatch(createConnectionThunk(protocol, url, port, database, username, password)); - } catch (e) { - dispatch(createNotificationThunk("Unable to establish connection to Neo4j Desktop", e)); - } -} + try { + const desktopConnectionDetails = getState().application.desktopConnection; + const { protocol, url, port, database, username, password } = desktopConnectionDetails; + dispatch(createConnectionThunk(protocol, url, port, database, username, password)); + } catch (e) { + dispatch(createNotificationThunk('Unable to establish connection to Neo4j Desktop', e)); + } +}; /** * Find the active database from Neo4j Desktop. * Set global state values to remember the values retrieved from the integration so that we can connect later if possible. */ -export const setDatabaseFromNeo4jDesktopIntegrationThunk = () => (dispatch: any, getState: any) => { - const getActiveDatabase = (context) => { - for (let pi = 0; pi < context.projects.length; pi++) { - let prj = context.projects[pi]; - for (let gi = 0; gi < prj.graphs.length; gi++) { - let grf = prj.graphs[gi]; - if (grf.status == 'ACTIVE') { - return grf; - } - } +export const setDatabaseFromNeo4jDesktopIntegrationThunk = () => (dispatch: any) => { + const getActiveDatabase = (context) => { + for (let pi = 0; pi < context.projects.length; pi++) { + let prj = context.projects[pi]; + for (let gi = 0; gi < prj.graphs.length; gi++) { + let grf = prj.graphs[gi]; + if (grf.status == 'ACTIVE') { + return grf; } - // No active database found - ask for manual connection details. - return null; - } - - let promise = window.neo4jDesktopApi && window.neo4jDesktopApi.getContext(); - - if (promise) { - promise.then(function (context) { - let neo4j = getActiveDatabase(context); - if (neo4j) { - dispatch(setDesktopConnectionProperties( - neo4j.connection.configuration.protocols.bolt.url.split("://")[0], - neo4j.connection.configuration.protocols.bolt.url.split("://")[1].split(":")[0], - neo4j.connection.configuration.protocols.bolt.port, - undefined, - neo4j.connection.configuration.protocols.bolt.username, - neo4j.connection.configuration.protocols.bolt.password)); - } - }); + } } -} + // No active database found - ask for manual connection details. + return null; + }; + + let promise = window.neo4jDesktopApi && window.neo4jDesktopApi.getContext(); + + if (promise) { + promise.then((context) => { + let neo4j = getActiveDatabase(context); + if (neo4j) { + dispatch( + setDesktopConnectionProperties( + neo4j.connection.configuration.protocols.bolt.url.split('://')[0], + neo4j.connection.configuration.protocols.bolt.url.split('://')[1].split(':')[0], + neo4j.connection.configuration.protocols.bolt.port, + undefined, + neo4j.connection.configuration.protocols.bolt.username, + neo4j.connection.configuration.protocols.bolt.password + ) + ); + } + }); + } +}; /** * On application startup, check the URL to see if we are loading a shared dashboard. * If yes, decode the URL parameters and set the application state accordingly, so that it can be loaded later. */ -export const handleSharedDashboardsThunk = () => (dispatch: any, getState: any) => { - try { - const queryString = window.location.search; - const urlParams = new URLSearchParams(queryString); - - // Parse the URL parameters to see if there's any deep linking of parameters. - const paramsToSetAfterConnecting = {} - Array.from(urlParams.entries()).forEach(([key, value]) => { - if (key.startsWith("neodash_")) { - paramsToSetAfterConnecting[key] = value; - } - }); - if (Object.keys(paramsToSetAfterConnecting).length > 0) { - dispatch(setParametersToLoadAfterConnecting(paramsToSetAfterConnecting)); - } +export const handleSharedDashboardsThunk = () => (dispatch: any) => { + try { + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + + // Parse the URL parameters to see if there's any deep linking of parameters. + const paramsToSetAfterConnecting = {}; + Array.from(urlParams.entries()).forEach(([key, value]) => { + if (key.startsWith('neodash_')) { + paramsToSetAfterConnecting[key] = value; + } + }); + if (Object.keys(paramsToSetAfterConnecting).length > 0) { + dispatch(setParametersToLoadAfterConnecting(paramsToSetAfterConnecting)); + } - if (urlParams.get("share") !== null) { - const id = decodeURIComponent(urlParams.get("id")); - const type = urlParams.get("type"); - const standalone = urlParams.get("standalone") == "Yes"; - const dashboardDatabase = urlParams.get("dashboardDatabase"); - if (urlParams.get("credentials")) { - const connection = decodeURIComponent(urlParams.get("credentials")); - const protocol = connection.split("://")[0]; - const username = connection.split("://")[1].split(":")[0]; - const password = connection.split("://")[1].split(":")[1].split("@")[0]; - - const database = connection.split("@")[1].split(":")[0]; - const url = connection.split("@")[1].split(":")[1]; - const port = connection.split("@")[1].split(":")[2]; - - if (url == password) { - // Special case where a connect link is generated without a password. - // Here, the format is parsed incorrectly and we open the connection window instead. - - dispatch(resetShareDetails()); - dispatch(setConnectionProperties("neo4j", url, "7687", database, username.split("@")[0], "")); - dispatch(setWelcomeScreenOpen(false)); - dispatch(setConnectionModalOpen(true)); - // window.history.pushState({}, document.title, "/"); - return - } - - dispatch(setConnectionModalOpen(false)); - dispatch(setShareDetailsFromUrl(type, id, standalone, protocol, url, port, database, username, password, dashboardDatabase)); - window.history.pushState({}, document.title, "/"); - } else { - dispatch(setConnectionModalOpen(false)); - dispatch(setShareDetailsFromUrl(type, id, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined)); - window.history.pushState({}, document.title, "/"); - } - } else { - // dispatch(resetShareDetails()); + if (urlParams.get('share') !== null) { + const id = decodeURIComponent(urlParams.get('id')); + const type = urlParams.get('type'); + const standalone = urlParams.get('standalone') == 'Yes'; + const dashboardDatabase = urlParams.get('dashboardDatabase'); + if (urlParams.get('credentials')) { + const connection = decodeURIComponent(urlParams.get('credentials')); + const protocol = connection.split('://')[0]; + const username = connection.split('://')[1].split(':')[0]; + const password = connection.split('://')[1].split(':')[1].split('@')[0]; + + const database = connection.split('@')[1].split(':')[0]; + const url = connection.split('@')[1].split(':')[1]; + const port = connection.split('@')[1].split(':')[2]; + + if (url == password) { + // Special case where a connect link is generated without a password. + // Here, the format is parsed incorrectly and we open the connection window instead. + + dispatch(resetShareDetails()); + dispatch(setConnectionProperties('neo4j', url, '7687', database, username.split('@')[0], '')); + dispatch(setWelcomeScreenOpen(false)); + dispatch(setConnectionModalOpen(true)); + // window.history.pushState({}, document.title, "/"); + return; } - } catch (e) { - dispatch(createNotificationThunk("Unable to load shared dashboard", "You have specified an invalid/incomplete share URL. Try regenerating the share URL from the sharing window.")); + dispatch(setConnectionModalOpen(false)); + dispatch( + setShareDetailsFromUrl( + type, + id, + standalone, + protocol, + url, + port, + database, + username, + password, + dashboardDatabase + ) + ); + window.history.pushState({}, document.title, '/'); + } else { + dispatch(setConnectionModalOpen(false)); + dispatch( + setShareDetailsFromUrl( + type, + id, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ) + ); + window.history.pushState({}, document.title, '/'); + } + } else { + // dispatch(resetShareDetails()); } -} - + } catch (e) { + dispatch( + createNotificationThunk( + 'Unable to load shared dashboard', + 'You have specified an invalid/incomplete share URL. Try regenerating the share URL from the sharing window.' + ) + ); + } +}; /** * Confirm that we load a shared dashboard. This requires that the state was previously set in `handleSharedDashboardsThunk()`. */ export const onConfirmLoadSharedDashboardThunk = () => (dispatch: any, getState: any) => { - try { - const state = getState(); - const shareDetails = state.application.shareDetails; - dispatch(setWelcomeScreenOpen(false)); - dispatch(setDashboardToLoadAfterConnecting(shareDetails.id)); - + try { + const state = getState(); + const { shareDetails } = state.application; + dispatch(setWelcomeScreenOpen(false)); + dispatch(setDashboardToLoadAfterConnecting(shareDetails.id)); - if (shareDetails.dashboardDatabase) { - dispatch(setStandaloneDashboardDatabase(shareDetails.dashboardDatabase)); - dispatch(setStandaloneDashboardDatabase(shareDetails.database)); - } - if (shareDetails.url) { - dispatch(createConnectionThunk(shareDetails.protocol, shareDetails.url, shareDetails.port, shareDetails.database, shareDetails.username, shareDetails.password)); - } else { - dispatch(setConnectionModalOpen(true)); - } - if (shareDetails.standalone == true) { - dispatch(setStandaloneMode(true)); - } - dispatch(resetShareDetails()); - } catch (e) { - dispatch(createNotificationThunk("Unable to load shared dashboard", "The provided connection or dashboard identifiers are invalid. Try regenerating the share URL from the sharing window.")); + if (shareDetails.dashboardDatabase) { + dispatch(setStandaloneDashboardDatabase(shareDetails.dashboardDatabase)); + dispatch(setStandaloneDashboardDatabase(shareDetails.database)); } -} - + if (shareDetails.url) { + dispatch( + createConnectionThunk( + shareDetails.protocol, + shareDetails.url, + shareDetails.port, + shareDetails.database, + shareDetails.username, + shareDetails.password + ) + ); + } else { + dispatch(setConnectionModalOpen(true)); + } + if (shareDetails.standalone == true) { + dispatch(setStandaloneMode(true)); + } + dispatch(resetShareDetails()); + } catch (e) { + dispatch( + createNotificationThunk( + 'Unable to load shared dashboard', + 'The provided connection or dashboard identifiers are invalid. Try regenerating the share URL from the sharing window.' + ) + ); + } +}; /** * Initializes the NeoDash application. - * + * * This is a multi step process, starting with loading the runtime configuration. * This is present in the file located at /config.json on the URL where NeoDash is deployed. * Note: this does not work in Neo4j Desktop, so we revert to defaults. */ export const loadApplicationConfigThunk = () => async (dispatch: any, getState: any) => { - var config = { - ssoEnabled: false, - ssoDiscoveryUrl: "http://example.com", - standalone: false, - standaloneProtocol: "neo4j", - standaloneHost: "localhost", - standalonePort: "7687", - standaloneDatabase: "neo4j", - standaloneDashboardName: "My Dashboard", - standaloneDashboardDatabase: "dashboards", - standaloneDashboardURL: "" - }; - try { - config = await (await fetch("config.json")).json(); - } catch (e) { - // Config may not be found, for example when we are in Neo4j Desktop. - console.log("No config file detected. Setting to safe defaults."); + let config = { + ssoEnabled: false, + ssoDiscoveryUrl: 'http://example.com', + standalone: false, + standaloneProtocol: 'neo4j', + standaloneHost: 'localhost', + standalonePort: '7687', + standaloneDatabase: 'neo4j', + standaloneDashboardName: 'My Dashboard', + standaloneDashboardDatabase: 'dashboards', + standaloneDashboardURL: '', + }; + try { + config = await (await fetch('config.json')).json(); + } catch (e) { + // Config may not be found, for example when we are in Neo4j Desktop. + // eslint-disable-next-line no-console + console.log('No config file detected. Setting to safe defaults.'); + } + + try { + // Parse the URL parameters to see if there's any deep linking of parameters. + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + const paramsToSetAfterConnecting = {}; + Array.from(urlParams.entries()).forEach(([key, value]) => { + if (key.startsWith('neodash_')) { + paramsToSetAfterConnecting[key] = value; + } + }); + + const page = urlParams.get('page'); + if (page !== '' && page !== null) { + if (!isNaN(page)) { + dispatch(setPageNumberThunk(parseInt(page))); + } } - try { - // Parse the URL parameters to see if there's any deep linking of parameters. - const queryString = window.location.search; - const urlParams = new URLSearchParams(queryString); - const paramsToSetAfterConnecting = {} - Array.from(urlParams.entries()).forEach(([key, value]) => { - if (key.startsWith("neodash_")) { - paramsToSetAfterConnecting[key] = value; - } - }); - - const page = urlParams.get('page'); - if (page !== "" && page !== null) { - if (!isNaN(page)) { - dispatch(setPageNumberThunk(parseInt(page))); - } - } - - dispatch(setSSOEnabled(config['ssoEnabled'], config["ssoDiscoveryUrl"])); - const state = getState(); - const standalone = config['standalone']; - dispatch(setStandaloneEnabled(standalone, config['standaloneProtocol'], config['standaloneHost'], config['standalonePort'], config['standaloneDatabase'], config['standaloneDashboardName'], config['standaloneDashboardDatabase'], config["standaloneDashboardURL"], config['standaloneUsername'], config['standalonePassword'])) - dispatch(setConnectionModalOpen(false)); - - // Auto-upgrade the dashboard version if an old version is cached. - if (state.dashboard && state.dashboard.version !== NEODASH_VERSION) { - if (state.dashboard.version == "2.0") { - const upgradedDashboard = upgradeDashboardVersion(state.dashboard, "2.0", "2.1"); - dispatch(setDashboard(upgradedDashboard)); - dispatch(createNotificationThunk("Successfully upgraded dashboard", "Your old dashboard was migrated to version 2.1. You might need to refresh this page.")); - } - if (state.dashboard.version == "2.1") { - const upgradedDashboard = upgradeDashboardVersion(state.dashboard, "2.1", "2.2"); - dispatch(setDashboard(upgradedDashboard)); - dispatch(createNotificationThunk("Successfully upgraded dashboard", "Your old dashboard was migrated to version 2.2. You might need to refresh this page.")); - } - } - - // SSO - specific case starts here. - if (state.application.waitForSSO) { - // We just got redirected from the SSO provider. Hide all windows and attempt the connection. - dispatch(setAboutModalOpen(false)); - dispatch(setConnected(false)); - dispatch(setWelcomeScreenOpen(false)); - const success = await initializeSSO(config["ssoDiscoveryUrl"], (credentials) => { - if (standalone) { - dispatch(setConnectionProperties(config['standaloneProtocol'], config['standaloneHost'], config['standalonePort'], config['standaloneDatabase'], credentials['username'], credentials['password'])); - dispatch(createConnectionThunk(config['standaloneProtocol'], config['standaloneHost'], config['standalonePort'], config['standaloneDatabase'], credentials['username'], credentials['password'])); - if (config['standaloneDashboardURL'] !== undefined && config['standaloneDashboardURL'].length > 0) { - dispatch(setDashboardToLoadAfterConnecting(config['standaloneDashboardURL'])); - } else { - dispatch(setDashboardToLoadAfterConnecting("name:" + config['standaloneDashboardName'])); - } - dispatch(setParametersToLoadAfterConnecting(paramsToSetAfterConnecting)); - } - }); - dispatch(setWaitForSSO(false)); - if (!success) { - alert("Unable to connect using SSO"); - dispatch(createNotificationThunk("Unable to connect using SSO", "Something went wrong. Most likely your credentials are incorrect...")); - } else { - return; - } - } + dispatch(setSSOEnabled(config.ssoEnabled, config.ssoDiscoveryUrl)); + const state = getState(); + const { standalone } = config; + dispatch( + setStandaloneEnabled( + standalone, + config.standaloneProtocol, + config.standaloneHost, + config.standalonePort, + config.standaloneDatabase, + config.standaloneDashboardName, + config.standaloneDashboardDatabase, + config.standaloneDashboardURL, + config.standaloneUsername, + config.standalonePassword + ) + ); + dispatch(setConnectionModalOpen(false)); + + // Auto-upgrade the dashboard version if an old version is cached. + if (state.dashboard && state.dashboard.version !== NEODASH_VERSION) { + if (state.dashboard.version == '2.0') { + const upgradedDashboard = upgradeDashboardVersion(state.dashboard, '2.0', '2.1'); + dispatch(setDashboard(upgradedDashboard)); + dispatch( + createNotificationThunk( + 'Successfully upgraded dashboard', + 'Your old dashboard was migrated to version 2.1. You might need to refresh this page.' + ) + ); + } + if (state.dashboard.version == '2.1') { + const upgradedDashboard = upgradeDashboardVersion(state.dashboard, '2.1', '2.2'); + dispatch(setDashboard(upgradedDashboard)); + dispatch( + createNotificationThunk( + 'Successfully upgraded dashboard', + 'Your old dashboard was migrated to version 2.2. You might need to refresh this page.' + ) + ); + } + } + // SSO - specific case starts here. + if (state.application.waitForSSO) { + // We just got redirected from the SSO provider. Hide all windows and attempt the connection. + dispatch(setAboutModalOpen(false)); + dispatch(setConnected(false)); + dispatch(setWelcomeScreenOpen(false)); + const success = await initializeSSO(config.ssoDiscoveryUrl, (credentials) => { if (standalone) { - dispatch(initializeApplicationAsStandaloneThunk(config, paramsToSetAfterConnecting)); - } else { - dispatch(initializeApplicationAsEditorThunk(config, paramsToSetAfterConnecting)); + dispatch( + setConnectionProperties( + config.standaloneProtocol, + config.standaloneHost, + config.standalonePort, + config.standaloneDatabase, + credentials.username, + credentials.password + ) + ); + dispatch( + createConnectionThunk( + config.standaloneProtocol, + config.standaloneHost, + config.standalonePort, + config.standaloneDatabase, + credentials.username, + credentials.password + ) + ); + if (config.standaloneDashboardURL !== undefined && config.standaloneDashboardURL.length > 0) { + dispatch(setDashboardToLoadAfterConnecting(config.standaloneDashboardURL)); + } else { + dispatch(setDashboardToLoadAfterConnecting(`name:${config.standaloneDashboardName}`)); + } + dispatch(setParametersToLoadAfterConnecting(paramsToSetAfterConnecting)); } - } catch (e) { - dispatch(setWelcomeScreenOpen(false)); - dispatch(createNotificationThunk("Unable to load application configuration", "Do you have a valid config.json deployed with your application?")); + }); + dispatch(setWaitForSSO(false)); + if (!success) { + alert('Unable to connect using SSO'); + dispatch( + createNotificationThunk( + 'Unable to connect using SSO', + 'Something went wrong. Most likely your credentials are incorrect...' + ) + ); + } else { + return; + } } -} -// Set up NeoDash to run in editor mode. -export const initializeApplicationAsEditorThunk = (config, paramsToSetAfterConnecting) => async (dispatch: any, getState: any) => { - const clearNotificationAfterLoad = true; - dispatch(clearDesktopConnectionProperties()); - dispatch(setDatabaseFromNeo4jDesktopIntegrationThunk()); - const old = localStorage.getItem('neodash-dashboard'); - dispatch(setOldDashboard(old)); - dispatch(setConnected(false)); - dispatch(setDashboardToLoadAfterConnecting(null)); - dispatch(updateGlobalParametersThunk(paramsToSetAfterConnecting)); - if (Object.keys(paramsToSetAfterConnecting).length > 0) { - dispatch(setParametersToLoadAfterConnecting(null)); + if (standalone) { + dispatch(initializeApplicationAsStandaloneThunk(config, paramsToSetAfterConnecting)); + } else { + dispatch(initializeApplicationAsEditorThunk(config, paramsToSetAfterConnecting)); } + } catch (e) { + dispatch(setWelcomeScreenOpen(false)); + dispatch( + createNotificationThunk( + 'Unable to load application configuration', + 'Do you have a valid config.json deployed with your application?' + ) + ); + } +}; - dispatch(setWelcomeScreenOpen(true)); - - if (clearNotificationAfterLoad) { - dispatch(clearNotification()); - } - dispatch(handleSharedDashboardsThunk()); - dispatch(setReportHelpModalOpen(false)); - dispatch(setAboutModalOpen(false)); -} +// Set up NeoDash to run in editor mode. +export const initializeApplicationAsEditorThunk = (_, paramsToSetAfterConnecting) => (dispatch: any) => { + const clearNotificationAfterLoad = true; + dispatch(clearDesktopConnectionProperties()); + dispatch(setDatabaseFromNeo4jDesktopIntegrationThunk()); + const old = localStorage.getItem('neodash-dashboard'); + dispatch(setOldDashboard(old)); + dispatch(setConnected(false)); + dispatch(setDashboardToLoadAfterConnecting(null)); + dispatch(updateGlobalParametersThunk(paramsToSetAfterConnecting)); + // TODO: this logic around loading/saving/upgrading/migrating dashboards needs a cleanup + if (Object.keys(paramsToSetAfterConnecting).length > 0) { + dispatch(setParametersToLoadAfterConnecting(null)); + } + + dispatch(setWelcomeScreenOpen(true)); + + if (clearNotificationAfterLoad) { + dispatch(clearNotification()); + } + dispatch(handleSharedDashboardsThunk()); + dispatch(setReportHelpModalOpen(false)); + dispatch(setAboutModalOpen(false)); +}; // Set up NeoDash to run in standalone mode. -export const initializeApplicationAsStandaloneThunk = (config, paramsToSetAfterConnecting) => async (dispatch: any, getState: any) => { +export const initializeApplicationAsStandaloneThunk = + (config, paramsToSetAfterConnecting) => (dispatch: any, getState: any) => { const clearNotificationAfterLoad = true; const state = getState(); // If we are running in standalone mode, auto-set the connection details that are configured. - dispatch(setConnectionProperties( - config['standaloneProtocol'], - config['standaloneHost'], - config['standalonePort'], - config['standaloneDatabase'], - config['standaloneUsername'] ? config['standaloneUsername'] : state.application.connection.username, - config['standalonePassword'] ? config['standalonePassword'] : state.application.connection.password)); + dispatch( + setConnectionProperties( + config.standaloneProtocol, + config.standaloneHost, + config.standalonePort, + config.standaloneDatabase, + config.standaloneUsername ? config.standaloneUsername : state.application.connection.username, + config.standalonePassword ? config.standalonePassword : state.application.connection.password + ) + ); dispatch(setAboutModalOpen(false)); dispatch(setConnected(false)); dispatch(setWelcomeScreenOpen(false)); - if (config['standaloneDashboardURL'] !== undefined && config['standaloneDashboardURL'].length > 0) { - dispatch(setDashboardToLoadAfterConnecting(config['standaloneDashboardURL'])); + if (config.standaloneDashboardURL !== undefined && config.standaloneDashboardURL.length > 0) { + dispatch(setDashboardToLoadAfterConnecting(config.standaloneDashboardURL)); } else { - dispatch(setDashboardToLoadAfterConnecting("name:" + config['standaloneDashboardName'])); + dispatch(setDashboardToLoadAfterConnecting(`name:${config.standaloneDashboardName}`)); } dispatch(setParametersToLoadAfterConnecting(paramsToSetAfterConnecting)); if (clearNotificationAfterLoad) { - dispatch(clearNotification()); + dispatch(clearNotification()); } // Override for when username and password are specified in the config - automatically connect to the specified URL. - if (config['standaloneUsername'] && config['standalonePassword']) { - dispatch(createConnectionThunk(config['standaloneProtocol'], - config['standaloneHost'], - config['standalonePort'], - config['standaloneDatabase'], - config['standaloneUsername'], - config['standalonePassword'])); + if (config.standaloneUsername && config.standalonePassword) { + dispatch( + createConnectionThunk( + config.standaloneProtocol, + config.standaloneHost, + config.standalonePort, + config.standaloneDatabase, + config.standaloneUsername, + config.standalonePassword + ) + ); } else { - dispatch(setConnectionModalOpen(true)); + dispatch(setConnectionModalOpen(true)); } -} - + }; diff --git a/src/card/Card.tsx b/src/card/Card.tsx index 1e0b2882c..1552bec3a 100644 --- a/src/card/Card.tsx +++ b/src/card/Card.tsx @@ -1,266 +1,273 @@ import Card from '@material-ui/core/Card'; import Collapse from '@material-ui/core/Collapse'; -import React, {useCallback, useContext, useEffect, useState} from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import NeoCardSettings from './settings/CardSettings'; import NeoCardView from './view/CardView'; -import {connect} from 'react-redux'; +import { connect } from 'react-redux'; import { - updateCypherParametersThunk, - updateFieldsThunk, - updateSelectionThunk, - updateReportQueryThunk, - toggleCardSettingsThunk, - updateReportRefreshRateThunk, - updateReportSettingThunk, - updateReportTitleThunk, - updateReportTypeThunk, - updateReportDatabaseThunk + updateFieldsThunk, + updateSelectionThunk, + updateReportQueryThunk, + toggleCardSettingsThunk, + updateReportRefreshRateThunk, + updateReportSettingThunk, + updateReportTitleThunk, + updateReportTypeThunk, + updateReportDatabaseThunk, } from './CardThunks'; -import {toggleReportSettings} from './CardActions'; -import {getReportState} from './CardSelectors'; -import {debounce, Dialog, DialogContent} from '@material-ui/core'; -import {getDashboardIsEditable, getDatabase, getGlobalParameters, getSessionParameters} from '../settings/SettingsSelectors'; -import {updateGlobalParameterThunk} from '../settings/SettingsThunks'; -import {createNotificationThunk} from '../page/PageThunks'; +import { toggleReportSettings } from './CardActions'; +import { getReportState } from './CardSelectors'; +import { debounce, Dialog, DialogContent } from '@material-ui/core'; +import { + getDashboardIsEditable, + getDatabase, + getGlobalParameters, + getSessionParameters, +} from '../settings/SettingsSelectors'; +import { updateGlobalParameterThunk } from '../settings/SettingsThunks'; import useDimensions from 'react-cool-dimensions'; -import {setReportHelpModalOpen} from '../application/ApplicationActions'; -import {loadDatabaseListFromNeo4jThunk} from "../dashboard/DashboardThunks"; -import {Neo4jContext, Neo4jContextState} from "use-neo4j/dist/neo4j.context"; +import { setReportHelpModalOpen } from '../application/ApplicationActions'; +import { loadDatabaseListFromNeo4jThunk } from '../dashboard/DashboardThunks'; +import { Neo4jContext, Neo4jContextState } from 'use-neo4j/dist/neo4j.context'; import { getDashboardExtensions } from '../dashboard/DashboardSelectors'; - +import { downloadComponentAsImage } from '../chart/ChartUtils'; const NeoCard = ({ - index, // index of the card. - report, // state of the card, retrieved based on card index. - editable, // whether the card is editable. - database, // the neo4j database that the card is running against. - extensions, // A set of enabled extensions. - globalParameters, // Query parameters that are globally set for the entire dashboard. - dashboardSettings, // Dictionary of settings for the entire dashboard. - onRemovePressed, // action to take when the card is removed. (passed from parent) - onClonePressed, // action to take when user presses the clone button - onReportHelpButtonPressed, // action to take when someone clicks the 'help' button in the report settings. - onTitleUpdate, // action to take when the card title is updated. - onTypeUpdate, // action to take when the card report type is updated. - onFieldsUpdate, // action to take when the set of returned query fields is updated. - onQueryUpdate, // action to take when the card query is updated. - onRefreshRateUpdate, // action to take when the card refresh rate is updated. - onReportSettingUpdate, // action to take when an advanced report setting is updated. - onSelectionUpdate, // action to take when the selected visualization fields are updated. - onGlobalParameterUpdate, // action to take when a report updates a dashboard parameter. - onToggleCardSettings, // action to take when the card settings button is clicked. - onToggleReportSettings, // action to take when the report settings (advanced settings) button is clicked. - onCreateNotification, // action to take when an (error) notification is created. - onDatabaseChanged, // action to take when the user changes the database related to the card - loadDatabaseListFromNeo4j // Thunk to get the list of databases - }) => { + index, // index of the card. + report, // state of the card, retrieved based on card index. + editable, // whether the card is editable. + database, // the neo4j database that the card is running against. + extensions, // A set of enabled extensions. + globalParameters, // Query parameters that are globally set for the entire dashboard. + dashboardSettings, // Dictionary of settings for the entire dashboard. + onRemovePressed, // action to take when the card is removed. (passed from parent) + onClonePressed, // action to take when user presses the clone button + onReportHelpButtonPressed, // action to take when someone clicks the 'help' button in the report settings. + onTitleUpdate, // action to take when the card title is updated. + onTypeUpdate, // action to take when the card report type is updated. + onFieldsUpdate, // action to take when the set of returned query fields is updated. + onQueryUpdate, // action to take when the card query is updated. + onRefreshRateUpdate, // action to take when the card refresh rate is updated. + onReportSettingUpdate, // action to take when an advanced report setting is updated. + onSelectionUpdate, // action to take when the selected visualization fields are updated. + onGlobalParameterUpdate, // action to take when a report updates a dashboard parameter. + onToggleCardSettings, // action to take when the card settings button is clicked. + onToggleReportSettings, // action to take when the report settings (advanced settings) button is clicked. + onDatabaseChanged, // action to take when the user changes the database related to the card + loadDatabaseListFromNeo4j, // Thunk to get the list of databases +}) => { + // Will be used to fetch the list of current databases + const { driver } = useContext(Neo4jContext); - // Will be used to fetch the list of current databases - const {driver} = useContext(Neo4jContext); + const [databaseList, setDatabaseList] = React.useState([database]); + const [databaseListLoaded, setDatabaseListLoaded] = React.useState(false); - const [databaseList, setDatabaseList] = React.useState([database]) - const [databaseListLoaded, setDatabaseListLoaded] = React.useState(false); + const ref = React.useRef(); - // fetching the list of databases from neo4j, filtering out the 'system' db - useEffect(() => { - if(!databaseListLoaded){ - loadDatabaseListFromNeo4j(driver, (result) => { - let index = result.indexOf("system") - if (index > -1) { // only splice array when item is found - result.splice(index, 1); // 2nd parameter means remove one item only - } - setDatabaseList(result) - }); - setDatabaseListLoaded(true); + // fetching the list of databases from neo4j, filtering out the 'system' db + useEffect(() => { + if (!databaseListLoaded) { + loadDatabaseListFromNeo4j(driver, (result) => { + let index = result.indexOf('system'); + if (index > -1) { + // only splice array when item is found + result.splice(index, 1); // 2nd parameter means remove one item only } - }, [report.query]); - - const [settingsOpen, setSettingsOpen] = React.useState(false); - const debouncedOnToggleCardSettings = useCallback( - debounce(onToggleCardSettings, 500), - [], - ); - const [collapseTimeout, setCollapseTimeout] = React.useState(report.collapseTimeout); + setDatabaseList(result); + }); + setDatabaseListLoaded(true); + } + }, [report.query]); - const {observe, unobserve, width, height, entry} = useDimensions({ - onResize: ({observe, unobserve, width, height, entry}) => { - // Triggered whenever the size of the target is changed... - unobserve(); // To stop observing the current target element - observe(); // To re-start observing the current target element - }, - }); + const [settingsOpen, setSettingsOpen] = React.useState(false); + const debouncedOnToggleCardSettings = useCallback(debounce(onToggleCardSettings, 500), []); + const [collapseTimeout, setCollapseTimeout] = React.useState(report.collapseTimeout); + const { observe, width, height } = useDimensions({ + onResize: ({ observe, unobserve }) => { + // Triggered whenever the size of the target is changed... + unobserve(); // To stop observing the current target element + observe(); // To re-start observing the current target element + }, + }); - const [expanded, setExpanded] = useState(false); - const onToggleCardExpand = () => { - // When we re-minimize a card, close the settings to avoid position issues. - if (expanded && settingsOpen) { - onToggleCardSettings(index, false); - } - setExpanded(!expanded); + const [expanded, setExpanded] = useState(false); + const onToggleCardExpand = () => { + // When we re-minimize a card, close the settings to avoid position issues. + if (expanded && settingsOpen) { + onToggleCardSettings(index, false); } + setExpanded(!expanded); + }; - const [active, setActive] = React.useState(report.settings && report.settings.autorun !== undefined ? report.settings.autorun : true); - - useEffect(() => { - if (!report.settingsOpen) { - setActive(report.settings && report.settings.autorun !== undefined ? report.settings.autorun : true); - } - }, [report.query]) + const [active, setActive] = React.useState( + report.settings && report.settings.autorun !== undefined ? report.settings.autorun : true + ); + useEffect(() => { + if (!report.settingsOpen) { + setActive(report.settings && report.settings.autorun !== undefined ? report.settings.autorun : true); + } + }, [report.query]); - useEffect(() => { - setSettingsOpen(report.settingsOpen); - }, [report.settingsOpen]) + useEffect(() => { + setSettingsOpen(report.settingsOpen); + }, [report.settingsOpen]); - useEffect(() => { - setCollapseTimeout(report.collapseTimeout); - }, [report.collapseTimeout]) + useEffect(() => { + setCollapseTimeout(report.collapseTimeout); + }, [report.collapseTimeout]); - // TODO - get rid of some of the props-drilling here... - const component =
- {/* The front of the card, referred to as the 'view' */} - - - onSelectionUpdate(index, selectable, field)} - onTitleUpdate={(title) => onTitleUpdate(index, title)} - onFieldsUpdate={(fields) => onFieldsUpdate(index, fields)} - onToggleCardSettings={() => { - setSettingsOpen(true); - setCollapseTimeout("auto"); - debouncedOnToggleCardSettings(index, true) - }}/> - - - {/* The back of the card, referred to as the 'settings' */} - - - onQueryUpdate(index, query)} - onRefreshRateUpdate={(rate) => onRefreshRateUpdate(index, rate)} - onDatabaseChanged={(database) => onDatabaseChanged(index, database)} - onReportSettingUpdate={(setting, value) => onReportSettingUpdate(index, setting, value)} - onTypeUpdate={(type) => onTypeUpdate(index, type)} - onReportHelpButtonPressed={() => onReportHelpButtonPressed()} - onRemovePressed={() => onRemovePressed(index)} - onClonePressed={() => onClonePressed(index)} - onCreateNotification={(title, message) => onCreateNotification(title, message)} - onToggleCardSettings={() => { - setSettingsOpen(false); - setCollapseTimeout("auto"); - debouncedOnToggleCardSettings(index, false); - }} - onToggleReportSettings={() => onToggleReportSettings(index)}/> - - -
; + // TODO - get rid of some of the props-drilling here... + const component = ( +
+ {/* The front of the card, referred to as the 'view' */} + + + downloadComponentAsImage(ref)} + query={report.query} + globalParameters={globalParameters} + fields={report.fields ? report.fields : []} + refreshRate={report.refreshRate} + selection={report.selection} + widthPx={width} + heightPx={height} + title={report.title} + expanded={expanded} + onToggleCardExpand={onToggleCardExpand} + onGlobalParameterUpdate={onGlobalParameterUpdate} + onSelectionUpdate={(selectable, field) => onSelectionUpdate(index, selectable, field)} + onTitleUpdate={(title) => onTitleUpdate(index, title)} + onFieldsUpdate={(fields) => onFieldsUpdate(index, fields)} + onToggleCardSettings={() => { + setSettingsOpen(true); + setCollapseTimeout('auto'); + debouncedOnToggleCardSettings(index, true); + }} + /> + + + {/* The back of the card, referred to as the 'settings' */} + + + onQueryUpdate(index, query)} + onRefreshRateUpdate={(rate) => onRefreshRateUpdate(index, rate)} + onDatabaseChanged={(database) => onDatabaseChanged(index, database)} + onReportSettingUpdate={(setting, value) => onReportSettingUpdate(index, setting, value)} + onTypeUpdate={(type) => onTypeUpdate(index, type)} + onReportHelpButtonPressed={() => onReportHelpButtonPressed()} + onRemovePressed={() => onRemovePressed(index)} + onClonePressed={() => onClonePressed(index)} + onToggleCardSettings={() => { + setSettingsOpen(false); + setCollapseTimeout('auto'); + debouncedOnToggleCardSettings(index, false); + }} + onToggleReportSettings={() => onToggleReportSettings(index)} + /> + + +
+ ); - // If the card is viewed in fullscreen, wrap it in a dialog. - // TODO - this causes a re-render (and therefore, a re-run of the report) - // Look into React Portals: https://stackoverflow.com/questions/61432878/how-to-render-child-component-outside-of-its-parent-component-dom-hierarchy - if (expanded) { - return - - {component} - - - } - return component; + // If the card is viewed in fullscreen, wrap it in a dialog. + // TODO - this causes a re-render (and therefore, a re-run of the report) + // Look into React Portals: https://stackoverflow.com/questions/61432878/how-to-render-child-component-outside-of-its-parent-component-dom-hierarchy + if (expanded) { + return ( + + + {component} + + + ); + } + return component; }; const mapStateToProps = (state, ownProps) => ({ - report: getReportState(state, ownProps.index), - extensions: getDashboardExtensions(state), - editable: getDashboardIsEditable(state), - database: getDatabase(state, ownProps && ownProps.dashboardSettings ? ownProps.dashboardSettings.pagenumber : undefined, ownProps.index), - globalParameters: {...getGlobalParameters(state), ...getSessionParameters(state)} + report: getReportState(state, ownProps.index), + extensions: getDashboardExtensions(state), + editable: getDashboardIsEditable(state), + database: getDatabase( + state, + ownProps && ownProps.dashboardSettings ? ownProps.dashboardSettings.pagenumber : undefined, + ownProps.index + ), + globalParameters: { ...getGlobalParameters(state), ...getSessionParameters(state) }, }); -const mapDispatchToProps = dispatch => ({ - onTitleUpdate: (index: any, title: any) => { - dispatch(updateReportTitleThunk(index, title)) - }, - onQueryUpdate: (index: any, query: any) => { - dispatch(updateReportQueryThunk(index, query)) - }, - onRefreshRateUpdate: (index: any, rate: any) => { - dispatch(updateReportRefreshRateThunk(index, rate)) - }, - onTypeUpdate: (index: any, type: any) => { - dispatch(updateReportTypeThunk(index, type)) - }, - onReportSettingUpdate: (index: any, setting: any, value: any) => { - dispatch(updateReportSettingThunk(index, setting, value)) - }, - onFieldsUpdate: (index: any, fields: any) => { - dispatch(updateFieldsThunk(index, fields)) - }, - onGlobalParameterUpdate: (key: any, value: any) => { - dispatch(updateGlobalParameterThunk(key, value)) - }, - onSelectionUpdate: (index: any, selectable: any, field: any) => { - dispatch(updateSelectionThunk(index, selectable, field)) - }, - onToggleCardSettings: (index: any, open: any) => { - dispatch(toggleCardSettingsThunk(index, open)) - }, - onReportHelpButtonPressed: () => { - dispatch(setReportHelpModalOpen(true)) - }, - onToggleReportSettings: (index: any) => { - dispatch(toggleReportSettings(index)) - }, - onCreateNotification: (title: any, message: any) => { - dispatch(createNotificationThunk(title, message)) - }, - onDatabaseChanged: (index: any, database: any) => { - dispatch(updateReportDatabaseThunk(index, database)) - }, - loadDatabaseListFromNeo4j: (driver, callback) => dispatch(loadDatabaseListFromNeo4jThunk(driver, callback)) - +const mapDispatchToProps = (dispatch) => ({ + onTitleUpdate: (index: any, title: any) => { + dispatch(updateReportTitleThunk(index, title)); + }, + onQueryUpdate: (index: any, query: any) => { + dispatch(updateReportQueryThunk(index, query)); + }, + onRefreshRateUpdate: (index: any, rate: any) => { + dispatch(updateReportRefreshRateThunk(index, rate)); + }, + onTypeUpdate: (index: any, type: any) => { + dispatch(updateReportTypeThunk(index, type)); + }, + onReportSettingUpdate: (index: any, setting: any, value: any) => { + dispatch(updateReportSettingThunk(index, setting, value)); + }, + onFieldsUpdate: (index: any, fields: any) => { + dispatch(updateFieldsThunk(index, fields)); + }, + onGlobalParameterUpdate: (key: any, value: any) => { + dispatch(updateGlobalParameterThunk(key, value)); + }, + onSelectionUpdate: (index: any, selectable: any, field: any) => { + dispatch(updateSelectionThunk(index, selectable, field)); + }, + onToggleCardSettings: (index: any, open: any) => { + dispatch(toggleCardSettingsThunk(index, open)); + }, + onReportHelpButtonPressed: () => { + dispatch(setReportHelpModalOpen(true)); + }, + onToggleReportSettings: (index: any) => { + dispatch(toggleReportSettings(index)); + }, + onDatabaseChanged: (index: any, database: any) => { + dispatch(updateReportDatabaseThunk(index, database)); + }, + loadDatabaseListFromNeo4j: (driver, callback) => dispatch(loadDatabaseListFromNeo4jThunk(driver, callback)), }); - export default connect(mapStateToProps, mapDispatchToProps)(NeoCard); - diff --git a/src/card/CardActions.ts b/src/card/CardActions.ts index 0d4a016ec..28e2c20f4 100644 --- a/src/card/CardActions.ts +++ b/src/card/CardActions.ts @@ -4,92 +4,90 @@ export const TOGGLE_CARD_SETTINGS = 'PAGE/CARD/TOGGLE_CARD_SETTINGS'; export const toggleCardSettings = (pagenumber: any, index: any, open: any) => ({ - type: TOGGLE_CARD_SETTINGS, - payload: { pagenumber, index, open }, + type: TOGGLE_CARD_SETTINGS, + payload: { pagenumber, index, open }, }); export const HARD_RESET_CARD_SETTINGS = 'PAGE/CARD/HARD_RESET_CARD_SETTINGS'; export const hardResetCardSettings = (pagenumber: any, index: any) => ({ - type: HARD_RESET_CARD_SETTINGS, - payload: { pagenumber, index }, + type: HARD_RESET_CARD_SETTINGS, + payload: { pagenumber, index }, }); export const UPDATE_REPORT_TITLE = 'PAGE/CARD/UPDATE_REPORT_TITLE'; export const updateReportTitle = (pagenumber: number, index: number, title: any) => ({ - type: UPDATE_REPORT_TITLE, - payload: { pagenumber, index, title }, + type: UPDATE_REPORT_TITLE, + payload: { pagenumber, index, title }, }); export const UPDATE_REPORT_SIZE = 'PAGE/CARD/UPDATE_REPORT_SIZE'; export const updateReportSize = (pagenumber: number, index: number, width: any, height: any) => ({ - type: UPDATE_REPORT_SIZE, - payload: { pagenumber, index, width, height }, + type: UPDATE_REPORT_SIZE, + payload: { pagenumber, index, width, height }, }); export const UPDATE_REPORT_QUERY = 'PAGE/CARD/UPDATE_REPORT_QUERY'; export const updateReportQuery = (pagenumber: number, index: number, query: any) => ({ - type: UPDATE_REPORT_QUERY, - payload: { pagenumber, index, query }, + type: UPDATE_REPORT_QUERY, + payload: { pagenumber, index, query }, }); export const UPDATE_REPORT_REFRESH_RATE = 'PAGE/CARD/UPDATE_REPORT_REFRESH_RATE'; export const updateReportRefreshRate = (pagenumber: number, index: number, rate: any) => ({ - type: UPDATE_REPORT_REFRESH_RATE, - payload: { pagenumber, index, rate }, + type: UPDATE_REPORT_REFRESH_RATE, + payload: { pagenumber, index, rate }, }); export const UPDATE_CYPHER_PARAMETERS = 'PAGE/CARD/UPDATE_CYPHER_PARAMETERS'; export const updateCypherParameters = (pagenumber: number, index: number, parameters: any) => ({ - type: UPDATE_CYPHER_PARAMETERS, - payload: { pagenumber, index, parameters }, + type: UPDATE_CYPHER_PARAMETERS, + payload: { pagenumber, index, parameters }, }); - export const UPDATE_REPORT_TYPE = 'PAGE/CARD/UPDATE_REPORT_TYPE'; export const updateReportType = (pagenumber: number, index: number, type: any) => ({ - type: UPDATE_REPORT_TYPE, - payload: { pagenumber, index, type }, + type: UPDATE_REPORT_TYPE, + payload: { pagenumber, index, type }, }); export const UPDATE_FIELDS = 'PAGE/CARD/UPDATE_FIELDS'; export const updateFields = (pagenumber: number, index: number, fields: any) => ({ - type: UPDATE_FIELDS, - payload: { pagenumber, index, fields }, + type: UPDATE_FIELDS, + payload: { pagenumber, index, fields }, }); export const UPDATE_SELECTION = 'PAGE/CARD/UPDATE_SELECTION'; export const updateSelection = (pagenumber: number, index: number, selectable: any, field: any) => ({ - type: UPDATE_SELECTION, - payload: { pagenumber, index, selectable, field }, + type: UPDATE_SELECTION, + payload: { pagenumber, index, selectable, field }, }); export const UPDATE_ALL_SELECTIONS = 'PAGE/CARD/UPDATE_ALL_SELECTIONS'; export const updateAllSelections = (pagenumber: number, index: number, selections: any) => ({ - type: UPDATE_ALL_SELECTIONS, - payload: { pagenumber, index, selections }, + type: UPDATE_ALL_SELECTIONS, + payload: { pagenumber, index, selections }, }); export const CLEAR_SELECTION = 'PAGE/CARD/CLEAR_SELECTION'; export const clearSelection = (pagenumber: number, index: number) => ({ - type: CLEAR_SELECTION, - payload: { pagenumber, index }, + type: CLEAR_SELECTION, + payload: { pagenumber, index }, }); - export const UPDATE_REPORT_SETTING = 'PAGE/CARD/UPDATE_REPORT_SETTING'; export const updateReportSetting = (pagenumber: number, index: number, setting: any, value: any) => ({ - type: UPDATE_REPORT_SETTING, - payload: { pagenumber, index, setting, value }, + type: UPDATE_REPORT_SETTING, + payload: { pagenumber, index, setting, value }, }); export const TOGGLE_REPORT_SETTINGS = 'PAGE/CARD/TOGGLE_REPORT_SETTINGS'; export const toggleReportSettings = (index: any) => ({ - type: TOGGLE_REPORT_SETTINGS, - payload: { index }, + type: TOGGLE_REPORT_SETTINGS, + payload: { index }, }); export const UPDATE_REPORT_DATABASE = 'PAGE/CARD/UPDATE_REPORT_DATABASE'; export const updateReportDatabase = (pagenumber: number, index: number, database: any) => ({ - type: UPDATE_REPORT_DATABASE, - payload: { pagenumber, index, database }, -}); \ No newline at end of file + type: UPDATE_REPORT_DATABASE, + payload: { pagenumber, index, database }, +}); diff --git a/src/card/CardAddButton.tsx b/src/card/CardAddButton.tsx index 7c0e1923e..d92be2f76 100644 --- a/src/card/CardAddButton.tsx +++ b/src/card/CardAddButton.tsx @@ -1,7 +1,5 @@ -import React, { useState } from 'react'; +import React from 'react'; import { connect } from 'react-redux'; -import { addReportThunk } from '../page/PageThunks'; -import { getReports } from '../page/PageSelectors'; import { Card, CardContent, Typography, Fab } from '@material-ui/core'; import AddIcon from '@material-ui/icons/Add'; @@ -9,30 +7,30 @@ import AddIcon from '@material-ui/icons/Add'; * Button to add a new report to the current page. */ const NeoAddNewCard = ({ onCreatePressed }) => { - return ( -
- - - - { - onCreatePressed(); - }} > - - - - - -
- ); + return ( +
+ + + + { + onCreatePressed(); + }} + > + + + + + +
+ ); }; -const mapStateToProps = state => ({ - -}); +const mapStateToProps = () => ({}); -const mapDispatchToProps = dispatch => ({ +const mapDispatchToProps = () => ({}); -}); - -export default connect(mapStateToProps, mapDispatchToProps)(NeoAddNewCard); \ No newline at end of file +export default connect(mapStateToProps, mapDispatchToProps)(NeoAddNewCard); diff --git a/src/card/CardReducer.ts b/src/card/CardReducer.ts index fcba4c3a1..8623503cd 100644 --- a/src/card/CardReducer.ts +++ b/src/card/CardReducer.ts @@ -1,151 +1,145 @@ import { - CLEAR_SELECTION, - HARD_RESET_CARD_SETTINGS, - TOGGLE_REPORT_SETTINGS, - UPDATE_ALL_SELECTIONS, - UPDATE_CYPHER_PARAMETERS, - UPDATE_FIELDS, - UPDATE_REPORT_QUERY, - UPDATE_REPORT_REFRESH_RATE, - UPDATE_REPORT_SETTING, - UPDATE_REPORT_SIZE, - UPDATE_REPORT_TITLE, - UPDATE_REPORT_TYPE, - UPDATE_SELECTION, - UPDATE_REPORT_DATABASE -} from "./CardActions"; -import {TOGGLE_CARD_SETTINGS} from "./CardActions"; + CLEAR_SELECTION, + HARD_RESET_CARD_SETTINGS, + TOGGLE_REPORT_SETTINGS, + UPDATE_ALL_SELECTIONS, + UPDATE_CYPHER_PARAMETERS, + UPDATE_FIELDS, + UPDATE_REPORT_QUERY, + UPDATE_REPORT_REFRESH_RATE, + UPDATE_REPORT_SETTING, + UPDATE_REPORT_SIZE, + UPDATE_REPORT_TITLE, + UPDATE_REPORT_TYPE, + UPDATE_SELECTION, + UPDATE_REPORT_DATABASE, +} from './CardActions'; +import { TOGGLE_CARD_SETTINGS } from './CardActions'; -const update = (state, mutations) => - Object.assign({}, state, mutations) +const update = (state, mutations) => Object.assign({}, state, mutations); /** * State reducers for a single card instance as part of a report. */ export const CARD_INITIAL_STATE = { - title: "", - query: '\n\n\n', - settingsOpen: false, - advancedSettingsOpen: false, - width: 3, - height: 3, - x: 0, - y: 0, - type: "table", - fields: [], - selection: {}, - settings: {}, - collapseTimeout: "auto", - }; + title: '', + query: '\n\n\n', + settingsOpen: false, + advancedSettingsOpen: false, + width: 3, + height: 3, + x: 0, + y: 0, + type: 'table', + fields: [], + selection: {}, + settings: {}, + collapseTimeout: 'auto', +}; +export const cardReducer = (state = CARD_INITIAL_STATE, action: { type: any; payload: any }) => { + const { type, payload } = action; -export const cardReducer = (state = CARD_INITIAL_STATE, action: { type: any; payload: any; }) => { - const {type, payload} = action; + if (!action.type.startsWith('PAGE/CARD/')) { + return state; + } - - if (!action.type.startsWith('PAGE/CARD/')) { - return state; + switch (type) { + case UPDATE_REPORT_TITLE: { + const { title } = payload; + state = update(state, { title: title }); + return state; } + case UPDATE_REPORT_SIZE: { + const { width, height } = payload; + state = update(state, { width: width, height: height }); + return state; + } + case UPDATE_REPORT_QUERY: { + const { query } = payload; + state = update(state, { query: query }); + return state; + } + case UPDATE_REPORT_REFRESH_RATE: { + const { rate } = payload; - switch (type) { - case UPDATE_REPORT_TITLE: { - const {pagenumber, index, title} = payload; - state = update(state, {title: title}) - return state; - } - case UPDATE_REPORT_SIZE: { - const {pagenumber, index, width, height} = payload; - state = update(state, {width: width, height: height}) - return state; - } - case UPDATE_REPORT_QUERY: { - const {pagenumber, index, query} = payload; - state = update(state, {query: query}) - return state; - } - case UPDATE_REPORT_REFRESH_RATE: { - const {pagenumber, index, rate} = payload; - - state = update(state, {refreshRate: rate}) - return state; - } - case UPDATE_CYPHER_PARAMETERS: { - const {pagenumber, index, parameters} = payload; - state = update(state, {parameters: parameters}) - return state; - } - case UPDATE_FIELDS: { - const {pagenumber, index, fields} = payload; - state = update(state, {fields: fields}) - return state; - } - case UPDATE_REPORT_TYPE: { - const {pagenumber, index, type} = payload; - state = update(state, {type: type}) - return state; - } - case CLEAR_SELECTION: { - const {pagenumber, index} = payload; - state = update(state, {selection: {}}) - return state; - } - case UPDATE_SELECTION: { - const {pagenumber, index, selectable, field} = payload; - const selection = (state.selection) ? (state.selection) : {}; + state = update(state, { refreshRate: rate }); + return state; + } + case UPDATE_CYPHER_PARAMETERS: { + const { parameters } = payload; + state = update(state, { parameters: parameters }); + return state; + } + case UPDATE_FIELDS: { + const { fields } = payload; + state = update(state, { fields: fields }); + return state; + } + case UPDATE_REPORT_TYPE: { + const { type } = payload; + state = update(state, { type: type }); + return state; + } + case CLEAR_SELECTION: { + state = update(state, { selection: {} }); + return state; + } + case UPDATE_SELECTION: { + const { selectable, field } = payload; + const selection = state.selection ? state.selection : {}; - const entry = {} - entry[selectable] = field; - state = update(state, {selection: update(selection, entry)}); - return state; - } + const entry = {}; + entry[selectable] = field; + state = update(state, { selection: update(selection, entry) }); + return state; + } - case UPDATE_ALL_SELECTIONS: { - const {pagenumber, index, selections} = payload; - state = update(state, {selection: selections}) - return state; - } + case UPDATE_ALL_SELECTIONS: { + const { selections } = payload; + state = update(state, { selection: selections }); + return state; + } - case UPDATE_REPORT_SETTING: { - const {pagenumber, index, setting, value} = payload; - const settings = (state.settings) ? (state.settings) : {}; + case UPDATE_REPORT_SETTING: { + const { setting, value } = payload; + const settings = state.settings ? state.settings : {}; - // Javascript is amazing, so "" == 0. Instead we check if the string length is zero... - if (value == undefined || value.toString().length == 0) { - delete settings[setting]; - update(state, {settings: settings}); - return state; - } + // Javascript is amazing, so "" == 0. Instead we check if the string length is zero... + if (value == undefined || value.toString().length == 0) { + delete settings[setting]; + update(state, { settings: settings }); + return state; + } - const entry = {} - entry[setting] = value; - state = update(state, {settings: update(settings, entry)}) - return state; - } - case TOGGLE_CARD_SETTINGS: { - const {pagenumber, index, open} = payload; - state = update(state, {settingsOpen: open, collapseTimeout: "auto"}) - return state; - } - case HARD_RESET_CARD_SETTINGS: { - const {pagenumber, index} = payload; - state = update(state, {settingsOpen: false, collapseTimeout: 0}) - return state; - } - case TOGGLE_REPORT_SETTINGS: { - const {index} = payload; - state = update(state, {advancedSettingsOpen: !state.advancedSettingsOpen}) - return state; - } - case UPDATE_REPORT_DATABASE: { - const {pagenumber, index, database} = payload; - state = update(state, {database: database}) - return state; - } - default: { - return state; - } - } -} + const entry = {}; + entry[setting] = value; + state = update(state, { settings: update(settings, entry) }); + return state; + } + case TOGGLE_CARD_SETTINGS: { + const { open } = payload; + state = update(state, { settingsOpen: open, collapseTimeout: 'auto' }); + return state; + } + case HARD_RESET_CARD_SETTINGS: { + state = update(state, { settingsOpen: false, collapseTimeout: 0 }); + return state; + } + case TOGGLE_REPORT_SETTINGS: { + state = update(state, { advancedSettingsOpen: !state.advancedSettingsOpen }); + return state; + } + case UPDATE_REPORT_DATABASE: { + const { database } = payload; + state = update(state, { database: database }); + return state; + } + default: { + return state; + } + } +}; -export default cardReducer; \ No newline at end of file +export default cardReducer; diff --git a/src/card/CardSelectors.ts b/src/card/CardSelectors.ts index 1ba318196..e4df95472 100644 --- a/src/card/CardSelectors.ts +++ b/src/card/CardSelectors.ts @@ -1,8 +1,6 @@ - export const getDashboardTitle = (state: any) => state.dashboard.title; - export const getReportState = (state: any, index: any) => { - const pagenumber = state.dashboard.settings.pagenumber; - return state.dashboard.pages[pagenumber].reports[index]; -} + const { pagenumber } = state.dashboard.settings; + return state.dashboard.pages[pagenumber].reports[index]; +}; diff --git a/src/card/CardStyle.ts b/src/card/CardStyle.ts index 47f56068e..214d1b301 100644 --- a/src/card/CardStyle.ts +++ b/src/card/CardStyle.ts @@ -1,49 +1,4 @@ +// TODO We need to refactor styled components import styled from 'styled-components'; -export const ListWrapper = styled.div` - max-width: 700px; - margin: auto; -` -export const ReportItemContainer = styled.div` - -`; - - -export const getBorderStyleForDate = (startingDate: number | Date, currentDate: number) => { - return (startingDate > new Date(currentDate - 8640000 * 5)) ? 'none' : 'none'; -} - -export const ReportItemContainerWithWarning = styled(ReportItemContainer)` -border-bottom: ${(props: { createdAt: string | number | Date; }) => getBorderStyleForDate(new Date(props.createdAt), Date.now())}; -`; - -export const ButtonsContainer = styled.div` -position: absolute; -right: 12px; -bottom: 12px; -`; - -export const Button = styled.button` -`; - -export const CompletedButton = styled(Button)` -`; - -export const RemoveButton = styled(Button)` -`; - -export const FormContainer = styled.div` -`; - -export const NewTodoInput = styled.input` -`; - -export const NewTodoButton = styled.button` -cursor: pointer; -`; - -export const AppContainer = styled.div` -margin: 0; -font-family: Arial, Helvetica, sans-serif; -color: #222222; -`; \ No newline at end of file +export const ReportItemContainer = styled.div``; diff --git a/src/card/CardThunks.ts b/src/card/CardThunks.ts index d8231e4a8..68d9f827e 100644 --- a/src/card/CardThunks.ts +++ b/src/card/CardThunks.ts @@ -1,213 +1,204 @@ import { - updateReportTitle, - updateReportQuery, - updateSelection, - updateReportSize, - updateReportRefreshRate, - updateCypherParameters, - updateFields, - updateReportType, - updateReportSetting, - toggleCardSettings, - clearSelection, - updateAllSelections, - updateReportDatabase -} from "./CardActions"; -import { createNotificationThunk } from "../page/PageThunks"; -import { DEFAULT_NODE_LABELS } from "../config/ReportConfig"; -import { getReportTypes } from "../extensions/ExtensionUtils"; + updateReportTitle, + updateReportQuery, + updateSelection, + updateReportSize, + updateReportRefreshRate, + updateCypherParameters, + updateFields, + updateReportType, + updateReportSetting, + toggleCardSettings, + clearSelection, + updateAllSelections, + updateReportDatabase, +} from './CardActions'; +import { createNotificationThunk } from '../page/PageThunks'; +import { DEFAULT_NODE_LABELS } from '../config/ReportConfig'; +import { getReportTypes } from '../extensions/ExtensionUtils'; import isEqual from 'lodash.isequal'; -import { SELECTION_TYPES } from "../config/CardConfig"; +import { SELECTION_TYPES } from '../config/CardConfig'; export const updateReportTitleThunk = (index, title) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - dispatch(updateReportTitle(pagenumber, index, title)) - } catch (e) { - dispatch(createNotificationThunk("Cannot update report title", e)); - } -} + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + dispatch(updateReportTitle(pagenumber, index, title)); + } catch (e) { + dispatch(createNotificationThunk('Cannot update report title', e)); + } +}; /* Thunk used to update the database used from a report - */ +*/ export const updateReportDatabaseThunk = (index, database) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - dispatch(updateReportDatabase(pagenumber, index, database)) - } catch (e) { - dispatch(createNotificationThunk("Cannot update report database", e)); - } -} + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + dispatch(updateReportDatabase(pagenumber, index, database)); + } catch (e) { + dispatch(createNotificationThunk('Cannot update report database', e)); + } +}; export const updateReportQueryThunk = (index, query) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - dispatch(updateReportQuery(pagenumber, index, query)) - } catch (e) { - dispatch(createNotificationThunk("Cannot update query", e)); - } -} - + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + dispatch(updateReportQuery(pagenumber, index, query)); + } catch (e) { + dispatch(createNotificationThunk('Cannot update query', e)); + } +}; + +// TODO: make refresh rate an advanced setting export const updateReportRefreshRateThunk = (index, rate) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - dispatch(updateReportRefreshRate(pagenumber, index, rate)) - } catch (e) { - dispatch(createNotificationThunk("Cannot update refresh rate", e)); - } -} + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + dispatch(updateReportRefreshRate(pagenumber, index, rate)); + } catch (e) { + dispatch(createNotificationThunk('Cannot update refresh rate', e)); + } +}; export const updateCypherParametersThunk = (index, parameters) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - dispatch(updateCypherParameters(pagenumber, index, parameters)) - } catch (e) { - dispatch(createNotificationThunk("Cannot update cypher parameters rate", e)); - } -} + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + dispatch(updateCypherParameters(pagenumber, index, parameters)); + } catch (e) { + dispatch(createNotificationThunk('Cannot update cypher parameters rate', e)); + } +}; export const updateReportTypeThunk = (index, type) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - - dispatch(updateReportType(pagenumber, index, type)); - dispatch(updateFields(pagenumber, index, [])); - dispatch(clearSelection(pagenumber, index)); - - } catch (e) { - dispatch(createNotificationThunk("Cannot update report type", e)); - } -} + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + + dispatch(updateReportType(pagenumber, index, type)); + dispatch(updateFields(pagenumber, index, [])); + dispatch(clearSelection(pagenumber, index)); + } catch (e) { + dispatch(createNotificationThunk('Cannot update report type', e)); + } +}; export const updateFieldsThunk = (index, fields) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - const extensions = state.dashboard.extensions; - const oldReport = state.dashboard.pages[pagenumber].reports[index]; - if(!oldReport){ - return; - } - const oldFields = oldReport.fields; - const reportType =oldReport.type; - const oldSelection = oldReport.selection; - const reportTypes = getReportTypes(extensions); - const selectableFields = reportTypes[reportType].selection; // The dictionary of selectable fields as defined in the config. - const autoAssignSelectedProperties = reportTypes[reportType].autoAssignSelectedProperties; - const selectables = (selectableFields) ? Object.keys(selectableFields) : []; - - - // If the new set of fields is not equal to the current set of fields, we ned to update the field selection. - if (!isEqual(oldFields, fields) || Object.keys(oldSelection).length === 0) { - selectables.forEach((selection, i) => { - if (fields.includes(oldSelection[selection])) { - // If the current selection is still present in the new set of fields, no need to reset. - // Also we ignore this on a node property selector. - /* continue */ - } else if (selectableFields[selection].optional) { - // If the fields change, always set optional selections to none. - if (selectableFields[selection].multiple) { - dispatch(updateSelection(pagenumber, index, selection, ["(none)"])); - } else { - dispatch(updateSelection(pagenumber, index, selection, "(none)")); - } - - } else { - if (fields.length > 0) { - // For multi selections, select the Nth item of the result fields as a single item array. - if (selectableFields[selection].multiple) { - // only update if the old selection no longer covers the new set of fields... - if(!oldSelection[selection] || !oldSelection[selection].every(v => fields.includes(v))){ - dispatch(updateSelection(pagenumber, index, selection, [fields[Math.min(i, fields.length - 1)]])); - } - - } else if (selectableFields[selection].type == SELECTION_TYPES.NODE_PROPERTIES) { - // For node property selections, select the most obvious properties of the node to display. - const selection = {}; - fields.forEach(nodeLabelAndProperties => { - const label = nodeLabelAndProperties[0]; - const properties = nodeLabelAndProperties.slice(1); - var selectedProp = oldSelection[label] ? oldSelection[label] : undefined; - if(autoAssignSelectedProperties){ - DEFAULT_NODE_LABELS.forEach(prop => { - if(properties.indexOf(prop) !== -1){ - if(selectedProp == undefined){ - selectedProp = prop; - } - } - }); - selection[label] = selectedProp ? selectedProp : "(label)"; - }else{ - selection[label] = selectedProp ? selectedProp : "(no label)"; - } - - }); - dispatch(updateAllSelections(pagenumber, index, selection)); - } else { - // Else, default the selection to the Nth item of the result set fields. - dispatch(updateSelection(pagenumber, index, selection, fields[Math.min(i, fields.length - 1)])); - } - + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + const { extensions } = state.dashboard; + const oldReport = state.dashboard.pages[pagenumber].reports[index]; + if (!oldReport) { + return; + } + const oldFields = oldReport.fields; + const reportType = oldReport.type; + const oldSelection = oldReport.selection; + const reportTypes = getReportTypes(extensions); + const selectableFields = reportTypes[reportType].selection; // The dictionary of selectable fields as defined in the config. + const { autoAssignSelectedProperties } = reportTypes[reportType]; + const selectables = selectableFields ? Object.keys(selectableFields) : []; + + // If the new set of fields is not equal to the current set of fields, we ned to update the field selection. + if (!isEqual(oldFields, fields) || Object.keys(oldSelection).length === 0) { + selectables.forEach((selection, i) => { + if (fields.includes(oldSelection[selection])) { + // If the current selection is still present in the new set of fields, no need to reset. + // Also we ignore this on a node property selector. + /* continue */ + } else if (selectableFields[selection].optional) { + // If the fields change, always set optional selections to none. + if (selectableFields[selection].multiple) { + dispatch(updateSelection(pagenumber, index, selection, ['(none)'])); + } else { + dispatch(updateSelection(pagenumber, index, selection, '(none)')); + } + } else if (fields.length > 0) { + // For multi selections, select the Nth item of the result fields as a single item array. + if (selectableFields[selection].multiple) { + // only update if the old selection no longer covers the new set of fields... + if (!oldSelection[selection] || !oldSelection[selection].every((v) => fields.includes(v))) { + dispatch(updateSelection(pagenumber, index, selection, [fields[Math.min(i, fields.length - 1)]])); + } + } else if (selectableFields[selection].type == SELECTION_TYPES.NODE_PROPERTIES) { + // For node property selections, select the most obvious properties of the node to display. + const selection = {}; + fields.forEach((nodeLabelAndProperties) => { + const label = nodeLabelAndProperties[0]; + const properties = nodeLabelAndProperties.slice(1); + let selectedProp = oldSelection[label] ? oldSelection[label] : undefined; + if (autoAssignSelectedProperties) { + DEFAULT_NODE_LABELS.forEach((prop) => { + if (properties.indexOf(prop) !== -1) { + if (selectedProp == undefined) { + selectedProp = prop; } - } + } + }); + selection[label] = selectedProp ? selectedProp : '(label)'; + } else { + selection[label] = selectedProp ? selectedProp : '(no label)'; + } }); - // Set the new set of fields for the report so that we may select them. - dispatch(updateFields(pagenumber, index, fields)) + dispatch(updateAllSelections(pagenumber, index, selection)); + } else { + // Else, default the selection to the Nth item of the result set fields. + dispatch(updateSelection(pagenumber, index, selection, fields[Math.min(i, fields.length - 1)])); + } } - } catch (e) { - dispatch(createNotificationThunk("Cannot update report fields", e)); + }); + // Set the new set of fields for the report so that we may select them. + dispatch(updateFields(pagenumber, index, fields)); } -} + } catch (e) { + dispatch(createNotificationThunk('Cannot update report fields', e)); + } +}; export const updateSelectionThunk = (index, selectable, field) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - dispatch(updateSelection(pagenumber, index, selectable, field)) - } catch (e) { - dispatch(createNotificationThunk("Cannot update report selection", e)); - } -} + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + dispatch(updateSelection(pagenumber, index, selectable, field)); + } catch (e) { + dispatch(createNotificationThunk('Cannot update report selection', e)); + } +}; export const toggleCardSettingsThunk = (index, open) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const pagenumber = state.dashboard.settings.pagenumber; - dispatch(toggleCardSettings(pagenumber, index, open)) - } catch (e) { - dispatch(createNotificationThunk("Cannot open card settings", e)); - } -} + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + dispatch(toggleCardSettings(pagenumber, index, open)); + } catch (e) { + dispatch(createNotificationThunk('Cannot open card settings', e)); + } +}; export const updateReportSettingThunk = (index, setting, value) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const extensions = state.dashboard.extensions; - const pagenumber = state.dashboard.settings.pagenumber; - - // If we disable optional selections (e.g. grouping), we reset these selections to their none value. - if (setting == "showOptionalSelections" && value == false) { - - const reportType = state.dashboard.pages[pagenumber].reports[index].type; - const reportTypes = getReportTypes(extensions); - const selectableFields = reportTypes[reportType].selection; - const optionalSelectables = - (selectableFields) ? Object.keys(selectableFields).filter((key) => selectableFields[key].optional) : []; - optionalSelectables.forEach((selection) => { - dispatch(updateSelection(pagenumber, index, selection, ("(none)"))); - }); - } - dispatch(updateReportSetting(pagenumber, index, setting, value)) - } catch (e) { - dispatch(createNotificationThunk("Error when updating report settings", e)); + try { + const state = getState(); + const { extensions } = state.dashboard; + const { pagenumber } = state.dashboard.settings; + + // If we disable optional selections (e.g. grouping), we reset these selections to their none value. + if (setting == 'showOptionalSelections' && value == false) { + const reportType = state.dashboard.pages[pagenumber].reports[index].type; + const reportTypes = getReportTypes(extensions); + const selectableFields = reportTypes[reportType].selection; + const optionalSelectables = selectableFields + ? Object.keys(selectableFields).filter((key) => selectableFields[key].optional) + : []; + optionalSelectables.forEach((selection) => { + dispatch(updateSelection(pagenumber, index, selection, '(none)')); + }); } -} - - + dispatch(updateReportSetting(pagenumber, index, setting, value)); + } catch (e) { + dispatch(createNotificationThunk('Error when updating report settings', e)); + } +}; diff --git a/src/card/settings/CardSettings.tsx b/src/card/settings/CardSettings.tsx index d1d646015..d00f83a07 100644 --- a/src/card/settings/CardSettings.tsx +++ b/src/card/settings/CardSettings.tsx @@ -7,84 +7,96 @@ import { CardContent } from '@material-ui/core'; import { CARD_HEADER_HEIGHT } from '../../config/CardConfig'; const NeoCardSettings = ({ - settingsOpen, - query, - database, // Current database related to the report - databaseList, // List of databases the user can choose from ('system' is filtered out) - refreshRate, - width, - height, - type, - reportSettings, - reportSettingsOpen, - fields, - widthPx, - heightPx, - extensions, // A set of enabled extensions. - onQueryUpdate, - onRefreshRateUpdate, - onDatabaseChanged, // When the database related to a report is changed it must be stored in the report state - onRemovePressed, - onClonePressed, - onReportSettingUpdate, - onToggleCardSettings, - onTypeUpdate, - setActive, - onReportHelpButtonPressed, - onToggleReportSettings, - dashboardSettings, - expanded, - onToggleCardExpand, - onCreateNotification - }) => { + settingsOpen, + query, + database, // Current database related to the report + databaseList, // List of databases the user can choose from ('system' is filtered out) + refreshRate, + width, + height, + type, + reportSettings, + reportSettingsOpen, + fields, + heightPx, + extensions, // A set of enabled extensions. + onQueryUpdate, + onRefreshRateUpdate, + onDatabaseChanged, // When the database related to a report is changed it must be stored in the report state + onRemovePressed, + onClonePressed, + onReportSettingUpdate, + onToggleCardSettings, + onTypeUpdate, + setActive, + onReportHelpButtonPressed, + onToggleReportSettings, + dashboardSettings, + expanded, + onToggleCardExpand, +}) => { + const reportHeight = heightPx - CARD_HEADER_HEIGHT + 24; - const reportHeight = heightPx - CARD_HEADER_HEIGHT + 24; + const cardSettingsHeader = ( + { + setActive(reportSettings.autorun !== undefined ? reportSettings.autorun : true); + onToggleCardSettings(e); + }} + /> + ); - const cardSettingsHeader = { setActive(reportSettings.autorun !== undefined ? reportSettings.autorun : true); onToggleCardSettings(e) }} /> + // TODO - instead of hiding everything based on settingsopen, only hide the components that slow down render (cypher editor) + const cardSettingsContent = settingsOpen ? ( + + ) : ( + + ); - // TODO - instead of hiding everything based on settingsopen, only hide the components that slow down render (cypher editor) - const cardSettingsContent = (settingsOpen) ? : ; + const cardSettingsFooter = settingsOpen ? ( + + ) : ( +
+ ); - const cardSettingsFooter = (settingsOpen) ? :
; - - return ( -
- {cardSettingsHeader} - - {cardSettingsContent} - {cardSettingsFooter} - -
- ); + return ( +
+ {cardSettingsHeader} + + {cardSettingsContent} + {cardSettingsFooter} + +
+ ); }; -export default NeoCardSettings; \ No newline at end of file +export default NeoCardSettings; diff --git a/src/card/settings/CardSettingsContent.tsx b/src/card/settings/CardSettingsContent.tsx index 2a3f0bfbc..ee098bf1b 100644 --- a/src/card/settings/CardSettingsContent.tsx +++ b/src/card/settings/CardSettingsContent.tsx @@ -7,120 +7,141 @@ import NeoField from '../../component/field/Field'; import NeoCodeEditorComponent from '../../component/editor/CodeEditorComponent'; import { getReportTypes } from '../../extensions/ExtensionUtils'; - const NeoCardSettingsContent = ({ - query, - database, // Current report database - databaseList, // List of databases the user can choose from ('system' is filtered out) - reportSettings, - refreshRate, - type, - extensions, - onQueryUpdate, - onRefreshRateUpdate, - onReportSettingUpdate, - onTypeUpdate, - onDatabaseChanged // When the database related to a report is changed it must be stored in the report state + query, + database, // Current report database + databaseList, // List of databases the user can choose from ('system' is filtered out) + reportSettings, + refreshRate, + type, + extensions, + onQueryUpdate, + onRefreshRateUpdate, + onReportSettingUpdate, + onTypeUpdate, + onDatabaseChanged, // When the database related to a report is changed it must be stored in the report state }) => { + // Ensure that we only trigger a text update event after the user has stopped typing. + const [queryText, setQueryText] = React.useState(query); + const debouncedQueryUpdate = useCallback(debounce(onQueryUpdate, 250), []); - // Ensure that we only trigger a text update event after the user has stopped typing. - const [queryText, setQueryText] = React.useState(query); - const debouncedQueryUpdate = useCallback( - debounce(onQueryUpdate, 250), - [], - ); + const [refreshRateText, setRefreshRateText] = React.useState(refreshRate); + const debouncedRefreshRateUpdate = useCallback(debounce(onRefreshRateUpdate, 250), []); - const [refreshRateText, setRefreshRateText] = React.useState(refreshRate); - const debouncedRefreshRateUpdate = useCallback( - debounce(onRefreshRateUpdate, 250), - [], - ); + // State to manage the current database entry inside the form + const [databaseText, setDatabaseText] = React.useState(database); + const debouncedDatabaseUpdate = useCallback(debounce(onDatabaseChanged, 250), []); - // State to manage the current database entry inside the form - const [databaseText, setDatabaseText] = React.useState(database); - const debouncedDatabaseUpdate = useCallback( - debounce(onDatabaseChanged, 250), - [], - ); + useEffect(() => { + // Reset text to the dashboard state when the page gets reorganized. + if (query !== queryText) { + setQueryText(query); + } + }, [query]); - useEffect(() => { - // Reset text to the dashboard state when the page gets reorganized. - if (query !== queryText) { - setQueryText(query); - } - }, [query]) + useEffect(() => { + // Reset text to the dashboard state when the page gets reorganized. + if (refreshRate !== refreshRateText) { + setRefreshRateText(refreshRate !== undefined ? refreshRate : ''); + } + }, [refreshRate]); - useEffect(() => { - // Reset text to the dashboard state when the page gets reorganized. - if (refreshRate !== refreshRateText) { - setRefreshRateText((refreshRate !== undefined) ? refreshRate : ""); - } - }, [refreshRate]) + const reportTypes = getReportTypes(extensions); + const SettingsComponent = reportTypes[type] && reportTypes[type].settingsComponent; - const reportTypes = getReportTypes(extensions); - const SettingsComponent = reportTypes[type] && reportTypes[type].settingsComponent; + return ( + + onTypeUpdate(value)} + choices={Object.keys(reportTypes).map((option) => ( + + {reportTypes[option].label} + + ))} + /> - return - onTypeUpdate(value)} - choices={Object.keys(reportTypes).map((option) => ( - - {reportTypes[option].label} - - ))} /> + {reportTypes[type] && reportTypes[type].disableRefreshRate == undefined ? ( + ( + + {database} + + ))} + onChange={(value) => { + setDatabaseText(value); + debouncedDatabaseUpdate(value); + }} + /> + ) : ( + <> + )} - {reportTypes[type] && reportTypes[type]["disableRefreshRate"] == undefined ? ( - - {database} - - ))} - onChange={(value) => { - setDatabaseText(value); - debouncedDatabaseUpdate(value) - }} /> : <>} + {reportTypes[type] && reportTypes[type].disableRefreshRate == undefined ? ( + { + setRefreshRateText(value); + debouncedRefreshRateUpdate(value); + }} + /> + ) : ( + <> + )} - {reportTypes[type] && reportTypes[type]["disableRefreshRate"] == undefined ? +
+ {/* Allow for overriding the code box with a custom component */} + {reportTypes[type] && reportTypes[type].settingsComponent ? ( + + ) : ( +
+ { - setRefreshRateText(value); - debouncedRefreshRateUpdate(value); - }} /> : <>} - -

- {/* Allow for overriding the code box with a custom component */} - {reportTypes[type] && reportTypes[type]["settingsComponent"] ? - : -
- { - debouncedQueryUpdate(value); - setQueryText(value); - }} - placeholder={"Enter Cypher here..."} - /> -

{reportTypes[type] && reportTypes[type].helperText}

-
} - + debouncedQueryUpdate(value); + setQueryText(value); + }} + placeholder={'Enter Cypher here...'} + /> +

+ {reportTypes[type] && reportTypes[type].helperText} +

+
+ )}
+ ); }; -export default NeoCardSettingsContent; \ No newline at end of file +export default NeoCardSettingsContent; diff --git a/src/card/settings/CardSettingsFooter.tsx b/src/card/settings/CardSettingsFooter.tsx index 0e2bd35e9..50529400c 100644 --- a/src/card/settings/CardSettingsFooter.tsx +++ b/src/card/settings/CardSettingsFooter.tsx @@ -3,133 +3,163 @@ import debounce from 'lodash/debounce'; import { useCallback } from 'react'; import { FormControlLabel, FormGroup, IconButton, Switch, Tooltip } from '@material-ui/core'; import NeoSetting from '../../component/field/Setting'; -import { NeoCustomReportStyleModal, RULE_BASED_REPORT_CUSTOMIZATIONS } from '../../extensions/styling/StyleRuleCreationModal'; +import { + NeoCustomReportStyleModal, + RULE_BASED_REPORT_CUSTOMIZATIONS, +} from '../../extensions/styling/StyleRuleCreationModal'; import TuneIcon from '@material-ui/icons/Tune'; import { getReportTypes } from '../../extensions/ExtensionUtils'; -const update = (state, mutations) => - Object.assign({}, state, mutations) - - -const NeoCardSettingsFooter = ({ type, fields, reportSettings, reportSettingsOpen, extensions, - onToggleReportSettings, onCreateNotification, onReportSettingUpdate }) => { - - const [reportSettingsText, setReportSettingsText] = React.useState(reportSettings); - - // Variables related to customizing report settings - const [customReportStyleModalOpen, setCustomReportStyleModalOpen] = React.useState(false); - - const settingToCustomize = "styleRules"; - - const debouncedReportSettingUpdate = useCallback( - debounce(onReportSettingUpdate, 250), - [], - ); - - const updateSpecificReportSetting = (field: string, value: any) => { - const entry = {} - entry[field] = value; - setReportSettingsText(update(reportSettingsText, entry)); - debouncedReportSettingUpdate(field, value); - }; - - const reportTypes = getReportTypes(extensions); - - // Contains, for a certain type of chart, its disabling logic - const disabledDependency = reportTypes[type] && reportTypes[type]["disabledDependency"]; - - /* This method manages the disabling logic for all the settings inside the footer. - * The logic is based on the disabledDependency param inside the chart's configuration */ - const getDisabled = (field:string) =>{ - // By default an option is enabled - let isDisabled = false - let dependencyLogic = disabledDependency[field] - if(dependencyLogic != undefined) { - // Getting the current parameter defined in the settings of the report - // (if undefined, the param will be treated as undefined (boolean false) - isDisabled = reportSettingsText[dependencyLogic.dependsOn] - if (!dependencyLogic.operator){ - isDisabled = !isDisabled - } - } - return isDisabled - } - useEffect(() => { - // Reset text to the dashboard state when the page gets reorganized. - setReportSettingsText(reportSettings); - }, [JSON.stringify(reportSettings)]) - - const settings = reportTypes[type] ? reportTypes[type]["settings"] : {}; - - // If there are no advanced settings, render nothing. - if (Object.keys(settings).length == 0) { - return
+const update = (state, mutations) => Object.assign({}, state, mutations); + +const NeoCardSettingsFooter = ({ + type, + fields, + reportSettings, + reportSettingsOpen, + extensions, + onToggleReportSettings, + onReportSettingUpdate, +}) => { + const [reportSettingsText, setReportSettingsText] = React.useState(reportSettings); + + // Variables related to customizing report settings + const [customReportStyleModalOpen, setCustomReportStyleModalOpen] = React.useState(false); + + const settingToCustomize = 'styleRules'; + + const debouncedReportSettingUpdate = useCallback(debounce(onReportSettingUpdate, 250), []); + + const updateSpecificReportSetting = (field: string, value: any) => { + const entry = {}; + entry[field] = value; + setReportSettingsText(update(reportSettingsText, entry)); + debouncedReportSettingUpdate(field, value); + }; + + const reportTypes = getReportTypes(extensions); + + // Contains, for a certain type of chart, its disabling logic + const disabledDependency = reportTypes[type] && reportTypes[type].disabledDependency; + + /* This method manages the disabling logic for all the settings inside the footer. + * The logic is based on the disabledDependency param inside the chart's configuration */ + const getDisabled = (field: string) => { + // By default an option is enabled + let isDisabled = false; + let dependencyLogic = disabledDependency[field]; + if (dependencyLogic != undefined) { + // Getting the current parameter defined in the settings of the report + // (if undefined, the param will be treated as undefined (boolean false) + isDisabled = reportSettingsText[dependencyLogic.dependsOn]; + if (!dependencyLogic.operator) { + isDisabled = !isDisabled; + } } - - // Else, build the advanced settings view. - const advancedReportSettings =
- {Object.keys(settings).map(setting =>{ - let isDisabled = false - // Adding disabling logic to specific entries but only if the logic is defined inside the configuration - if (disabledDependency != undefined) { - isDisabled = getDisabled(setting) - } - - return updateSpecificReportSetting(setting, e)} - /> + return isDisabled; + }; + useEffect(() => { + // Reset text to the dashboard state when the page gets reorganized. + setReportSettingsText(reportSettings); + }, [JSON.stringify(reportSettings)]); + + const settings = reportTypes[type] ? reportTypes[type].settings : {}; + + // If there are no advanced settings, render nothing. + if (Object.keys(settings).length == 0) { + return
; + } + + // Else, build the advanced settings view. + const advancedReportSettings = ( +
+ {Object.keys(settings).map((setting) => { + let isDisabled = false; + // Adding disabling logic to specific entries but only if the logic is defined inside the configuration + if (disabledDependency != undefined) { + isDisabled = getDisabled(setting); } - )} -
- // TODO - Make the extensions more pluggable and dynamic, instead of hardcoded here. - return
- {extensions['styling'] ? : <>} - - - - - {RULE_BASED_REPORT_CUSTOMIZATIONS[type] && extensions['styling'] ? : <>} - - - - - -
- - } - labelPlacement="end" - label={
Advanced settings
} /> -
-
- - { - setCustomReportStyleModalOpen(true); // Open the modal. - }}> - - - -
- {reportSettingsOpen ? advancedReportSettings :
} -
+ return ( + updateSpecificReportSetting(setting, e)} + /> + ); + })} +
+ ); + + // TODO - Make the extensions more pluggable and dynamic, instead of hardcoded here. + return ( +
+ {extensions.styling ? ( + + ) : ( + <> + )} + + + + + {RULE_BASED_REPORT_CUSTOMIZATIONS[type] && extensions.styling ? ( + + ) : ( + <> + )} + + + + + +
+ + } + labelPlacement='end' + label={
Advanced settings
} + /> +
+
+ + { + setCustomReportStyleModalOpen(true); // Open the modal. + }} + > + + + +
+ {reportSettingsOpen ? advancedReportSettings :
} +
+ ); }; -export default NeoCardSettingsFooter; \ No newline at end of file +export default NeoCardSettingsFooter; diff --git a/src/card/settings/CardSettingsHeader.tsx b/src/card/settings/CardSettingsHeader.tsx index 21c18c9b7..dacdcf20a 100644 --- a/src/card/settings/CardSettingsHeader.tsx +++ b/src/card/settings/CardSettingsHeader.tsx @@ -10,51 +10,87 @@ import { FullscreenExit } from '@material-ui/icons'; import DragIndicatorIcon from '@material-ui/icons/DragIndicator'; import { Tooltip } from '@material-ui/core'; -const NeoCardSettingsHeader = ({ onRemovePressed, onToggleCardSettings, onToggleCardExpand, - expanded, fullscreenEnabled, onReportHelpButtonPressed, onClonePressed }) => { - const maximizeButton = - +const NeoCardSettingsHeader = ({ + onRemovePressed, + onToggleCardSettings, + onToggleCardExpand, + expanded, + fullscreenEnabled, + onReportHelpButtonPressed, + onClonePressed, +}) => { + const maximizeButton = ( + + + ); - const unMaximizeButton = - + const unMaximizeButton = ( + + + ); - return ( - - - - - - - - - - - - - - - - - - -
} - action={<> - {fullscreenEnabled ? (expanded ? unMaximizeButton : maximizeButton) : <>} - - { e.preventDefault(); onToggleCardSettings() }}> - - } - title="" - subheader="" /> - ); + return ( + + + + + + + + + + + + + + + + + +
+ } + action={ + <> + {fullscreenEnabled ? expanded ? unMaximizeButton : maximizeButton : <>} + + { + e.preventDefault(); + onToggleCardSettings(); + }} + > + + + + + } + title='' + subheader='' + /> + ); }; -export default NeoCardSettingsHeader; \ No newline at end of file +export default NeoCardSettingsHeader; diff --git a/src/card/settings/custom/CardSettingsContentPropertySelect.tsx b/src/card/settings/custom/CardSettingsContentPropertySelect.tsx index 4c6e3e591..1843511de 100644 --- a/src/card/settings/custom/CardSettingsContentPropertySelect.tsx +++ b/src/card/settings/custom/CardSettingsContentPropertySelect.tsx @@ -1,217 +1,303 @@ +// TODO: this file (in a way) belongs to chart/parameter/ParameterSelectionChart. It would make sense to move it there import React, { useCallback, useContext, useEffect } from 'react'; import { RUN_QUERY_DELAY_MS } from '../../../config/ReportConfig'; import { QueryStatus, runCypherQuery } from '../../../report/ReportQueryRunner'; -import { Neo4jContext, Neo4jContextState } from "use-neo4j/dist/neo4j.context"; +import { Neo4jContext, Neo4jContextState } from 'use-neo4j/dist/neo4j.context'; import { debounce, MenuItem, TextField } from '@material-ui/core'; import { Autocomplete } from '@material-ui/lab'; import NeoField from '../../../component/field/Field'; import { getReportTypes } from '../../../extensions/ExtensionUtils'; -const NeoCardSettingsContentPropertySelect = ({ type, database, settings, extensions, onReportSettingUpdate, onQueryUpdate }) => { - const { driver } = useContext(Neo4jContext); - if (!driver) throw new Error('`driver` not defined. Have you added it into your app as ?') - - const debouncedRunCypherQuery = useCallback( - debounce(runCypherQuery, RUN_QUERY_DELAY_MS), - [], +const NeoCardSettingsContentPropertySelect = ({ + type, + database, + settings, + extensions, + onReportSettingUpdate, + onQueryUpdate, +}) => { + const { driver } = useContext(Neo4jContext); + if (!driver) { + throw new Error( + '`driver` not defined. Have you added it into your app as ?' ); + } - const manualPropertyNameSpecification = settings['manualPropertyNameSpecification']; - const [labelInputText, setLabelInputText] = React.useState(settings['entityType']); - const [labelRecords, setLabelRecords] = React.useState([]); - const [propertyInputText, setPropertyInputText] = React.useState(settings['propertyType']); - const [propertyRecords, setPropertyRecords] = React.useState([]); - var parameterName = settings['parameterName']; + const debouncedRunCypherQuery = useCallback(debounce(runCypherQuery, RUN_QUERY_DELAY_MS), []); - // When certain settings are updated, a re-generated search query is needed. - useEffect(() => { - updateReportQuery(settings.entityType, settings.propertyType); - }, [settings.suggestionLimit, settings.deduplicateSuggestions, settings.searchType, settings.caseSensitive]) + const { manualPropertyNameSpecification } = settings; + const [labelInputText, setLabelInputText] = React.useState(settings.entityType); + const [labelRecords, setLabelRecords] = React.useState([]); + const [propertyInputText, setPropertyInputText] = React.useState(settings.propertyType); + const [propertyRecords, setPropertyRecords] = React.useState([]); + let { parameterName } = settings; - if (settings["type"] == undefined) { - onReportSettingUpdate("type", "Node Property"); - } - if (!parameterName && settings['entityType'] && settings['propertyType']) { - const id = settings['id'] ? settings['id'] : ""; - onReportSettingUpdate("parameterName", "neodash_" + (settings['entityType'] + "_" + settings['propertyType'] + (id == "" || id.startsWith("_") ? id : "_" + id)).toLowerCase().replaceAll(" ", "_").replaceAll("-", "_")); - } - // Define query callback to allow reports to get extra data on interactions. - const queryCallback = useCallback( - (query, parameters, setRecords) => { - debouncedRunCypherQuery(driver, database, query, parameters, 10, - (status) => { status == QueryStatus.NO_DATA ? setRecords([]) : null }, - (result => setRecords(result)), - () => { return }); - }, - [], + const cleanParameter = (parameter: string) => parameter.replaceAll(' ', '_').replaceAll('-', '_').toLowerCase(); + const formatParameterId = (id: string | undefined | null) => { + const cleanedId = id || ''; + const formattedId = cleanedId == '' || cleanedId.startsWith('_') ? cleanedId : `_${cleanedId}`; + return formattedId; + }; + + // When certain settings are updated, a re-generated search query is needed. + useEffect(() => { + updateReportQuery(settings.entityType, settings.propertyType); + }, [settings.suggestionLimit, settings.deduplicateSuggestions, settings.searchType, settings.caseSensitive]); + + if (settings.type == undefined) { + onReportSettingUpdate('type', 'Node Property'); + } + if (!parameterName && settings.entityType && settings.propertyType) { + const entityAndPropertyType = `neodash_${settings.entityType}_${settings.propertyType}`; + const formattedParameterId = formatParameterId(settings.id); + const parameterName = cleanParameter(entityAndPropertyType + formattedParameterId); + + onReportSettingUpdate('parameterName', parameterName); + } + // Define query callback to allow reports to get extra data on interactions. + const queryCallback = useCallback((query, parameters, setRecords) => { + debouncedRunCypherQuery( + driver, + database, + query, + parameters, + 10, + (status) => { + status == QueryStatus.NO_DATA ? setRecords([]) : null; + }, + (result) => setRecords(result), + () => {} ); + }, []); - function handleParameterTypeUpdate(newValue) { - onReportSettingUpdate('entityType', undefined); - onReportSettingUpdate('propertyType', undefined); - onReportSettingUpdate('id', undefined); - onReportSettingUpdate('parameterName', undefined); - onReportSettingUpdate("type", newValue); - } + function handleParameterTypeUpdate(newValue) { + onReportSettingUpdate('entityType', undefined); + onReportSettingUpdate('propertyType', undefined); + onReportSettingUpdate('id', undefined); + onReportSettingUpdate('parameterName', undefined); + onReportSettingUpdate('type', newValue); + } - function handleNodeLabelSelectionUpdate(newValue) { - setPropertyInputText(""); - onReportSettingUpdate('entityType', newValue); - onReportSettingUpdate('propertyType', undefined); - onReportSettingUpdate('parameterName', undefined); - } + function handleNodeLabelSelectionUpdate(newValue) { + setPropertyInputText(''); + onReportSettingUpdate('entityType', newValue); + onReportSettingUpdate('propertyType', undefined); + onReportSettingUpdate('parameterName', undefined); + } - function handleFreeTextNameSelectionUpdate(newValue) { - if (newValue) { - const new_parameter_name = ("neodash_" + newValue).toLowerCase().replaceAll(" ", "_").replaceAll("-", "_"); - handleReportQueryUpdate(new_parameter_name, newValue, undefined); - } else { - onReportSettingUpdate('parameterName', undefined); - } + function handleFreeTextNameSelectionUpdate(newValue) { + if (newValue) { + const new_parameter_name = cleanParameter(`neodash_${newValue}`); + handleReportQueryUpdate(new_parameter_name, newValue, undefined); + } else { + onReportSettingUpdate('parameterName', undefined); } + } - function handlePropertyNameSelectionUpdate(newValue) { - onReportSettingUpdate('propertyType', newValue); - if (newValue && settings['entityType']) { - const id = settings['id'] ? settings['id'] : ""; - const new_parameter_name = "neodash_" + (settings['entityType'] + "_" + newValue + (id == "" || id.startsWith("_") ? id : "_" + id)).toLowerCase().replaceAll(" ", "_").replaceAll("-", "_"); - handleReportQueryUpdate(new_parameter_name, settings['entityType'], newValue); - } else { - onReportSettingUpdate('parameterName', undefined); - } - } + function handlePropertyNameSelectionUpdate(newValue) { + onReportSettingUpdate('propertyType', newValue); + if (newValue && settings.entityType) { + const newParameterName = `neodash_${settings.entityType}_${newValue}`; + const formattedParameterId = formatParameterId(settings.id); + const cleanedParameter = cleanParameter(newParameterName + formattedParameterId); - function handleIdSelectionUpdate(value) { - const newValue = value ? value : ""; - onReportSettingUpdate('id', "" + newValue); - if (settings['propertyType'] && settings['entityType']) { - const id = value ? "_" + value : ""; - const new_parameter_name = "neodash_" + (settings['entityType'] + "_" + settings['propertyType'] + (id == "" || id.startsWith("_") ? id : "_" + id)).toLowerCase().replaceAll(" ", "_").replaceAll("-", "_"); - handleReportQueryUpdate(new_parameter_name, settings['entityType'], settings['propertyType']); - } + handleReportQueryUpdate(cleanedParameter, settings.entityType, newValue); + } else { + onReportSettingUpdate('parameterName', undefined); } + } - function handleReportQueryUpdate(new_parameter_name, entityType, propertyType) { - onReportSettingUpdate('parameterName', new_parameter_name); - updateReportQuery(entityType, propertyType); + function handleIdSelectionUpdate(value) { + const newValue = value ? value : ''; + onReportSettingUpdate('id', `${newValue}`); + if (settings.propertyType && settings.entityType) { + const newParameterName = `neodash_${settings.entityType}_${settings.propertyType}`; + const formattedParameterId = formatParameterId(`${newValue}`); + const cleanedParameter = cleanParameter(newParameterName + formattedParameterId); + handleReportQueryUpdate(cleanedParameter, settings.entityType, settings.propertyType); } + } - function updateReportQuery(entityType, propertyType) { - const limit = settings.suggestionLimit ? settings.suggestionLimit : 5; - const deduplicate = settings.deduplicateSuggestions !== undefined ? settings.deduplicateSuggestions : true; - const searchType = settings.searchType ? settings.searchType : "CONTAINS"; - const caseSensitive = settings.caseSensitive !== undefined ? settings.caseSensitive : false; - if (settings['type'] == "Node Property") { - const newQuery = "MATCH (n:`" + entityType + "`) \n"+ - "WHERE "+(caseSensitive ? "" : "toLower")+"(toString(n.`" + propertyType + "`)) "+searchType+ - " "+(caseSensitive ? "" : "toLower")+"($input) \n"+ - "RETURN " + (deduplicate ? "DISTINCT" : "") + " n.`" + propertyType + "` as value "+ - "ORDER BY size(toString(value)) ASC LIMIT " + limit; - onQueryUpdate(newQuery); - } else if (settings['type'] == "Relationship Property") { - const newQuery = "MATCH ()-[n:`" + entityType + "`]->() \n"+ - "WHERE "+(caseSensitive ? "" : "toLower")+"(toString(n.`" + propertyType + "`)) "+searchType+ - " "+(caseSensitive ? "" : "toLower")+"($input) \n"+ - "RETURN " + (deduplicate ? "DISTINCT" : "") + " n.`" + propertyType + "` as value "+ - "ORDER BY size(toString(value)) ASC LIMIT " + limit; - onQueryUpdate(newQuery); - } else { - const newQuery = "RETURN true"; - onQueryUpdate(newQuery); - } + function handleReportQueryUpdate(new_parameter_name, entityType, propertyType) { + onReportSettingUpdate('parameterName', new_parameter_name); + updateReportQuery(entityType, propertyType); + } + + function updateReportQuery(entityType, propertyType) { + const limit = settings.suggestionLimit ? settings.suggestionLimit : 5; + const deduplicate = settings.deduplicateSuggestions !== undefined ? settings.deduplicateSuggestions : true; + const searchType = settings.searchType ? settings.searchType : 'CONTAINS'; + const caseSensitive = settings.caseSensitive !== undefined ? settings.caseSensitive : false; + if (settings.type == 'Node Property') { + const newQuery = + `MATCH (n:\`${entityType}\`) \n` + + `WHERE ${caseSensitive ? '' : 'toLower'}(toString(n.\`${propertyType}\`)) ${searchType} ${ + caseSensitive ? '' : 'toLower' + }($input) \n` + + `RETURN ${deduplicate ? 'DISTINCT' : ''} n.\`${propertyType}\` as value ` + + `ORDER BY size(toString(value)) ASC LIMIT ${limit}`; + onQueryUpdate(newQuery); + } else if (settings.type == 'Relationship Property') { + const newQuery = + `MATCH ()-[n:\`${entityType}\`]->() \n` + + `WHERE ${caseSensitive ? '' : 'toLower'}(toString(n.\`${propertyType}\`)) ${searchType} ${ + caseSensitive ? '' : 'toLower' + }($input) \n` + + `RETURN ${deduplicate ? 'DISTINCT' : ''} n.\`${propertyType}\` as value ` + + `ORDER BY size(toString(value)) ASC LIMIT ${limit}`; + onQueryUpdate(newQuery); + } else { + const newQuery = 'RETURN true'; + onQueryUpdate(newQuery); } + } - const parameterSelectTypes = ["Node Property", "Relationship Property", "Free Text"] - const reportTypes = getReportTypes(extensions); - - return
-

- {reportTypes[type].helperText} -

- { - handleParameterTypeUpdate(e.target.value); + // TODO: since this component is only rendered for parameter select, this is technically not needed + const parameterSelectTypes = ['Node Property', 'Relationship Property', 'Free Text']; + const reportTypes = getReportTypes(extensions); + + return ( +
+

+ {reportTypes[type].helperText} +

+ { + handleParameterTypeUpdate(e.target.value); + }} + style={{ width: '25%' }} + label='Selection Type' + type='text' + style={{ width: 335, marginLeft: '5px', marginTop: '0px' }} + > + {parameterSelectTypes.map((option) => ( + + {option} + + ))} + + + {settings.type == 'Free Text' ? ( + { + setLabelInputText(value); + handleNodeLabelSelectionUpdate(value); + handleFreeTextNameSelectionUpdate(value); + }} + /> + ) : ( + <> + (r._fields ? r._fields[0] : '(no data)')) + } + getOptionLabel={(option) => (option ? option : '')} + style={{ width: 335, marginLeft: '5px', marginTop: '5px' }} + inputValue={labelInputText} + onInputChange={(event, value) => { + setLabelInputText(value); + if (manualPropertyNameSpecification) { + handleNodeLabelSelectionUpdate(value); + } else if (settings.type == 'Node Property') { + queryCallback( + 'CALL db.labels() YIELD label WITH label as nodeLabel WHERE toLower(nodeLabel) CONTAINS toLower($input) RETURN DISTINCT nodeLabel LIMIT 5', + { input: value }, + setLabelRecords + ); + } else { + queryCallback( + 'CALL db.relationshipTypes() YIELD relationshipType WITH relationshipType as relType WHERE toLower(relType) CONTAINS toLower($input) RETURN DISTINCT relType LIMIT 5', + { input: value }, + setLabelRecords + ); + } }} - style={{ width: "25%" }} label="Selection Type" - type="text" - style={{ width: 335, marginLeft: "5px", marginTop: "0px" }}> - {parameterSelectTypes.map((option) => ( - - {option} - - ))} - - - {settings.type == "Free Text" ? - handleNodeLabelSelectionUpdate(newValue)} + renderInput={(params) => ( + + )} + /> + {/* Draw the property name & id selectors only after a label/type has been selected. */} + {settings.entityType ? ( + <> + (r._fields ? r._fields[0] : '(no data)')) + } + getOptionLabel={(option) => (option ? option : '')} + style={{ display: 'inline-block', width: 185, marginLeft: '5px', marginTop: '5px' }} + inputValue={propertyInputText} + onInputChange={(event, value) => { + setPropertyInputText(value); + if (manualPropertyNameSpecification) { + handlePropertyNameSelectionUpdate(value); + } else { + queryCallback( + 'CALL db.propertyKeys() YIELD propertyKey as propertyName WITH propertyName WHERE toLower(propertyName) CONTAINS toLower($input) RETURN DISTINCT propertyName LIMIT 5', + { input: value }, + setPropertyRecords + ); + } + }} + value={settings.propertyType} + onChange={(event, newValue) => handlePropertyNameSelectionUpdate(newValue)} + renderInput={(params) => ( + + )} + /> + { - setLabelInputText(value); - handleNodeLabelSelectionUpdate(value); - handleFreeTextNameSelectionUpdate(value); + handleIdSelectionUpdate(value); }} - /> - : - <> - r["_fields"] ? r["_fields"][0] : "(no data)")} - getOptionLabel={(option) => option ? option : ""} - style={{ width: 335, marginLeft: "5px", marginTop: "5px" }} - inputValue={labelInputText} - onInputChange={(event, value) => { - setLabelInputText(value); - if (manualPropertyNameSpecification) { - handleNodeLabelSelectionUpdate(value); - } else { - if (settings["type"] == "Node Property") { - queryCallback("CALL db.labels() YIELD label WITH label as nodeLabel WHERE toLower(nodeLabel) CONTAINS toLower($input) RETURN DISTINCT nodeLabel LIMIT 5", { input: value }, setLabelRecords); - } else { - queryCallback("CALL db.relationshipTypes() YIELD relationshipType WITH relationshipType as relType WHERE toLower(relType) CONTAINS toLower($input) RETURN DISTINCT relType LIMIT 5", { input: value }, setLabelRecords); - } - } - }} - value={settings['entityType'] ? settings['entityType'] : undefined} - onChange={(event, newValue) => handleNodeLabelSelectionUpdate(newValue)} - renderInput={(params) => } - /> - {/* Draw the property name & id selectors only after a label/type has been selected. */} - {settings['entityType'] ? - <> - r["_fields"] ? r["_fields"][0] : "(no data)")} - getOptionLabel={(option) => option ? option : ""} - style={{ display: "inline-block", width: 185, marginLeft: "5px", marginTop: "5px" }} - inputValue={propertyInputText} - onInputChange={(event, value) => { - setPropertyInputText(value); - if (manualPropertyNameSpecification) { - handlePropertyNameSelectionUpdate(value); - } else { - queryCallback("CALL db.propertyKeys() YIELD propertyKey as propertyName WITH propertyName WHERE toLower(propertyName) CONTAINS toLower($input) RETURN DISTINCT propertyName LIMIT 5", { input: value }, setPropertyRecords); - } - }} - value={settings['propertyType']} - onChange={(event, newValue) => handlePropertyNameSelectionUpdate(newValue)} - renderInput={(params) => } - /> - { - handleIdSelectionUpdate(value); - }} /> - : <>} - } - {parameterName ?

Use ${parameterName} in a query to use the parameter.

: <>} -
; -} + /> + + ) : ( + <> + )} + + )} + {parameterName ? ( +

+ Use ${parameterName} in a query to use the parameter. +

+ ) : ( + <> + )} +
+ ); +}; export default NeoCardSettingsContentPropertySelect; diff --git a/src/card/tests/README.md b/src/card/tests/README.md deleted file mode 100644 index 086a66c7a..000000000 --- a/src/card/tests/README.md +++ /dev/null @@ -1,2 +0,0 @@ -## Tests -This folder is intended for tests to be added in the near future. \ No newline at end of file diff --git a/src/card/tests/reducers.test.tsx b/src/card/tests/reducers.test.tsx deleted file mode 100644 index 6a74394b9..000000000 --- a/src/card/tests/reducers.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// import { expect } from 'chai'; -// import { todos } from '../../page/logic/pageReducer'; -// import { describe } from 'mocha'; - -// /** -// * TODO - write tests... -// */ -// describe('Todos reducer test', () => { -// it('Adds a new todo when CREATE_TODO action is received', () => { -// const fakeTodo = { text: 'hello', isCompleted: false }; -// const fakeAction = { -// type: 'CREATE_TODO', -// payload: { -// todo: fakeTodo, -// }, -// } -// const originalState = { isLoading: false, data: [] }; -// const expected = { -// isLoading: false, -// data: [fakeTodo], -// } -// const actual = todos(originalState, fakeAction); -// expect(actual).to.deep.equal(expected); -// }); -// }); \ No newline at end of file diff --git a/src/card/tests/selectors.test.tsx b/src/card/tests/selectors.test.tsx deleted file mode 100644 index 69166e7a6..000000000 --- a/src/card/tests/selectors.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// import { expect } from 'chai'; -// import { describe } from 'mocha'; - -// import { getCompletedTodos } from '../../page/logic/pageSelectors'; - -// /** -// * TODO - write tests... -// */ -// describe('The getCompletedTodos selector', () => { -// it('Returns only completed todos', () => { -// const fakeTodos = [{ text: '1', isCompleted: true }, { text: '2', isCompleted: false }, { text: '3', isCompleted: false }] -// const expected = [{ text: '1', isCompleted: true }]; -// const actual = getCompletedTodos.resultFunc(fakeTodos); -// expect(actual).to.deep.equal(expected); -// }) -// }); diff --git a/src/card/tests/styled.test.tsx b/src/card/tests/styled.test.tsx deleted file mode 100644 index adc826ffd..000000000 --- a/src/card/tests/styled.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ - - -// import { expect } from 'chai'; -// import { getBorderStyleForDate } from '../logic/cardStyle'; -// import { describe } from 'mocha'; - -// /** -// * TODO - write tests... -// */ -// describe('getBorderStyleForDate', () => { -// it('Returns none when the date is less than five days ago', () => { -// const today = Date.now(); -// const recentDate = new Date(Date.now() - 8640000 * 3); -// const expected = 'none'; -// const actual = getBorderStyleForDate(recentDate, today); -// expect(actual).to.equal(expected); -// }); -// it('Returns border when the date is more than five days ago', () => { -// const today = Date.now(); -// const recentDate = new Date(Date.now() - 8640000 * 7); -// const expected = '5px solid red'; -// const actual = getBorderStyleForDate(recentDate, today); -// expect(actual).to.equal(expected); -// }); -// }); \ No newline at end of file diff --git a/src/card/tests/thunks.test.tsx b/src/card/tests/thunks.test.tsx deleted file mode 100644 index e33a71b82..000000000 --- a/src/card/tests/thunks.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// import 'node-fetch'; -// import fetchMock from 'fetch-mock'; -// import { expect } from 'chai'; -// import sinon from 'sinon'; -// import { loadTodos } from '../../page/logic/pageThunks'; -// import { describe } from 'mocha'; - -// /** -// * TODO - write tests... -// */ -// describe('The loadTodos thunk', () => { -// it('Dispatches the correct actions in the success scenario', async () => { -// const fakeDispatch = sinon.spy(); -// const fakeTodos = [{ text: '1' }, { text: '2' }]; -// fetchMock.get('http://localhost:8080/todos', fakeTodos); - -// const expectedFirstAction = { type: 'LOAD_TODOS_IN_PROGRESS' }; -// const expectedSecondAction = { type: "LOAD_TODOS_SUCCESS", payload: { todos: fakeTodos } } - -// // @ts-ignore -// await loadTodos()(fakeDispatch); - -// expect(fakeDispatch.getCall(0).args[0]).to.deep.equal(expectedFirstAction); -// expect(fakeDispatch.getCall(1).args[0]).to.deep.equal(expectedSecondAction); - -// fetchMock.reset(); -// }); -// }); \ No newline at end of file diff --git a/src/card/view/CardView.tsx b/src/card/view/CardView.tsx index 4ddfff2c9..f1c53ad04 100644 --- a/src/card/view/CardView.tsx +++ b/src/card/view/CardView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React from 'react'; import { ReportItemContainer } from '../CardStyle'; import NeoCardViewHeader from './CardViewHeader'; import NeoCardViewFooter from './CardViewFooter'; @@ -10,118 +10,201 @@ import PlayCircleFilledIcon from '@material-ui/icons/PlayCircleFilled'; import { CARD_FOOTER_HEIGHT, CARD_HEADER_HEIGHT } from '../../config/CardConfig'; import { downloadComponentAsImage } from '../../chart/ChartUtils'; import { getReportTypes } from '../../extensions/ExtensionUtils'; -import NeoCodeViewerComponent, { NoDrawableDataErrorMessage } from '../../component/editor/CodeViewerComponent'; - -const NeoCardView = ({ title, database, query, globalParameters, - widthPx, heightPx, fields, extensions, active, setActive, - type, selection, dashboardSettings, settings, settingsOpen, refreshRate, editable, - onGlobalParameterUpdate, onSelectionUpdate, onToggleCardSettings, onTitleUpdate, - onFieldsUpdate, expanded, onToggleCardExpand }) => { - - const reportHeight = heightPx - CARD_FOOTER_HEIGHT - CARD_HEADER_HEIGHT + 13; - const cardHeight = heightPx - CARD_FOOTER_HEIGHT; - const ref = React.useRef(); - - // @ts-ignore - const reportHeader = downloadComponentAsImage(ref)} - onToggleCardExpand={onToggleCardExpand} - expanded={expanded} - > - ; - - // @ts-ignore - const reportFooter = active ? - - : <>; - - const reportTypes = getReportTypes(extensions); - - - const withoutFooter = reportTypes[type] && reportTypes[type].withoutFooter ? reportTypes[type].withoutFooter : reportTypes[type] && !reportTypes[type].selection || (settings && settings.hideSelections); - - const getGlobalParameter = (key: string): any => { - return globalParameters ? globalParameters[key] : undefined; +import NeoCodeViewerComponent from '../../component/editor/CodeViewerComponent'; + +const NeoCardView = ({ + title, + database, + query, + globalParameters, + widthPx, + heightPx, + fields, + extensions, + active, + setActive, + onDownloadImage, + type, + selection, + dashboardSettings, + settings, + settingsOpen, + refreshRate, + editable, + onGlobalParameterUpdate, + onSelectionUpdate, + onToggleCardSettings, + onTitleUpdate, + onFieldsUpdate, + expanded, + onToggleCardExpand, +}) => { + const reportHeight = heightPx - CARD_FOOTER_HEIGHT - CARD_HEADER_HEIGHT + 13; + const cardHeight = heightPx - CARD_FOOTER_HEIGHT; + const ref = React.useRef(); + + const getLocalParameters = (parse_string): any => { + let re = /(?:^|\W)\$(\w+)(?!\w)/g; + let match; + let localQueryVariables: string[] = []; + while ((match = re.exec(parse_string))) { + localQueryVariables.push(match[1]); } - const getLocalParameters = (): any => { - let re = /(?:^|\W)\$(\w+)(?!\w)/g, match, localQueryVariables: string[] = []; - while (match = re.exec(query)) { - localQueryVariables.push(match[1]); - } - - if (!globalParameters) { - return {}; - } - return Object.fromEntries(Object.entries(globalParameters).filter(([local]) => localQueryVariables.includes(local))); + if (!globalParameters) { + return {}; } - - // TODO - understand why CardContent is throwing a warning based on this style config. - const cardContentStyle = { - paddingBottom: "0px", paddingLeft: "0px", paddingRight: "0px", paddingTop: "0px", width: "100%", marginTop: "-3px", - height: expanded ? (withoutFooter ? "100%" : `calc(100% - ${CARD_FOOTER_HEIGHT}px)`) : ((withoutFooter) ? reportHeight + CARD_FOOTER_HEIGHT + "px" : reportHeight + "px"), - overflow: "auto" - }; - - const reportContent = - {active ? - : - <> - { setActive(true) }}> - - - { }} - placeholder={"No query specified..."} - /> - - } + return Object.fromEntries( + Object.entries(globalParameters).filter(([local]) => localQueryVariables.includes(local)) + ); + }; + + // @ts-ignore + const reportHeader = ( + + ); + + // @ts-ignore + const reportFooter = active ? ( + + ) : ( + <> + ); + + const reportTypes = getReportTypes(extensions); + + const withoutFooter = + reportTypes[type] && reportTypes[type].withoutFooter + ? reportTypes[type].withoutFooter + : (reportTypes[type] && !reportTypes[type].selection) || (settings && settings.hideSelections); + + const getGlobalParameter = (key: string): any => { + return globalParameters ? globalParameters[key] : undefined; + }; + + // ONLY if the 'actions' extension is enabled, we send 'actionsRules' to the table visualization. + const filteredSettings = Object.fromEntries( + Object.entries(settings).filter( + ([k, _]) => + !( + k == 'actionsRules' && + dashboardSettings.extensions != null && + !dashboardSettings.extensions.includes('actions') + ) + ) + ); + + // TODO - understand why CardContent is throwing a warning based on this style config. + const cardContentStyle = { + paddingBottom: '0px', + paddingLeft: '0px', + paddingRight: '0px', + paddingTop: '0px', + width: '100%', + marginTop: '-3px', + height: expanded + ? withoutFooter + ? '100%' + : `calc(100% - ${CARD_FOOTER_HEIGHT}px)` + : withoutFooter + ? `${reportHeight + CARD_FOOTER_HEIGHT}px` + : `${reportHeight}px`, + overflow: 'auto', + }; + + const reportContent = ( + + {active ? ( + + ) : ( + <> + { + setActive(true); + }} + > + + + {}} + placeholder={'No query specified...'} + /> + + )} + ); - return ( -
- {reportHeader} - {/* if there's no selection for this report, we don't have a footer, so the report can be taller. */} - - {reportTypes[type] ? reportContent : } - {reportTypes[type] ? reportFooter : <>} - -
- ); + return ( +
+ {reportHeader} + {/* if there's no selection for this report, we don't have a footer, so the report can be taller. */} + + {reportTypes[type] ? ( + reportContent + ) : ( + + )} + {reportTypes[type] ? reportFooter : <>} + +
+ ); }; -export default NeoCardView; \ No newline at end of file +export default NeoCardView; diff --git a/src/card/view/CardViewFooter.tsx b/src/card/view/CardViewFooter.tsx index fa526d489..0a85e7862 100644 --- a/src/card/view/CardViewFooter.tsx +++ b/src/card/view/CardViewFooter.tsx @@ -1,107 +1,145 @@ -import React from "react"; -import { CardActions, Checkbox, FormControl, InputLabel, ListItemText, MenuItem, Select, TextField } from "@material-ui/core"; -import { categoricalColorSchemes } from "../../config/ColorConfig"; -import { getReportTypes } from "../../extensions/ExtensionUtils"; -import { SELECTION_TYPES } from "../../config/CardConfig"; +import React from 'react'; +import { CardActions, Checkbox, FormControl, InputLabel, MenuItem, Select } from '@material-ui/core'; +import { categoricalColorSchemes } from '../../config/ColorConfig'; +import { getReportTypes } from '../../extensions/ExtensionUtils'; +import { SELECTION_TYPES } from '../../config/CardConfig'; -const NeoCardViewFooter = ({ fields, settings, selection, type, extensions, showOptionalSelections, onSelectionUpdate }) => { - /** - * For each selectable field in the visualization, give the user an option to select them from the query output fields. - */ - const reportTypes = getReportTypes(extensions); - const selectableFields = reportTypes[type].selection; - const selectables = (selectableFields) ? Object.keys(selectableFields) : []; - const nodeColorScheme = settings && settings.nodeColorScheme ? settings.nodeColorScheme : "neodash"; - const hideSelections = settings && settings.hideSelections ? settings.hideSelections : false; - const ignoreLabelColors = reportTypes[type].ignoreLabelColors; - if (!fields || fields.length == 0 || hideSelections) { - return
- } - return ( - - {selectables.map((selectable, index) => { - const selectionIsMandatory = (selectableFields[selectable]['optional']) ? false : true; +const NeoCardViewFooter = ({ + fields, + settings, + selection, + type, + extensions, + showOptionalSelections, + onSelectionUpdate, +}) => { + /** + * For each selectable field in the visualization, give the user an option to select them from the query output fields. + */ + const reportTypes = getReportTypes(extensions); + const selectableFields = reportTypes[type].selection; + const selectables = selectableFields ? Object.keys(selectableFields) : []; + const nodeColorScheme = settings && settings.nodeColorScheme ? settings.nodeColorScheme : 'neodash'; + const hideSelections = settings && settings.hideSelections ? settings.hideSelections : false; + const { ignoreLabelColors } = reportTypes[type]; + if (!fields || fields.length == 0 || hideSelections) { + return
; + } + return ( + + {selectables.map((selectable, index) => { + const selectionIsMandatory = !selectableFields[selectable].optional; - // Creates the component for node property selections. - if (selectableFields[selectable].type == SELECTION_TYPES.NODE_PROPERTIES) { - // Only show optional selections if we explicitly allow it. - if (showOptionalSelections || selectionIsMandatory) { - const fieldSelections = fields.map((field, i) => { - const nodeLabel = field[0]; - const discoveredProperties = field.slice(1); - const properties = (discoveredProperties ? [...discoveredProperties].sort() : []).concat(["(label)", "(id)", "(no label)"]); - const totalColors = categoricalColorSchemes[nodeColorScheme] ? categoricalColorSchemes[nodeColorScheme].length : 0; - const color = (totalColors > 0 && !ignoreLabelColors) ? categoricalColorSchemes[nodeColorScheme][i % totalColors] : "lightgrey"; - return - {nodeLabel} - - ; - }); - return fieldSelections; - } - } - // Creates the selection for all other types of components - if (selectableFields[selectable].type == SELECTION_TYPES.LIST || - selectableFields[selectable].type == SELECTION_TYPES.NUMBER || - selectableFields[selectable].type == SELECTION_TYPES.NUMBER_OR_DATETIME || - selectableFields[selectable].type == SELECTION_TYPES.TEXT) { - if (selectionIsMandatory || showOptionalSelections) { - const sortedFields = fields ? [...fields].sort() : []; + // Creates the component for node property selections. + if (selectableFields[selectable].type == SELECTION_TYPES.NODE_PROPERTIES) { + // Only show optional selections if we explicitly allow it. + if (showOptionalSelections || selectionIsMandatory) { + const fieldSelections = fields.map((field, i) => { + // TODO logically, it should be the last element in the field (node labels) array, as that is typically + // the most specific node label when we have multi-labels + const nodeLabel = field[0]; + // TODO this convention that we have for storing node labels and properties in fields should be documented + // , and probably even converted to a generic type. + const discoveredProperties = field.slice(1); + const properties = (discoveredProperties ? [...discoveredProperties].sort() : []).concat([ + '(label)', + '(id)', + '(no label)', + ]); + const totalColors = categoricalColorSchemes[nodeColorScheme] + ? categoricalColorSchemes[nodeColorScheme].length + : 0; + const color = + totalColors > 0 && !ignoreLabelColors + ? categoricalColorSchemes[nodeColorScheme][i % totalColors] + : 'lightgrey'; + return ( + + + {nodeLabel} + + + + ); + }); + return fieldSelections; + } + } + // Creates the selection for all other types of components + if ( + selectableFields[selectable].type == SELECTION_TYPES.LIST || + selectableFields[selectable].type == SELECTION_TYPES.NUMBER || + selectableFields[selectable].type == SELECTION_TYPES.NUMBER_OR_DATETIME || + selectableFields[selectable].type == SELECTION_TYPES.TEXT + ) { + if (selectionIsMandatory || showOptionalSelections) { + const sortedFields = fields ? [...fields].sort() : []; - const fieldsToRender = (selectionIsMandatory ? sortedFields : sortedFields.concat(["(none)"])); - return - {selectableFields[selectable].label} - onSelectionUpdate(selectable, e.target.value)} + renderValue={(selected) => (Array.isArray(selected) ? selected.join(', ') : selected)} + value={ + selection && selection[selectable] + ? selectableFields[selectable].multiple && !Array.isArray(selection[selectable]) + ? [selection[selectable]] + : selection[selectable] + : selectableFields[selectable].multiple + ? selection[selectable] && selection[selectable].length > 0 + ? selection[selectable][0] + : [] + : '(no data)' + } + > + {/* Render choices */} + {fieldsToRender.map((field) => { + return ( + + {selectableFields[selectable].multiple && Array.isArray(selection[selectable]) ? ( + -1} /> + ) : ( + <> + )} + {field} + + ); + })} + + + ); + } + } + })} + + ); +}; - {/* Render choices */} - {fieldsToRender.map((field) => { - return - {selectableFields[selectable].multiple && Array.isArray(selection[selectable]) ? - -1} /> : - <> - } - {field} - {/* */} - - })} - - - } - } - - }) - } -
- ); -} - -export default NeoCardViewFooter; \ No newline at end of file +export default NeoCardViewFooter; diff --git a/src/card/view/CardViewHeader.tsx b/src/card/view/CardViewHeader.tsx index 305b51a3a..6fc681aa1 100644 --- a/src/card/view/CardViewHeader.tsx +++ b/src/card/view/CardViewHeader.tsx @@ -1,128 +1,186 @@ -import React, { useEffect } from "react"; +import React, { useEffect } from 'react'; import CardHeader from '@material-ui/core/CardHeader'; import IconButton from '@material-ui/core/IconButton'; import MoreVertIcon from '@material-ui/icons/MoreVert'; import FullscreenIcon from '@material-ui/icons/Fullscreen'; import FullscreenExit from '@material-ui/icons/FullscreenExit'; -import { Badge, Dialog, DialogContent, DialogTitle, TextField } from "@material-ui/core"; +import { Badge, Dialog, DialogContent, DialogTitle, TextField } from '@material-ui/core'; import debounce from 'lodash/debounce'; import { useCallback } from 'react'; import DragIndicatorIcon from '@material-ui/icons/DragIndicator'; -import DragHandleIcon from '@material-ui/icons/DragHandle'; import { Tooltip } from '@material-ui/core'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import ImageIcon from '@material-ui/icons/Image'; import CloseIcon from '@material-ui/icons/Close'; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown from 'react-markdown'; import gfm from 'remark-gfm'; -const NeoCardViewHeader = ({ title, description, editable, onTitleUpdate, fullscreenEnabled, downloadImageEnabled, - onToggleCardSettings, onDownloadImage, onToggleCardExpand, expanded }) => { +const NeoCardViewHeader = ({ + title, + description, + editable, + onTitleUpdate, + fullscreenEnabled, + downloadImageEnabled, + onToggleCardSettings, + onDownloadImage, + onToggleCardExpand, + expanded, + parameters, +}) => { + const [text, setText] = React.useState(title); + const [parsedText, setParsedText] = React.useState(title); + const [editing, setEditing] = React.useState(false); + const [descriptionModalOpen, setDescriptionModalOpen] = React.useState(false); - const [text, setText] = React.useState(title); - const [descriptionModalOpen, setDescriptionModalOpen] = React.useState(false); + function replaceParamsOnString(s, p) { + let parsed = `${s} `; + for (const [key, value] of Object.entries(p)) { + // TODO: make this a regex. + parsed = parsed.replace(`$${key} `, `${value} `); + parsed = parsed.replace(`$${key},`, `${value},`); + parsed = parsed.replace(`$${key}.`, `${value}.`); + } + return parsed; + } - // Ensure that we only trigger a text update event after the user has stopped typing. - const debouncedTitleUpdate = useCallback( - debounce(onTitleUpdate, 250), - [], - ); + // Ensure that we only trigger a text update event after the user has stopped typing. + const debouncedTitleUpdate = useCallback(debounce(onTitleUpdate, 250), []); - useEffect(() => { - // Reset text to the dashboard state when the page gets reorganized. - if (text !== title) { - setText(title); - } - }, [title]) + useEffect(() => { + let titleParsed = replaceParamsOnString(`${title}`, parameters); + if (!editing) { + setParsedText(titleParsed); + } + }, [editing, parameters]); + + useEffect(() => { + // Reset text to the dashboard state when the page gets reorganized. + if (text !== title) { + setText(title); + } + }, [title]); - const cardTitle = <> - - - - {editable ? : <>} - - - -
- - - { - setText(event.target.value); - debouncedTitleUpdate(event.target.value); - }} - /> -
+ const cardTitle = ( + <> + + + + {editable ? ( + + ) : ( + <> + )} + + + +
+ + + { + setEditing(true); + }} + onBlur={() => { + setEditing(false); + }} + className={'no-underline large'} + label='' + disabled={!editable} + placeholder='Report name...' + fullWidth + maxRows={4} + value={editing ? text : parsedText} + onChange={(event) => { + setText(event.target.value); + debouncedTitleUpdate(event.target.value); + }} + /> +
+ ); - const descriptionEnabled = description && description.length > 0; + const descriptionEnabled = description && description.length > 0; - const settingsButton = - - - + // TODO: all components like buttons should probably be seperate files + const settingsButton = ( + + + + + ); - const maximizeButton = - - - + const maximizeButton = ( + + + + + ); - const unMaximizeButton = - + const unMaximizeButton = ( + + + ); - const downloadImageButton = - - - - + const downloadImageButton = ( + + + + + ); - const descriptionButton = - setDescriptionModalOpen(true)} aria-label="details"> - - + const descriptionButton = ( + + setDescriptionModalOpen(true)} aria-label='details'> + + + ); - return <> - setDescriptionModalOpen(false)} aria-labelledby="form-dialog-title"> - - {title} - setDescriptionModalOpen(false)} style={{ padding: "3px", float: "right" }}> - - - - - - -
- -
-
-
- - {(downloadImageEnabled) ? downloadImageButton : <>} - {fullscreenEnabled ? (expanded ? unMaximizeButton : maximizeButton) : <>} - {descriptionEnabled ? descriptionButton : <>} - {editable ? settingsButton : <>} - } - title={cardTitle} /> + return ( + <> + setDescriptionModalOpen(false)} + aria-labelledby='form-dialog-title' + > + + {title} + setDescriptionModalOpen(false)} style={{ padding: '3px', float: 'right' }}> + + + + + + +
+ +
+
+
+ + {downloadImageEnabled ? downloadImageButton : <>} + {fullscreenEnabled ? expanded ? unMaximizeButton : maximizeButton : <>} + {descriptionEnabled ? descriptionButton : <>} + {editable ? settingsButton : <>} + + } + title={cardTitle} + /> -} + ); +}; export default NeoCardViewHeader; - diff --git a/src/chart/Chart.ts b/src/chart/Chart.ts index f0ff1b3df..907fd1987 100644 --- a/src/chart/Chart.ts +++ b/src/chart/Chart.ts @@ -3,14 +3,14 @@ import { Record as Neo4jRecord } from 'neo4j-driver'; // Interface for all charts that NeoDash can render. // When you extend NeoDash, make sure that your component implements this interface. export interface ChartProps { - records: Neo4jRecord[]; // Query output, Neo4j records as returned from the driver. - extensions?: Record; // A dictionary of enabled extensions. - selection?: Record; // A dictionary with the selection made in the report footer. - settings?: Record; // A dictionary with the 'advanced settings' specified through the NeoDash interface. - dimensions?: Record; // a dictionary with the dimensions of the report (likely not needed, charts automatically fill up space). - fullscreen?: boolean; // flag indicating whether the report is rendered in a fullscreen view. - parameters?: Record; // A dictionary with the global dashboard parameters. - queryCallback?: (query: string, parameters: Record, records: Neo4jRecord[]) => null; // Optionally, a way for the report to read more data from Neo4j. - setGlobalParameter?: (name: string, value: string) => void; // Allows a chart to update a global dashboard parameter to be used in Cypher queries for other reports. - getGlobalParameter?: (name) => string; // Allows a chart to get a global dashboard parameter. -} \ No newline at end of file + records: Neo4jRecord[]; // Query output, Neo4j records as returned from the driver. + extensions?: Record; // A dictionary of enabled extensions. + selection?: Record; // A dictionary with the selection made in the report footer. + settings?: Record; // A dictionary with the 'advanced settings' specified through the NeoDash interface. + dimensions?: Record; // a dictionary with the dimensions of the report (likely not needed, charts automatically fill up space). + fullscreen?: boolean; // flag indicating whether the report is rendered in a fullscreen view. + parameters?: Record; // A dictionary with the global dashboard parameters. + queryCallback?: (query: string, parameters: Record, records: Neo4jRecord[]) => null; // Optionally, a way for the report to read more data from Neo4j. + setGlobalParameter?: (name: string, value: string) => void; // Allows a chart to update a global dashboard parameter to be used in Cypher queries for other reports. + getGlobalParameter?: (name) => string; // Allows a chart to get a global dashboard parameter. +} diff --git a/src/chart/ChartUtils.ts b/src/chart/ChartUtils.ts index 792eb6494..e45798f7e 100644 --- a/src/chart/ChartUtils.ts +++ b/src/chart/ChartUtils.ts @@ -1,31 +1,27 @@ import domtoimage from 'dom-to-image'; - - - - /** - * Converts a neo4j record entry to a readable string representation. + * Converts a neo4j record entry to a readable string representation. */ export const convertRecordObjectToString = (entry) => { - if (entry == null || entry == undefined) { - return entry; - } - const className = entry.__proto__.constructor.name; - if (className == "String") { - return entry; - } else if (valueIsNode(entry)) { - return convertNodeToString(entry); - } else if (valueIsRelationship(entry)) { - return convertRelationshipToString(entry); - } else if (valueIsPath(entry)) { - return convertPathToString(entry); - } - return entry.toString(); -} + if (entry == null || entry == undefined) { + return entry; + } + const className = entry.__proto__.constructor.name; + if (className == 'String') { + return entry; + } else if (valueIsNode(entry)) { + return convertNodeToString(entry); + } else if (valueIsRelationship(entry)) { + return convertRelationshipToString(entry); + } else if (valueIsPath(entry)) { + return convertPathToString(entry); + } + return entry.toString(); +}; /** - * Converts a neo4j node record entry to a readable string representation. + * Converts a neo4j node record entry to a readable string representation. * if it's a fieldType =="Node" * Then, return * 1. 'name' property, if it exists, @@ -35,135 +31,131 @@ export const convertRecordObjectToString = (entry) => { * 5. the ({labels}}, if they exist, * 6. Node(id). */ -const convertNodeToString = (nodeEntry => { - if (nodeEntry.properties.name) { - return nodeEntry.labels + "(" + nodeEntry.properties.name + ")"; - } - if (nodeEntry.properties.title) { - return nodeEntry.labels + "(" + nodeEntry.properties.title + ")"; - } - if (nodeEntry.properties.id) { - return nodeEntry.labels + "(" + nodeEntry.properties.id + ")"; - } - if (nodeEntry.properties.uid) { - return nodeEntry.labels + "(" + nodeEntry.properties.uid + ")"; - } - return nodeEntry.labels + "(" + "_id=" + nodeEntry.identity + ")"; -}); - +const convertNodeToString = (nodeEntry) => { + if (nodeEntry.properties.name) { + return `${nodeEntry.labels}(${nodeEntry.properties.name})`; + } + if (nodeEntry.properties.title) { + return `${nodeEntry.labels}(${nodeEntry.properties.title})`; + } + if (nodeEntry.properties.id) { + return `${nodeEntry.labels}(${nodeEntry.properties.id})`; + } + if (nodeEntry.properties.uid) { + return `${nodeEntry.labels}(${nodeEntry.properties.uid})`; + } + return `${nodeEntry.labels}(` + `_id=${nodeEntry.identity})`; +}; // if it's a fieldType == "Relationship" -const convertRelationshipToString = (relEntry => { - return relEntry.toString(); -}); +const convertRelationshipToString = (relEntry) => { + return relEntry.toString(); +}; // if it's a fieldType == "Path" -const convertPathToString = (pathEntry => { - return pathEntry.toString(); -}); +const convertPathToString = (pathEntry) => { + return pathEntry.toString(); +}; // Anything else, return the string representation of the object. - - /* HELPER FUNCTIONS FOR DETERMINING TYPE OF FIELD RETURNED FROM NEO4J */ export function valueIsArray(value) { - const className = value.__proto__.constructor.name; - return className == "Array"; + const className = value.__proto__.constructor.name; + return className == 'Array'; } export function valueIsNode(value) { - // const className = value.__proto__.constructor.name; - // return className == "Node"; - return (value && value["labels"] && value["identity"] && value["properties"]); + // const className = value.__proto__.constructor.name; + // return className == "Node"; + return value && value.labels && value.identity && value.properties; } export function valueIsRelationship(value) { - // const className = value.__proto__.constructor.name; - // return className == "Relationship"; - return (value && value["type"] && value["start"] && value["end"] && value["identity"] && value["properties"]); + // const className = value.__proto__.constructor.name; + // return className == "Relationship"; + return value && value.type && value.start && value.end && value.identity && value.properties; } export function valueIsPath(value) { - // const className = value.__proto__.constructor.name; - // return className == "Path" - return (value && value["start"] && value["end"] && value["segments"] && value["length"]); + // const className = value.__proto__.constructor.name; + // return className == "Path" + return value && value.start && value.end && value.segments && value.length; } export function valueisPoint(value) { - // Look at the properties and identify the type. - return (value && value["x"] && value["y"] && value["srid"]); + // Look at the properties and identify the type. + return value && value.x && value.y && value.srid; } export function valueIsObject(value) { - // TODO - this will not work in production builds. Need alternative. - const className = value.__proto__.constructor.name; - return className == "Object"; + // TODO - this will not work in production builds. Need alternative. + const className = value.__proto__.constructor.name; + return className == 'Object'; } export function getRecordType(value) { - // mui data-grid native column types are: 'string' (default), - // 'number', 'date', 'dateTime', 'boolean' and 'singleSelect' - // https://v4.mui.com/components/data-grid/columns/#column-types - // Type singleSelect is not implemented here - if (value === true || value === false) { - return 'boolean'; - } else if (value === undefined) { - return 'undefined'; - } else if (value === null) { - return 'null'; - } else if (value.__isInteger__) { - return 'integer'; - } else if (typeof (value) == "number") { - return 'number'; - } else if (value.__isDate__) { - return 'date'; - } else if (value.__isDateTime__) { - return 'dateTime'; - } else if (valueIsNode(value)) { - return 'node'; - } else if (valueIsRelationship(value)) { - return 'relationship'; - } else if (valueIsPath(value)) { - return 'path'; - } else if (valueIsArray(value)) { - return 'array'; - } else if (valueIsObject(value)) { - return 'object'; - } - - // Use string as default type - return 'string'; + // mui data-grid native column types are: 'string' (default), + // 'number', 'date', 'dateTime', 'boolean' and 'singleSelect' + // https://v4.mui.com/components/data-grid/columns/#column-types + // Type singleSelect is not implemented here + if (value === true || value === false) { + return 'boolean'; + } else if (value === undefined) { + return 'undefined'; + } else if (value === null) { + return 'null'; + } else if (value.__isInteger__) { + return 'integer'; + } else if (typeof value == 'number') { + return 'number'; + } else if (value.__isDate__) { + return 'date'; + } else if (value.__isDateTime__) { + return 'dateTime'; + } else if (valueIsNode(value)) { + return 'node'; + } else if (valueIsRelationship(value)) { + return 'relationship'; + } else if (valueIsPath(value)) { + return 'path'; + } else if (valueIsArray(value)) { + return 'array'; + } else if (valueIsObject(value)) { + return 'object'; + } + + // Use string as default type + return 'string'; } - /** * Basic function to convert a table row output to a CSV file, and download it. * TODO: Make this more robust. Probably the commas should be escaped to ensure the CSV is always valid. */ export const downloadCSV = (rows) => { - const element = document.createElement("a"); - let csv = ""; - const headers = Object.keys(rows[0]).slice(1); - csv += headers.join(", ") + "\n"; - rows.forEach(row => { - headers.forEach((header) => { - // Parse value - var value = row[header]; - if (value && value["low"]) { - value = value["low"]; - } - csv += JSON.stringify(value).replaceAll(",", ";"); - csv += (headers.indexOf(header) < headers.length - 1) ? ", " : ""; - }); - csv += "\n"; + const element = document.createElement('a'); + let csv = ''; + const headers = Object.keys(rows[0]).slice(1); + csv += `${headers.join(', ')}\n`; + rows.forEach((row) => { + headers.forEach((header) => { + // Parse value + let value = row[header]; + if (value && value.low) { + value = value.low; + } + csv += JSON.stringify(value).replaceAll(',', ';'); + csv += headers.indexOf(header) < headers.length - 1 ? ', ' : ''; }); - - const file = new Blob([csv], { type: 'text/plain' }); - element.href = URL.createObjectURL(file); - element.download = "table.csv"; - document.body.appendChild(element); // Required for this to work in FireFox - element.click(); -} + csv += '\n'; + }); + + const file = new Blob([csv], { type: 'text/plain' }); + element.href = URL.createObjectURL(file); + element.download = 'table.csv'; + document.body.appendChild(element); // Required for this to work in FireFox + element.click(); +}; /** * Replaces all global dashboard parameters inside a string with their values. @@ -171,65 +163,69 @@ export const downloadCSV = (rows) => { * @param parameters The parameters to replace. */ export function replaceDashboardParameters(str, parameters) { - if (!str) return ""; - Object.keys(parameters).forEach(key => { - str = str.replaceAll("$" + key, parameters[key] !== null ? parameters[key] : ""); - }); - return str; + if (!str) { + return ''; + } + Object.keys(parameters).forEach((key) => { + str = str.replaceAll(`$${key}`, parameters[key] !== null ? parameters[key] : ''); + }); + return str; } /** * Downloads a screenshot of the element reference passed to it. * @param ref The reference to the element to download as an image. */ -export const downloadComponentAsImage = async (ref) => { - const element = ref.current; - - domtoimage.toPng(element, { bgcolor: 'white' }).then(function (dataUrl) { - var link = document.createElement('a'); - link.download = 'image.png'; - link.href = dataUrl; - link.click(); - }); +export const downloadComponentAsImage = (ref) => { + const element = ref.current; + + domtoimage.toPng(element, { bgcolor: 'white' }).then((dataUrl) => { + const link = document.createElement('a'); + link.download = 'image.png'; + link.href = dataUrl; + link.click(); + }); }; -import { QueryResult, Record as Neo4jRecord } from 'neo4j-driver' +import { QueryResult, Record as Neo4jRecord } from 'neo4j-driver'; export function recordToNative(input: any): any { - if (!input && input !== false) { - return null - } - else if (typeof input.keys === 'object' && typeof input.get === 'function') { - return Object.fromEntries(input.keys.map(key => [key, recordToNative(input.get(key))])) - } - else if (typeof input.toNumber === 'function') { - return input.toNumber() - } - else if (Array.isArray(input)) { - return (input as Array).map(item => recordToNative(item)) - } - else if (typeof input === 'object') { - const converted = Object.entries(input).map(([key, value]) => [key, recordToNative(value)]) - - return Object.fromEntries(converted) - } - - return input + if (!input && input !== false) { + return null; + } else if (typeof input.keys === 'object' && typeof input.get === 'function') { + return Object.fromEntries(input.keys.map((key) => [key, recordToNative(input.get(key))])); + } else if (typeof input.toNumber === 'function') { + return input.toNumber(); + } else if (Array.isArray(input)) { + return (input as Array).map((item) => recordToNative(item)); + } else if (typeof input === 'object') { + const converted = Object.entries(input).map(([key, value]) => [key, recordToNative(value)]); + + return Object.fromEntries(converted); + } + + return input; } export function resultToNative(result: QueryResult): Record { - if (!result) return {} + if (!result) { + return {}; + } - return result.records.map(row => recordToNative(row)) + return result.records.map((row) => recordToNative(row)); } export function checkResultKeys(first: Neo4jRecord, keys: string[]) { - const missing = keys.filter(key => !first.keys.includes(key)) + const missing = keys.filter((key) => !first.keys.includes(key)); - if (missing.length > 0) { - return new Error(`The query is missing the following key${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}. The expected keys are: ${keys.join(', ')}`) - } + if (missing.length > 0) { + return new Error( + `The query is missing the following key${missing.length > 1 ? 's' : ''}: ${missing.join( + ', ' + )}. The expected keys are: ${keys.join(', ')}` + ); + } - return false + return false; } /** @@ -237,81 +233,92 @@ export function checkResultKeys(first: Neo4jRecord, keys: string[]) { * If none can be found, return null. */ export const search = (tree, value, key = 'id', reverse = false) => { - if (tree.length == 0) return null; - const stack = Array.isArray(tree) ? [...tree] : [tree] - while (stack.length) { - const node = stack[reverse ? 'pop' : 'shift']() - if (node[key] && node[key] === value) return node - node.children && stack.push(...node.children) + if (tree.length == 0) { + return null; + } + const stack = Array.isArray(tree) ? [...tree] : [tree]; + while (stack.length) { + const node = stack[reverse ? 'pop' : 'shift'](); + if (node[key] && node[key] === value) { + return node; + } + if (node.children) { + stack.push(...node.children); } - return null + } + return null; }; - /** * For hierarchical data, we remove all intermediate node prefixes generate by `processHierarchyFromRecords`. * This ensures that the visualization itself shows the 'real' names, and not the intermediate ones. */ export const mutateName = (currentNode) => { - if (currentNode.name) { - let s = currentNode.name.split('_'); - currentNode.name = s.length > 0 ? s.slice(1).join('_') : s[0]; - } - - if (currentNode.children) - currentNode.children.forEach(n => mutateName(n)) -} + if (currentNode.name) { + const s = currentNode.name.split('_'); + currentNode.name = s.length > 0 ? s.slice(1).join('_') : s[0]; + } + + if (currentNode.children) { + currentNode.children.forEach((n) => mutateName(n)); + } +}; -export const findObject = (data, name) => data.find(searchedName => searchedName.name === name); +export const findObject = (data, name) => data.find((searchedName) => searchedName.name === name); -export const flatten = data => - data.reduce((acc, item) => { - if (item.children) - return [...acc, item, ...flatten(item.children)] - return [...acc, item] - }, []); +export const flatten = (data) => + data.reduce((acc, item) => { + if (item.children) { + return [...acc, item, ...flatten(item.children)]; + } + return [...acc, item]; + }, []); /** * Converts a list of Neo4j records into a hierarchy structure for hierarchical data visualizations. */ -export const processHierarchyFromRecords = (records: Record[], selection: any ) => { - return records.reduce((data: Record, row: Record) => { - try { - // const index = recordToNative(row.get('index')); - // const key = recordToNative(row.get('key')); - // const value = recordToNative(row.get('value')); - - const index = recordToNative(row.get(selection['index'])); - // const idx = data.findIndex(item => item.index === index) - // const key = selection['key'] !== "(none)" ? recordToNative(row.get(selection['key'])) : selection['value']; - const value = recordToNative(row.get(selection['value'])); - if(!Array.isArray(index) || isNaN(value)){ - throw "Invalid data format selected for hierarchy report."; - } - let holder = data; - for (let [idx, val] of index.entries()) { - // Add a level prefix to each item to avoid duplicates - val = "lvl" + idx + "_" + val; - let obj = search(holder, val, 'name'); - let entry = { name: val }; - if (obj) - holder = obj; - else { - if (Array.isArray(holder)) - holder.push(entry); - else if (holder.hasOwnProperty("children")) - holder.children.push(entry) - else - holder.children = [entry] - - holder = search(holder, val, 'name'); - } - } - holder.loc = value; - return data - } catch (e) { - console.error(e); - return []; +// TODO: this needs docs +export const processHierarchyFromRecords = (records: Record[], selection: any) => { + return records.reduce((data: Record, row: Record) => { + try { + // const index = recordToNative(row.get('index')); + // const key = recordToNative(row.get('key')); + // const value = recordToNative(row.get('value')); + + const index = recordToNative(row.get(selection.index)); + // const idx = data.findIndex(item => item.index === index) + // const key = selection['key'] !== "(none)" ? recordToNative(row.get(selection['key'])) : selection['value']; + const value = recordToNative(row.get(selection.value)); + if (!Array.isArray(index) || isNaN(value)) { + throw 'Invalid data format selected for hierarchy report.'; + } + let holder = data; + for (let [idx, val] of index.entries()) { + // Add a level prefix to each item to avoid duplicates + val = `lvl${idx}_${val}`; + const obj = search(holder, val, 'name'); + const entry = { name: val }; + if (obj) { + holder = obj; + } else { + if (Array.isArray(holder)) { + holder.push(entry); + // eslint-disable-next-line no-prototype-builtins + } else if (holder.hasOwnProperty('children')) { + holder.children.push(entry); + } else { + holder.children = [entry]; + } + + holder = search(holder, val, 'name'); } - }, []); -} + } + holder.loc = value; + return data; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return []; + } + }, []); +}; diff --git a/src/chart/Utils.ts b/src/chart/Utils.ts index 448045428..1b5b0882d 100644 --- a/src/chart/Utils.ts +++ b/src/chart/Utils.ts @@ -1,40 +1,42 @@ -import { QueryResult, Record as Neo4jRecord } from 'neo4j-driver' +import { QueryResult, Record as Neo4jRecord } from 'neo4j-driver'; export function recordToNative(input: any): any { - if ( !input && input !== false ) { - return null - } - else if ( typeof input.keys === 'object' && typeof input.get === 'function' ) { - return Object.fromEntries(input.keys.map(key => [ key, recordToNative(input.get(key)) ])) - } - else if ( typeof input.toNumber === 'function' ) { - return input.toNumber() - } - else if ( Array.isArray(input) ) { - return (input as Array).map(item => recordToNative(item)) - } - else if ( typeof input === 'object' ) { - const converted = Object.entries(input).map(([ key, value ]) => [ key, recordToNative(value) ]) + if (!input && input !== false) { + return null; + } else if (typeof input.keys === 'object' && typeof input.get === 'function') { + return Object.fromEntries(input.keys.map((key) => [key, recordToNative(input.get(key))])); + } else if (typeof input.toNumber === 'function') { + return input.toNumber(); + } else if (Array.isArray(input)) { + return (input as Array).map((item) => recordToNative(item)); + } else if (typeof input === 'object') { + const converted = Object.entries(input).map(([key, value]) => [key, recordToNative(value)]); - return Object.fromEntries(converted) - } + return Object.fromEntries(converted); + } - return input + return input; } export function resultToNative(result: QueryResult): Record { - if (!result) return {} + if (!result) { + return {}; + } - return result.records.map(row => recordToNative(row)) + return result.records.map((row) => recordToNative(row)); } export function checkResultKeys(first: Neo4jRecord, keys: string[]) { - const missing = keys.filter(key => !first.keys.includes(key)) + const missing = keys.filter((key) => !first.keys.includes(key)); - if ( missing.length > 0 ) { - return new Error(`The query is missing the following key${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}. The expected keys are: ${keys.join(', ')}`) - } + if (missing.length > 0) { + return new Error( + `The query is missing the following key${missing.length > 1 ? 's' : ''}: ${missing.join( + ', ' + )}. The expected keys are: ${keys.join(', ')}` + ); + } - return false + return false; } /** @@ -42,36 +44,43 @@ export function checkResultKeys(first: Neo4jRecord, keys: string[]) { * If none can be found, return null. */ export const search = (tree, value, key = 'id', reverse = false) => { - if (tree.length == 0) return null; - const stack = Array.isArray(tree) ? [...tree] : [tree] - while (stack.length) { - const node = stack[reverse ? 'pop' : 'shift']() - if (node[key] && node[key] === value) return node - node.children && stack.push(...node.children) + if (tree.length == 0) { + return null; + } + const stack = Array.isArray(tree) ? [...tree] : [tree]; + while (stack.length) { + const node = stack[reverse ? 'pop' : 'shift'](); + if (node[key] && node[key] === value) { + return node; } - return null + if (node.children) { + stack.push(...node.children); + } + } + return null; }; - /** * For hierarchical data, we remove all intermediate node prefixes generate by `processHierarchyFromRecords`. * This ensures that the visualization itself shows the 'real' names, and not the intermediate ones. */ export const mutateName = (currentNode) => { - if (currentNode.name){ - let s = currentNode.name.split('_'); - currentNode.name = s.length > 0 ? s.slice(1).join('_'): s[0]; - } + if (currentNode.name) { + const s = currentNode.name.split('_'); + currentNode.name = s.length > 0 ? s.slice(1).join('_') : s[0]; + } - if(currentNode.children) - currentNode.children.forEach(n => mutateName(n)) -} + if (currentNode.children) { + currentNode.children.forEach((n) => mutateName(n)); + } +}; -export const findObject = (data, name) => data.find(searchedName => searchedName.name === name); +export const findObject = (data, name) => data.find((searchedName) => searchedName.name === name); -export const flatten = data => - data.reduce((acc, item) => { - if (item.children) - return [...acc, item, ...flatten(item.children)] - return [...acc, item] - }, []); +export const flatten = (data) => + data.reduce((acc, item) => { + if (item.children) { + return [...acc, item, ...flatten(item.children)]; + } + return [...acc, item]; + }, []); diff --git a/src/chart/bar/BarChart.tsx b/src/chart/bar/BarChart.tsx index d334f4e63..ca5a63256 100644 --- a/src/chart/bar/BarChart.tsx +++ b/src/chart/bar/BarChart.tsx @@ -12,198 +12,221 @@ import { convertRecordObjectToString, recordToNative } from '../ChartUtils'; * TODO: There is a regression here with nivo > 0.73 causing the bar chart to have a very slow re-render. */ const NeoBarChart = (props: ChartProps) => { + /** + * The code fragment below is a workaround for a bug in nivo > 0.73 causing bar charts to re-render very slowly. + */ + const [loading, setLoading] = React.useState(false); + useEffect(() => { + setLoading(true); + const timeOutId = setTimeout(() => { + setLoading(false); + }, 1); + return () => clearTimeout(timeOutId); + }, [props.selection]); + if (loading) { + return <>; + } - /** - * The code fragment below is a workaround for a bug in nivo > 0.73 causing bar charts to re-render very slowly. - */ - const [loading, setLoading] = React.useState(false); - useEffect(() => { - setLoading(true); - const timeOutId = setTimeout(() => { - setLoading(false); - }, 1); - return () => clearTimeout(timeOutId); - }, [props.selection]) - if (loading) { - return <>; - } - - const records = props.records; - const selection = props.selection; + const { records, selection } = props; - if (!selection || props.records == null || props.records.length == 0 || props.records[0].keys == null) { - return - } + if (!selection || props.records == null || props.records.length == 0 || props.records[0].keys == null) { + return ; + } - const keys = {}; - const data: Record[] = records.reduce((data: Record[], row: Record) => { - try { - if (!selection || !selection['index'] || !selection['value']) { - return data; - } - const index = convertRecordObjectToString(row.get(selection['index'])); - const idx = data.findIndex(item => item.index === index) + const keys = {}; + const data: Record[] = records + .reduce((data: Record[], row: Record) => { + try { + if (!selection || !selection.index || !selection.value) { + return data; + } + const index = convertRecordObjectToString(row.get(selection.index)); + const idx = data.findIndex((item) => item.index === index); - const key = selection['key'] !== "(none)" ? recordToNative(row.get(selection['key'])) : selection['value']; - const value = recordToNative(row.get(selection['value'])); + const key = selection.key !== '(none)' ? recordToNative(row.get(selection.key)) : selection.value; + const value = recordToNative(row.get(selection.value)); - if (isNaN(value)) { - return data; - } - keys[key] = true; + if (isNaN(value)) { + return data; + } + keys[key] = true; - if (idx > -1) { - data[idx][key] = value - } - else { - data.push({ index, [key]: value }) - } - return data - } catch (e) { - console.error(e); - return []; + if (idx > -1) { + data[idx][key] = value; + } else { + data.push({ index, [key]: value }); } + return data; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return []; + } }, []) - .map(row => { - Object.keys(keys).forEach(key => { - if (!row.hasOwnProperty(key)) { - row[key] = 0 - } - }) - return row - }) + .map((row) => { + Object.keys(keys).forEach((key) => { + // eslint-disable-next-line no-prototype-builtins + if (!row.hasOwnProperty(key)) { + row[key] = 0; + } + }); + return row; + }); - const settings = (props.settings) ? props.settings : {}; - const legendWidth = (settings["legendWidth"]) ? settings["legendWidth"] : 128; - const marginRight = (settings["marginRight"]) ? settings["marginRight"] : 24; - const marginLeft = (settings["marginLeft"]) ? settings["marginLeft"] : 50; - const marginTop = (settings["marginTop"]) ? settings["marginTop"] : 24; - const marginBottom = (settings["marginBottom"]) ? settings["marginBottom"] : 40; - const legend = (settings["legend"]) ? settings["legend"] : false; - const labelRotation = (settings["labelRotation"] != undefined) ? settings["labelRotation"] : 45; + const settings = props.settings ? props.settings : {}; + const legendWidth = settings.legendWidth ? settings.legendWidth : 128; + const marginRight = settings.marginRight ? settings.marginRight : 24; + const marginLeft = settings.marginLeft ? settings.marginLeft : 50; + const marginTop = settings.marginTop ? settings.marginTop : 24; + const marginBottom = settings.marginBottom ? settings.marginBottom : 40; + const legend = settings.legend ? settings.legend : false; + const labelRotation = settings.labelRotation != undefined ? settings.labelRotation : 45; - const labelSkipWidth = (settings["labelSkipWidth"]) ? (settings["labelSkipWidth"]) : 0; - const labelSkipHeight = (settings["labelSkipHeight"]) ? (settings["labelSkipHeight"]) : 0; - const enableLabel = (settings["barValues"]) ? settings["barValues"] : false; - const positionLabel = (settings["positionLabel"]) ? settings["positionLabel"] : 'off'; + const labelSkipWidth = settings.labelSkipWidth ? settings.labelSkipWidth : 0; + const labelSkipHeight = settings.labelSkipHeight ? settings.labelSkipHeight : 0; + const enableLabel = settings.barValues ? settings.barValues : false; + const positionLabel = settings.positionLabel ? settings.positionLabel : 'off'; - const layout = (settings["layout"]) ? settings["layout"] : 'vertical'; - const colorScheme = (settings["colors"]) ? settings["colors"] : 'set2'; - const groupMode = (settings["groupMode"]) ? settings["groupMode"] : 'stacked'; - const valueScale = (settings["valueScale"]) ? settings["valueScale"] : 'linear'; - const minValue = (settings["minValue"]) ? settings["minValue"] : 'auto'; - const maxValue = (settings["maxValue"]) ? settings["maxValue"] : 'auto'; - const styleRules = extensionEnabled(props.extensions, 'styling') && props.settings && props.settings.styleRules ? props.settings.styleRules : []; + // TODO: we should make all these defaults be loaded from the config file. + const layout = settings.layout ? settings.layout : 'vertical'; + const colorScheme = settings.colors ? settings.colors : 'set2'; + const groupMode = settings.groupMode ? settings.groupMode : 'stacked'; + const valueScale = settings.valueScale ? settings.valueScale : 'linear'; + const minValue = settings.minValue ? settings.minValue : 'auto'; + const maxValue = settings.maxValue ? settings.maxValue : 'auto'; + const styleRules = + extensionEnabled(props.extensions, 'styling') && props.settings && props.settings.styleRules + ? props.settings.styleRules + : []; - // Compute bar color based on rules - overrides default color scheme completely. - const getBarColor = (bar) => { - const data = {} - if (!selection || !selection['index'] || !selection['value']) { - return "grey"; - } - data[selection['index']] = bar.indexValue; - data[selection['value']] = bar.value; - data[selection['key']] = bar.id; - const validRuleIndex = evaluateRulesOnDict(data, styleRules, ['bar color']); - if (validRuleIndex !== -1) { - return styleRules[validRuleIndex]['customizationValue']; - } - return "grey" + // Compute bar color based on rules - overrides default color scheme completely. + const getBarColor = (bar) => { + const data = {}; + if (!selection || !selection.index || !selection.value) { + return 'grey'; } - if (data.length == 0) { - return + data[selection.index] = bar.indexValue; + data[selection.value] = bar.value; + data[selection.key] = bar.id; + const validRuleIndex = evaluateRulesOnDict(data, styleRules, ['bar color']); + if (validRuleIndex !== -1) { + return styleRules[validRuleIndex].customizationValue; } + return 'grey'; + }; + if (data.length == 0) { + return ; + } - const BarComponent = ({ bar, borderColor }) => { - let shade = false; - let darkTop = false; - let includeIndex = false; - let x = bar.width/ 2,y = bar.height / 2, textAnchor = "middle"; - if (positionLabel == "top") - if(layout == "vertical") - y = - 10 ; - else - x = bar.width + 10; - else if (positionLabel == "bottom") - if(layout == "vertical") - y = bar.height + 10; - else - x = - 10 ; + const BarComponent = ({ bar, borderColor }) => { + let shade = false; + let darkTop = false; + let includeIndex = false; + let x = bar.width / 2; + let y = bar.height / 2; + let textAnchor = 'middle'; + if (positionLabel == 'top') { + if (layout == 'vertical') { + y = -10; + } else { + x = bar.width + 10; + } + } else if (positionLabel == 'bottom') { + if (layout == 'vertical') { + y = bar.height + 10; + } else { + x = -10; + } + } - return ( - - {shade ? : <>} - - { darkTop ? : <> } - {includeIndex ? - {bar.data.indexValue} - : <> } - { enableLabel ? - {bar.data.value} - : <> } - - ) - } - // TODO: Get rid of duplicate pie slice names... + return ( + + {shade ? : <>} + + {darkTop ? ( + + ) : ( + <> + )} + {includeIndex ? ( + + {bar.data.indexValue} + + ) : ( + <> + )} + {enableLabel ? ( + + {bar.data.value} + + ) : ( + <> + )} + + ); + }; + // TODO: Get rid of duplicate pie slice names... - const extraProperties = positionLabel == "off" ? {} : { barComponent : BarComponent }; - return = 1 ? getBarColor : { scheme: colorScheme }} - axisTop={null} - axisRight={null} - axisBottom={{ - tickSize: 5, - tickPadding: 5, - tickRotation: labelRotation, - }} - axisLeft={{ - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - }} - labelSkipWidth={labelSkipWidth} - labelSkipHeight={labelSkipHeight} - labelTextColor={{ from: 'color', modifiers: [['darker', 1.6]] }} - { ...extraProperties} - legends={(legend) ? [ - { + const extraProperties = positionLabel == 'off' ? {} : { barComponent: BarComponent }; + return ( + = 1 ? getBarColor : { scheme: colorScheme }} + axisTop={null} + axisRight={null} + axisBottom={{ + tickSize: 5, + tickPadding: 5, + tickRotation: labelRotation, + }} + axisLeft={{ + tickSize: 5, + tickPadding: 5, + tickRotation: 0, + }} + labelSkipWidth={labelSkipWidth} + labelSkipHeight={labelSkipHeight} + labelTextColor={{ from: 'color', modifiers: [['darker', 1.6]] }} + {...extraProperties} + legends={ + legend + ? [ + { dataFrom: 'keys', anchor: 'bottom-right', direction: 'column', @@ -217,19 +240,20 @@ const NeoBarChart = (props: ChartProps) => { itemOpacity: 0.85, symbolSize: 20, effects: [ - { - on: 'hover', - style: { - itemOpacity: 1 - } - } - ] - } - ] : []} - animate={false} + { + on: 'hover', + style: { + itemOpacity: 1, + }, + }, + ], + }, + ] + : [] + } + animate={false} /> + ); +}; - -} - -export default NeoBarChart; \ No newline at end of file +export default NeoBarChart; diff --git a/src/chart/graph/GraphChart.tsx b/src/chart/graph/GraphChart.tsx index b2d337fc1..a1c8e576d 100644 --- a/src/chart/graph/GraphChart.tsx +++ b/src/chart/graph/GraphChart.tsx @@ -1,8 +1,7 @@ - -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import ForceGraph2D from 'react-force-graph-2d'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import ForceGraph2D from 'react-force-graph-2d'; import ReactDOMServer from 'react-dom/server'; -import useDimensions from "react-cool-dimensions"; +import useDimensions from 'react-cool-dimensions'; import { categoricalColorSchemes } from '../../config/ColorConfig'; import { ChartProps } from '../Chart'; import { valueIsArray, valueIsNode, valueIsRelationship, valueIsPath } from '../../chart/ChartUtils'; @@ -10,7 +9,7 @@ import { NeoGraphItemInspectModal } from '../../modal/GraphItemInspectModal'; import LockIcon from '@material-ui/icons/Lock'; import LockOpenIcon from '@material-ui/icons/LockOpen'; import SettingsOverscanIcon from '@material-ui/icons/SettingsOverscan'; -import { Card, CardContent, CardHeader, Fab, Tooltip } from '@material-ui/core'; +import { Card, Fab, Tooltip } from '@material-ui/core'; import Table from '@material-ui/core/Table'; import TableBody from '@material-ui/core/TableBody'; import TableCell from '@material-ui/core/TableCell'; @@ -20,473 +19,519 @@ import SearchIcon from '@material-ui/icons/Search'; import { evaluateRulesOnNode } from '../../extensions/styling/StyleRuleEvaluator'; import { extensionEnabled } from '../../extensions/ExtensionUtils'; -const drawDataURIOnCanvas = (node, strDataURI, canvas, defaultNodeSize) => { - var img = new Image; - let prop = defaultNodeSize * 6; - /*img.onload = function(){ +const drawDataURIOnCanvas = (node, strDataURI, canvas, defaultNodeSize) => { + let img = new Image(); + let prop = defaultNodeSize * 6; + /* img.onload = function(){ canvas.drawImage(img,node.x - (prop/2),node.y -(prop/2) , prop, prop); }*/ - img.src = strDataURI; - canvas.drawImage(img,node.x - (prop/2),node.y -(prop/2) , prop, prop); - -} - // TODO: Fix experimental 3D graph visualization. - // const nodeTree = (node) => { - // const imgTexture = new THREE.TextureLoader().load(`https://img.icons8.com/external-flaticons-lineal-color-flat-icons/344/external-url-gdpr-flaticons-lineal-color-flat-icons.png`); - // const material = new THREE.SpriteMaterial({ map: imgTexture }); - // const sprite = new THREE.Sprite(material); - // sprite.scale.set(12, 12); - // return sprite; - // } - + img.src = strDataURI; + canvas.drawImage(img, node.x - prop / 2, node.y - prop / 2, prop, prop); +}; +// TODO: Fix experimental 3D graph visualization. +// const nodeTree = (node) => { +// const imgTexture = new THREE.TextureLoader().load(`https://img.icons8.com/external-flaticons-lineal-color-flat-icons/344/external-url-gdpr-flaticons-lineal-color-flat-icons.png`); +// const material = new THREE.SpriteMaterial({ map: imgTexture }); +// const sprite = new THREE.Sprite(material); +// sprite.scale.set(12, 12); +// return sprite; +// } -const update = (state, mutations) => - Object.assign({}, state, mutations) +const update = (state, mutations) => Object.assign({}, state, mutations); const layouts = { - "force-directed": undefined, - "tree": "td", - "radial": "radialout" + 'force-directed': undefined, + tree: 'td', + radial: 'radialout', }; /** * Draws graph data using a force-directed-graph visualization. - * This visualization is powered by `react-force-graph`. + * This visualization is powered by `react-force-graph`. * See https://github.com/vasturiano/react-force-graph for examples on customization. */ const NeoGraphChart = (props: ChartProps) => { - if (props.records == null || props.records.length == 0 || props.records[0].keys == null) { - return <>No data, re-run the report. + if (props.records == null || props.records.length == 0 || props.records[0].keys == null) { + return <>No data, re-run the report.; + } + + const [open, setOpen] = useState(false); + const [firstRun, setFirstRun] = useState(true); + const [inspectItem, setInspectItem] = useState({}); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + // Retrieve config from advanced settings + const backgroundColor = props.settings && props.settings.backgroundColor ? props.settings.backgroundColor : '#fafafa'; + const nodeSizeProp = props.settings && props.settings.nodeSizeProp ? props.settings.nodeSizeProp : 'size'; + const nodeColorProp = props.settings && props.settings.nodeColorProp ? props.settings.nodeColorProp : 'color'; + const defaultNodeSize = props.settings && props.settings.defaultNodeSize ? props.settings.defaultNodeSize : 2; + const relWidthProp = props.settings && props.settings.relWidthProp ? props.settings.relWidthProp : 'width'; + const relColorProp = props.settings && props.settings.relColorProp ? props.settings.relColorProp : 'color'; + const defaultRelWidth = props.settings && props.settings.defaultRelWidth ? props.settings.defaultRelWidth : 1; + const defaultRelColor = props.settings && props.settings.defaultRelColor ? props.settings.defaultRelColor : '#a0a0a0'; + const nodeLabelColor = props.settings && props.settings.nodeLabelColor ? props.settings.nodeLabelColor : 'black'; + const nodeLabelFontSize = props.settings && props.settings.nodeLabelFontSize ? props.settings.nodeLabelFontSize : 3.5; + const relLabelFontSize = props.settings && props.settings.relLabelFontSize ? props.settings.relLabelFontSize : 2.75; + const styleRules = + extensionEnabled(props.extensions, 'styling') && props.settings && props.settings.styleRules + ? props.settings.styleRules + : []; + const relLabelColor = props.settings && props.settings.relLabelColor ? props.settings.relLabelColor : '#a0a0a0'; + const nodeColorScheme = props.settings && props.settings.nodeColorScheme ? props.settings.nodeColorScheme : 'neodash'; + const showPropertiesOnHover = + props.settings && props.settings.showPropertiesOnHover !== undefined ? props.settings.showPropertiesOnHover : true; + const showPropertiesOnClick = + props.settings && props.settings.showPropertiesOnClick !== undefined ? props.settings.showPropertiesOnClick : true; + const fixNodeAfterDrag = + props.settings && props.settings.fixNodeAfterDrag !== undefined ? props.settings.fixNodeAfterDrag : true; + const layout = props.settings && props.settings.layout !== undefined ? props.settings.layout : 'force-directed'; + const lockable = props.settings && props.settings.lockable !== undefined ? props.settings.lockable : true; + const drilldownLink = + props.settings && props.settings.drilldownLink !== undefined ? props.settings.drilldownLink : ''; + const selfLoopRotationDegrees = 45; + const rightClickToExpandNodes = false; // TODO - this isn't working properly yet, disable it. + const defaultNodeColor = 'lightgrey'; // Color of nodes without labels + const linkDirectionalParticles = props.settings && props.settings.relationshipParticles ? 5 : undefined; + const linkDirectionalParticleSpeed = + props.settings && props.settings.relationshipParticleSpeed ? props.settings.relationshipParticleSpeed : 0.005; // Speed of particles on relationships. + const iconStyle = props.settings && props.settings.iconStyle !== undefined ? props.settings.iconStyle : ''; + let iconObject = undefined; + try { + iconObject = iconStyle ? JSON.parse(iconStyle) : undefined; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } + + // get dashboard parameters. + const parameters = props.parameters ? props.parameters : {}; + + const [data, setData] = useState({ nodes: [], links: [] }); + + // TODO we are modifying state directly instead of with an action, not good + // Create the dictionary used for storing the memory of dragged node positions. + if (props.settings.nodePositions == undefined) { + props.settings.nodePositions = {}; + } + let nodePositions = props.settings && props.settings.nodePositions; + + // 'frozen' indicates that the graph visualization engine is paused, node positions and stored and only dragging is possible. + const [frozen, setFrozen] = useState( + props.settings && props.settings.frozen !== undefined ? props.settings.frozen : false + ); + + // Currently unused, but dynamic graph exploration could be done with these records. + const [extraRecords, setExtraRecords] = useState([]); + + // When data is refreshed, rebuild the visualization data. + useEffect(() => { + buildVisualizationDictionaryFromRecords(props.records); + }, []); + + const { observe, width, height } = useDimensions({ + onResize: ({ observe, unobserve }) => { + // Triggered whenever the size of the target is changed... + unobserve(); // To stop observing the current target element + observe(); // To re-start observing the current target element + }, + }); + + // Dictionaries to populate based on query results. + let nodes = {}; + let nodeLabels = {}; + let links = {}; + let linkTypes = {}; + + // Gets all graphy objects (nodes/relationships) from the complete set of return values. + function extractGraphEntitiesFromField(value) { + if (value == undefined) { + return; } - - const [open, setOpen] = React.useState(false); - const [firstRun, setFirstRun] = React.useState(true); - const [inspectItem, setInspectItem] = React.useState({}); - - const handleOpen = () => { - setOpen(true); - }; - - const handleClose = () => { - setOpen(false); - }; - - // Retrieve config from advanced settings - const backgroundColor = props.settings && props.settings.backgroundColor ? props.settings.backgroundColor : "#fafafa"; - const nodeSizeProp = props.settings && props.settings.nodeSizeProp ? props.settings.nodeSizeProp : "size"; - const nodeColorProp = props.settings && props.settings.nodeColorProp ? props.settings.nodeColorProp : "color"; - const defaultNodeSize = props.settings && props.settings.defaultNodeSize ? props.settings.defaultNodeSize : 2; - const relWidthProp = props.settings && props.settings.relWidthProp ? props.settings.relWidthProp : "width"; - const relColorProp = props.settings && props.settings.relColorProp ? props.settings.relColorProp : "color"; - const defaultRelWidth = props.settings && props.settings.defaultRelWidth ? props.settings.defaultRelWidth : 1; - const defaultRelColor = props.settings && props.settings.defaultRelColor ? props.settings.defaultRelColor : "#a0a0a0"; - const nodeLabelColor = props.settings && props.settings.nodeLabelColor ? props.settings.nodeLabelColor : "black"; - const nodeLabelFontSize = props.settings && props.settings.nodeLabelFontSize ? props.settings.nodeLabelFontSize : 3.5; - const relLabelFontSize = props.settings && props.settings.relLabelFontSize ? props.settings.relLabelFontSize : 2.75; - const styleRules = extensionEnabled(props.extensions, 'styling') && props.settings && props.settings.styleRules ? props.settings.styleRules : []; - const relLabelColor = props.settings && props.settings.relLabelColor ? props.settings.relLabelColor : "#a0a0a0"; - const nodeColorScheme = props.settings && props.settings.nodeColorScheme ? props.settings.nodeColorScheme : "neodash"; - const showPropertiesOnHover = props.settings && props.settings.showPropertiesOnHover !== undefined ? props.settings.showPropertiesOnHover : true; - const showPropertiesOnClick = props.settings && props.settings.showPropertiesOnClick !== undefined ? props.settings.showPropertiesOnClick : true; - const fixNodeAfterDrag = props.settings && props.settings.fixNodeAfterDrag !== undefined ? props.settings.fixNodeAfterDrag : true; - const layout = props.settings && props.settings.layout !== undefined ? props.settings.layout : "force-directed"; - const lockable = props.settings && props.settings.lockable !== undefined ? props.settings.lockable : true; - const drilldownLink = props.settings && props.settings.drilldownLink !== undefined ? props.settings.drilldownLink : ""; - const selfLoopRotationDegrees = 45; - const rightClickToExpandNodes = false; // TODO - this isn't working properly yet, disable it. - const defaultNodeColor = "lightgrey"; // Color of nodes without labels - const linkDirectionalParticles = props.settings && props.settings.relationshipParticles ? 5 : undefined; - const linkDirectionalParticleSpeed = props.settings && props.settings.relationshipParticleSpeed ? props.settings.relationshipParticleSpeed : 0.005; // Speed of particles on relationships. - const iconStyle = props.settings && props.settings.iconStyle !== undefined ? props.settings.iconStyle : ""; - let iconObject = undefined; - try { - iconObject = iconStyle ? JSON.parse(iconStyle) : undefined; - } catch (error) { - console.error(error); + if (valueIsArray(value)) { + value.forEach((v) => extractGraphEntitiesFromField(v)); + } else if (valueIsNode(value)) { + value.labels.forEach((l) => (nodeLabels[l] = true)); + nodes[value.identity.low] = { + id: value.identity.low, + labels: value.labels, + size: value.properties[nodeSizeProp] ? value.properties[nodeSizeProp] : defaultNodeSize, + properties: value.properties, + lastLabel: value.labels[value.labels.length - 1], + }; + if (frozen && nodePositions && nodePositions[value.identity.low]) { + nodes[value.identity.low].fx = nodePositions[value.identity.low][0]; + nodes[value.identity.low].fy = nodePositions[value.identity.low][1]; + } + } else if (valueIsRelationship(value)) { + if (links[`${value.start.low},${value.end.low}`] == undefined) { + links[`${value.start.low},${value.end.low}`] = []; + } + const addItem = (arr, item) => arr.find((x) => x.id === item.id) || arr.push(item); + addItem(links[`${value.start.low},${value.end.low}`], { + id: value.identity.low, + source: value.start.low, + target: value.end.low, + type: value.type, + width: value.properties[relWidthProp] ? value.properties[relWidthProp] : defaultRelWidth, + color: value.properties[relColorProp] ? value.properties[relColorProp] : defaultRelColor, + properties: value.properties, + }); + } else if (valueIsPath(value)) { + value.segments.map((segment) => { + extractGraphEntitiesFromField(segment.start); + extractGraphEntitiesFromField(segment.relationship); + extractGraphEntitiesFromField(segment.end); + }); } - - - // get dashboard parameters. - const parameters = props.parameters ? props.parameters : {}; - - const [data, setData] = React.useState({ nodes: [], links: [] }); - - // Create the dictionary used for storing the memory of dragged node positions. - if (props.settings.nodePositions == undefined) { - props.settings.nodePositions = {}; + } + + // Function to manually compute curvatures for dense node pairs. + function getCurvature(index, total) { + if (total <= 6) { + // Precomputed edge curvatures for nodes with multiple edges in between. + const curvatures = { + 0: 0, + 1: 0, + 2: [-0.5, 0.5], // 2 = Math.floor(1/2) + 1 + 3: [-0.5, 0, 0.5], // 2 = Math.floor(3/2) + 1 + 4: [-0.66666, -0.33333, 0.33333, 0.66666], // 3 = Math.floor(4/2) + 1 + 5: [-0.66666, -0.33333, 0, 0.33333, 0.66666], // 3 = Math.floor(5/2) + 1 + 6: [-0.75, -0.5, -0.25, 0.25, 0.5, 0.75], // 4 = Math.floor(6/2) + 1 + 7: [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75], // 4 = Math.floor(7/2) + 1 + }; + return curvatures[total][index]; } - var nodePositions = props.settings && props.settings.nodePositions; - - // 'frozen' indicates that the graph visualization engine is paused, node positions and stored and only dragging is possible. - const [frozen, setFrozen] = React.useState(props.settings && props.settings.frozen !== undefined ? props.settings.frozen : false); - - // Currently unused, but dynamic graph exploration could be done with these records. - const [extraRecords, setExtraRecords] = React.useState([]); - - // When data is refreshed, rebuild the visualization data. - useEffect(() => { - buildVisualizationDictionaryFromRecords(props.records); - }, []) - - const { observe, unobserve, width, height, entry } = useDimensions({ - onResize: ({ observe, unobserve, width, height, entry }) => { - // Triggered whenever the size of the target is changed... - unobserve(); // To stop observing the current target element - observe(); // To re-start observing the current target element - }, + const arr1 = [...Array(Math.floor(total / 2)).keys()].map((i) => { + return (i + 1) / (Math.floor(total / 2) + 1); }); - - - // Dictionaries to populate based on query results. - var nodes = {}; - var nodeLabels = {}; - var links = {}; - var linkTypes = {}; - - // Gets all graphy objects (nodes/relationships) from the complete set of return values. - function extractGraphEntitiesFromField(value) { - if (value == undefined) { - return - } - if (valueIsArray(value)) { - value.forEach((v, i) => extractGraphEntitiesFromField(v)); - } else if (valueIsNode(value)) { - value.labels.forEach(l => nodeLabels[l] = true) - nodes[value.identity.low] = { - id: value.identity.low, - labels: value.labels, - size: value.properties[nodeSizeProp] ? value.properties[nodeSizeProp] : defaultNodeSize, - properties: value.properties, - lastLabel: value.labels[value.labels.length - 1] - }; - if (frozen && nodePositions && nodePositions[value.identity.low]) { - nodes[value.identity.low]["fx"] = nodePositions[value.identity.low][0]; - nodes[value.identity.low]["fy"] = nodePositions[value.identity.low][1]; - } - } else if (valueIsRelationship(value)) { - if (links[value.start.low + "," + value.end.low] == undefined) { - links[value.start.low + "," + value.end.low] = []; - } - const addItem = (arr, item) => arr.find((x) => x.id === item.id) || arr.push(item); - addItem(links[value.start.low + "," + value.end.low], { - id: value.identity.low, - source: value.start.low, - target: value.end.low, - type: value.type, - width: value.properties[relWidthProp] ? value.properties[relWidthProp] : defaultRelWidth, - color: value.properties[relColorProp] ? value.properties[relColorProp] : defaultRelColor, - properties: value.properties - }); - - } else if (valueIsPath(value)) { - value.segments.map((segment, i) => { - extractGraphEntitiesFromField(segment.start); - extractGraphEntitiesFromField(segment.relationship); - extractGraphEntitiesFromField(segment.end); - }); + const arr2 = total % 2 == 1 ? [0] : []; + const arr3 = [...Array(Math.floor(total / 2)).keys()].map((i) => { + return (i + 1) / -(Math.floor(total / 2) + 1); + }); + return arr1.concat(arr2).concat(arr3)[index]; + } + + function buildVisualizationDictionaryFromRecords(records) { + // Extract graph objects from result set. + records.forEach((record) => { + record._fields.forEach((field) => { + extractGraphEntitiesFromField(field); + }); + }); + // Assign proper curvatures to relationships. + // This is needed for pairs of nodes that have multiple relationships between them, or self-loops. + const linksList = Object.values(links).map((nodePair) => { + return nodePair.map((link, i) => { + if (link.source == link.target) { + // Self-loop + return update(link, { curvature: 0.4 + i / 8 }); } - } - - // Function to manually compute curvatures for dense node pairs. - function getCurvature(index, total) { - if (total <= 6) { - // Precomputed edge curvatures for nodes with multiple edges in between. - const curvatures = { - 0: 0, - 1: 0, - 2: [-0.5, 0.5], // 2 = Math.floor(1/2) + 1 - 3: [-0.5, 0, 0.5], // 2 = Math.floor(3/2) + 1 - 4: [-0.66666, -0.33333, 0.33333, 0.66666], // 3 = Math.floor(4/2) + 1 - 5: [-0.66666, -0.33333, 0, 0.33333, 0.66666], // 3 = Math.floor(5/2) + 1 - 6: [-0.75, -0.5, -0.25, 0.25, 0.5, 0.75], // 4 = Math.floor(6/2) + 1 - 7: [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75], // 4 = Math.floor(7/2) + 1 - } - return curvatures[total][index]; + // If we also have edges from the target to the source, adjust curvatures accordingly. + const mirroredNodePair = links[`${link.target},${link.source}`]; + if (!mirroredNodePair) { + return update(link, { curvature: getCurvature(i, nodePair.length) }); } - const arr1 = [...Array(Math.floor(total / 2)).keys()].map(i => { - return (i + 1) / (Math.floor(total / 2) + 1) - }) - const arr2 = (total % 2 == 1) ? [0] : []; - const arr3 = [...Array(Math.floor(total / 2)).keys()].map(i => { - return (i + 1) / -(Math.floor(total / 2) + 1) - }) - return arr1.concat(arr2).concat(arr3)[index]; - } - - function buildVisualizationDictionaryFromRecords(records) { - // Extract graph objects from result set. - records.forEach((record, rownumber) => { - record._fields.forEach((field, i) => { - extractGraphEntitiesFromField(field); - }) - }); - // Assign proper curvatures to relationships. - // This is needed for pairs of nodes that have multiple relationships between them, or self-loops. - const linksList = Object.values(links).map(nodePair => { - return nodePair.map((link, i) => { - if (link.source == link.target) { - // Self-loop - return update(link, { curvature: 0.4 + (i) / 8 }); - } else { - // If we also have edges from the target to the source, adjust curvatures accordingly. - const mirroredNodePair = links[link.target + "," + link.source]; - if (!mirroredNodePair) { - return update(link, { curvature: getCurvature(i, nodePair.length) }); - } else { - return update(link, { - curvature: (link.source > link.target ? 1 : -1) * - getCurvature(link.source > link.target ? i : i + mirroredNodePair.length, - nodePair.length + mirroredNodePair.length) - }); - } - } - }); + return update(link, { + curvature: + (link.source > link.target ? 1 : -1) * + getCurvature( + link.source > link.target ? i : i + mirroredNodePair.length, + nodePair.length + mirroredNodePair.length + ), }); + }); + }); - // Assign proper colors to nodes. - const totalColors = categoricalColorSchemes[nodeColorScheme] ? categoricalColorSchemes[nodeColorScheme].length : 0; - const nodeLabelsList = Object.keys(nodeLabels); - const nodesList = Object.values(nodes).map(node => { - // First try to assign a node a color if it has a property specifying the color. - var assignedColor = node.properties[nodeColorProp] ? node.properties[nodeColorProp] : - (totalColors > 0 ? categoricalColorSchemes[nodeColorScheme][nodeLabelsList.indexOf(node.lastLabel) % totalColors] : "grey"); - // Next, evaluate the custom styling rules to see if there's a rule-based override - assignedColor = evaluateRulesOnNode(node, 'node color', assignedColor, styleRules); - return update(node, { color: assignedColor ? assignedColor : defaultNodeColor }); - }); + // Assign proper colors to nodes. + const totalColors = categoricalColorSchemes[nodeColorScheme] ? categoricalColorSchemes[nodeColorScheme].length : 0; + const nodeLabelsList = Object.keys(nodeLabels); + const nodesList = Object.values(nodes).map((node) => { + // First try to assign a node a color if it has a property specifying the color. + let assignedColor = node.properties[nodeColorProp] + ? node.properties[nodeColorProp] + : totalColors > 0 + ? categoricalColorSchemes[nodeColorScheme][nodeLabelsList.indexOf(node.lastLabel) % totalColors] + : 'grey'; + // Next, evaluate the custom styling rules to see if there's a rule-based override + assignedColor = evaluateRulesOnNode(node, 'node color', assignedColor, styleRules); + return update(node, { color: assignedColor ? assignedColor : defaultNodeColor }); + }); - // Set the data dictionary that is read by the visualization. - setData({ - nodes: nodesList, - links: linksList.flat() - }); - } + // Set the data dictionary that is read by the visualization. + setData({ + nodes: nodesList, + links: linksList.flat(), + }); + } - // Replaces all global dashboard parameters inside a string with their values. - function replaceDashboardParameters(str) { - Object.keys(parameters).forEach(key => { - str = str.replaceAll("$"+key, parameters[key]); - }); - return str; + // Replaces all global dashboard parameters inside a string with their values. + function replaceDashboardParameters(str) { + Object.keys(parameters).forEach((key) => { + str = str.replaceAll(`$${key}`, parameters[key]); + }); + return str; + } + + // Generates tooltips when hovering on nodes/relationships. + const generateTooltip = (value) => { + const tooltip = ( + + + {value.labels ? (value.labels.length > 0 ? value.labels.join(', ') : 'Node') : value.type} + + + {Object.keys(value.properties).length == 0 ? ( + +
+ (No properties) +
+ ) : ( + + + + {Object.keys(value.properties) + .sort() + .map((key) => ( + + + {key} + + + {value.properties[key].toString().length <= 30 + ? value.properties[key].toString() + : `${value.properties[key].toString().substring(0, 40)}...`} + + + ))} + +
+
+ )} +
+ ); + return ReactDOMServer.renderToString(tooltip); + }; + + const renderNodeLabel = (node) => { + const selectedProp = props.selection && props.selection[node.lastLabel]; + if (selectedProp == '(id)') { + return node.id; } - - - // Generates tooltips when hovering on nodes/relationships. - const generateTooltip = (value) => { - const tooltip = - - - {value.labels ? (value.labels.length > 0 ? value.labels.join(", ") : "Node") : value.type} - - - {Object.keys(value.properties).length == 0 ? -
(No properties)
: - - - - {Object.keys(value.properties).sort().map((key) => ( - - - {key} - - - {(value.properties[key].toString().length <= 30) ? - value.properties[key].toString() : - value.properties[key].toString().substring(0, 40) + "..."} - - - ))} - -
-
} -
; - return ReactDOMServer.renderToString(tooltip); + if (selectedProp == '(label)') { + return node.labels; } - - const renderNodeLabel = (node) => { - const selectedProp = props.selection && props.selection[node.lastLabel]; - if (selectedProp == "(id)") { - return node.id; - } - if (selectedProp == "(label)") { - return node.labels; - } - if (selectedProp == "(no label)") { - return ""; - } - return node.properties[selectedProp] ? node.properties[selectedProp] : ""; + if (selectedProp == '(no label)') { + return ''; } + return node.properties[selectedProp] ? node.properties[selectedProp] : ''; + }; + + // TODO - implement this. + const handleExpand = useCallback((node) => { + if (rightClickToExpandNodes) { + props.queryCallback && + props.queryCallback(`MATCH (n)-[e]-(m) WHERE id(n) =${node.id} RETURN e,m`, {}, setExtraRecords); + } + }, []); - // TODO - implement this. - const handleExpand = useCallback(node => { - if (rightClickToExpandNodes) { - props.queryCallback && props.queryCallback("MATCH (n)-[e]-(m) WHERE id(n) =" + node.id + " RETURN e,m", {}, setExtraRecords); - } - }, []); - - const showPopup = useCallback(item => { - if (showPropertiesOnClick) { - setInspectItem(item); - handleOpen(); - } - }, []); - - const showPopup2 = useCallback(item => { - if (showPropertiesOnClick) { - setInspectItem(item); - handleOpen(); - } - }, []); - - // If the set of extra records gets updated (e.g. on relationship expand), rebuild the graph. - useEffect(() => { - buildVisualizationDictionaryFromRecords(props.records.concat(extraRecords)); - }, [extraRecords]) - - const { useRef } = React; - - // Return the actual graph visualization component with the parsed data and selected customizations. - const fgRef = useRef(); - return <> -
- - { - fgRef.current.zoomToFit(400) - }} style={{ fontSize: "1.3rem", opacity: 0.6, bottom: 11, right: 34, position: "absolute", zIndex: 5 }} color="disabled" fontSize="small"> - - {lockable ? (frozen ? - - { - setFrozen(false); - if (props.settings) { - props.settings.frozen = false; - } - }} style={{ fontSize: "1.3rem", opacity: 0.6, bottom: 12, right: 12, position: "absolute", zIndex: 5 }} color="disabled" fontSize="small"> - - : - - { - if (nodePositions == undefined) { - nodePositions = {}; - } - setFrozen(true); - if (props.settings) { - props.settings.frozen = true; - } - }} style={{ fontSize: "1.3rem", opacity: 0.6, bottom: 12, right: 12, position: "absolute", zIndex: 5 }} color="disabled" fontSize="small"> - - ) : <>} - {drilldownLink !== "" ? - - - - - - - : <>} - - link.width} - linkLabel={link => showPropertiesOnHover ? `
${generateTooltip(link)}
` : ""} - nodeLabel={node => showPropertiesOnHover ? `
${generateTooltip(node)}
` : ""} - nodeVal={node => node.size} - onNodeClick={showPopup} - // nodeThreeObject = {nodeTree} - onLinkClick={showPopup} - onNodeRightClick={handleExpand} - linkDirectionalParticles={linkDirectionalParticles} - linkDirectionalParticleSpeed={linkDirectionalParticleSpeed} - cooldownTicks={100} - onEngineStop={() => { - if (firstRun) { - fgRef.current.zoomToFit(400); - setFirstRun(false); - } - }} - onNodeDragEnd={node => { - if (fixNodeAfterDrag) { - node.fx = node.x; - node.fy = node.y; - } - if (frozen) { - if (nodePositions == undefined) { - nodePositions = {}; - } - nodePositions["" + node.id] = [node.x, node.y]; - } - }} - nodeCanvasObjectMode={() => "after"} - nodeCanvasObject={(node, ctx, globalScale) => { - if (iconObject && iconObject[node.lastLabel]) - drawDataURIOnCanvas(node, iconObject[node.lastLabel],ctx, defaultNodeSize); - else { - const label = (props.selection && props.selection[node.lastLabel]) ? renderNodeLabel(node) : ""; - const fontSize = nodeLabelFontSize; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.fillStyle = evaluateRulesOnNode(node, "node label color", nodeLabelColor, styleRules); - ctx.textAlign = "center"; - ctx.fillText(label, node.x, node.y + 1); - if (frozen && !node.fx && !node.fy && nodePositions) { - node.fx = node.x; - node.fy = node.y; - nodePositions["" + node.id] = [node.x, node.y]; - } - if (!frozen && node.fx && node.fy && nodePositions && nodePositions[node.id]) { - nodePositions[node.id] = undefined; - node.fx = undefined; - node.fy = undefined; - } - } + const showPopup = useCallback((item) => { + if (showPropertiesOnClick) { + setInspectItem(item); + handleOpen(); + } + }, []); + + // If the set of extra records gets updated (e.g. on relationship expand), rebuild the graph. + useEffect(() => { + buildVisualizationDictionaryFromRecords(props.records.concat(extraRecords)); + }, [extraRecords]); + + // Return the actual graph visualization component with the parsed data and selected customizations. + const fgRef = useRef(); + return ( + <> +
+ + { + fgRef.current.zoomToFit(400); + }} + style={{ fontSize: '1.3rem', opacity: 0.6, bottom: 11, right: 34, position: 'absolute', zIndex: 5 }} + color='disabled' + fontSize='small' + > + + {lockable ? ( + frozen ? ( + + { + setFrozen(false); + if (props.settings) { + props.settings.frozen = false; + } }} - linkCanvasObjectMode={() => "after"} - linkCanvasObject={(link, ctx, globalScale) => { - const label = link.properties.name || link.type || link.id; - const fontSize = relLabelFontSize; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.fillStyle = relLabelColor; - if (link.target != link.source) { - const lenX = (link.target.x - link.source.x); - const lenY = (link.target.y - link.source.y); - const posX = link.target.x - lenX / 2; - const posY = link.target.y - lenY / 2; - const length = Math.sqrt(lenX * lenX + lenY * lenY) - const angle = Math.atan(lenY / lenX) - ctx.save(); - ctx.translate(posX, posY); - ctx.rotate(angle); - // Mirrors the curvatures when the label is upside down. - const mirror = (link.source.x > link.target.x) ? 1 : -1; - ctx.textAlign = "center"; - if (link.curvature) { - ctx.fillText(label, 0, mirror * length * link.curvature * 0.5); - } else { - ctx.fillText(label, 0, 0); - } - ctx.restore(); - } else { - ctx.save(); - ctx.translate(link.source.x, link.source.y); - ctx.rotate(Math.PI * selfLoopRotationDegrees / 180); - ctx.textAlign = "center"; - ctx.fillText(label, 0, -18.7 + -37.1 * (link.curvature - 0.5)); - ctx.restore(); - } + style={{ fontSize: '1.3rem', opacity: 0.6, bottom: 12, right: 12, position: 'absolute', zIndex: 5 }} + color='disabled' + fontSize='small' + > + + ) : ( + + { + if (nodePositions == undefined) { + nodePositions = {}; + } + setFrozen(true); + if (props.settings) { + props.settings.frozen = true; + } }} - graphData={width ? data : { nodes: [], links: [] }} - /> - - -
+ style={{ fontSize: '1.3rem', opacity: 0.6, bottom: 12, right: 12, position: 'absolute', zIndex: 5 }} + color='disabled' + fontSize='small' + > + + ) + ) : ( + <> + )} + {drilldownLink !== '' ? ( + + + + + + + + ) : ( + <> + )} + + link.width} + linkLabel={(link) => (showPropertiesOnHover ? `
${generateTooltip(link)}
` : '')} + nodeLabel={(node) => (showPropertiesOnHover ? `
${generateTooltip(node)}
` : '')} + nodeVal={(node) => node.size} + onNodeClick={showPopup} + // nodeThreeObject = {nodeTree} + onLinkClick={showPopup} + onNodeRightClick={handleExpand} + linkDirectionalParticles={linkDirectionalParticles} + linkDirectionalParticleSpeed={linkDirectionalParticleSpeed} + cooldownTicks={100} + onEngineStop={() => { + if (firstRun) { + fgRef.current.zoomToFit(400); + setFirstRun(false); + } + }} + onNodeDragEnd={(node) => { + if (fixNodeAfterDrag) { + node.fx = node.x; + node.fy = node.y; + } + if (frozen) { + if (nodePositions == undefined) { + nodePositions = {}; + } + nodePositions[`${node.id}`] = [node.x, node.y]; + } + }} + nodeCanvasObjectMode={() => 'after'} + nodeCanvasObject={(node, ctx) => { + if (iconObject && iconObject[node.lastLabel]) { + drawDataURIOnCanvas(node, iconObject[node.lastLabel], ctx, defaultNodeSize); + } else { + const label = props.selection && props.selection[node.lastLabel] ? renderNodeLabel(node) : ''; + const fontSize = nodeLabelFontSize; + ctx.font = `${fontSize}px Sans-Serif`; + ctx.fillStyle = evaluateRulesOnNode(node, 'node label color', nodeLabelColor, styleRules); + ctx.textAlign = 'center'; + ctx.fillText(label, node.x, node.y + 1); + if (frozen && !node.fx && !node.fy && nodePositions) { + node.fx = node.x; + node.fy = node.y; + nodePositions[`${node.id}`] = [node.x, node.y]; + } + if (!frozen && node.fx && node.fy && nodePositions && nodePositions[node.id]) { + nodePositions[node.id] = undefined; + node.fx = undefined; + node.fy = undefined; + } + } + }} + linkCanvasObjectMode={() => 'after'} + linkCanvasObject={(link, ctx) => { + const label = link.properties.name || link.type || link.id; + const fontSize = relLabelFontSize; + ctx.font = `${fontSize}px Sans-Serif`; + ctx.fillStyle = relLabelColor; + if (link.target != link.source) { + const lenX = link.target.x - link.source.x; + const lenY = link.target.y - link.source.y; + const posX = link.target.x - lenX / 2; + const posY = link.target.y - lenY / 2; + const length = Math.sqrt(lenX * lenX + lenY * lenY); + const angle = Math.atan(lenY / lenX); + ctx.save(); + ctx.translate(posX, posY); + ctx.rotate(angle); + // Mirrors the curvatures when the label is upside down. + const mirror = link.source.x > link.target.x ? 1 : -1; + ctx.textAlign = 'center'; + if (link.curvature) { + ctx.fillText(label, 0, mirror * length * link.curvature * 0.5); + } else { + ctx.fillText(label, 0, 0); + } + ctx.restore(); + } else { + ctx.save(); + ctx.translate(link.source.x, link.source.y); + ctx.rotate((Math.PI * selfLoopRotationDegrees) / 180); + ctx.textAlign = 'center'; + ctx.fillText(label, 0, -18.7 + -37.1 * (link.curvature - 0.5)); + ctx.restore(); + } + }} + graphData={width ? data : { nodes: [], links: [] }} + /> + + +
-} + ); +}; export default NeoGraphChart; diff --git a/src/chart/iframe/IFrameChart.tsx b/src/chart/iframe/IFrameChart.tsx index ba07d5803..51406abc6 100644 --- a/src/chart/iframe/IFrameChart.tsx +++ b/src/chart/iframe/IFrameChart.tsx @@ -1,4 +1,3 @@ - import React from 'react'; import { ChartProps } from '../Chart'; import { replaceDashboardParameters } from '../ChartUtils'; @@ -7,21 +6,38 @@ import { replaceDashboardParameters } from '../ChartUtils'; * Renders an iFrame of the URL provided by the user. */ const NeoIFrameChart = (props: ChartProps) => { - // Records are overridden to be a single element array with a field called 'input'. - const records = props.records; - const parameters = props.parameters ? props.parameters : {}; - const passGlobalParameters = props.settings && props.settings.passGlobalParameters ? props.settings.passGlobalParameters : false; - const replaceGlobalParameters = props.settings && props.settings.replaceGlobalParameters !== undefined ? props.settings.replaceGlobalParameters : true; - const url = records[0]["input"].trim(); - const mapParameters = records[0]["parameters"] || {}; - const queryString = Object.keys(mapParameters).map(key => key + '=' + mapParameters[key]).join('&'); - const modifiedUrl = (replaceGlobalParameters ? replaceDashboardParameters(url, parameters) : url) + (passGlobalParameters ? "#" + queryString : ""); - - if (!modifiedUrl || !(modifiedUrl.startsWith("http://") || modifiedUrl.startsWith("https://"))) { - return

Invalid iFrame URL. Make sure your url starts with http:// or https://.

- } + // Records are overridden to be a single element array with a field called 'input'. + const { records } = props; + const parameters = props.parameters ? props.parameters : {}; + const passGlobalParameters = + props.settings && props.settings.passGlobalParameters ? props.settings.passGlobalParameters : false; + const replaceGlobalParameters = + props.settings && props.settings.replaceGlobalParameters !== undefined + ? props.settings.replaceGlobalParameters + : true; + const url = records[0].input.trim(); + const mapParameters = records[0].parameters || {}; + const queryString = Object.keys(mapParameters) + .map((key) => `${key}=${mapParameters[key]}`) + .join('&'); + const modifiedUrl = + (replaceGlobalParameters ? replaceDashboardParameters(url, parameters) : url) + + (passGlobalParameters ? `#${queryString}` : ''); + + if (!modifiedUrl || !(modifiedUrl.startsWith('http://') || modifiedUrl.startsWith('https://'))) { + return ( +

+ Invalid iFrame URL. Make sure your url starts with http:// or https://. +

+ ); + } - return