├── .circleci
├── config.yml
└── deployment-workflow.yml
├── .devcontainer
└── devcontainer.json
├── .dockerignore
├── .editorconfig
├── .eslintignore
├── .eslintrc.json
├── .github
└── CODEOWNERS
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── .vscode
├── extensions.json
└── settings.json
├── CONTRIBUTORS.md
├── Dockerfile
├── LICENSE.md
├── README.md
├── build_and_start_nginx.sh
├── cypress.config.js
├── default.template
├── docker_push.sh
├── jest.config.js
├── package-lock.json
├── package.json
├── run_e2e_tests.sh
├── spec
├── config-spec.js
├── end_to_end_tests
│ ├── config.json
│ ├── pageObjects
│ │ ├── base_page.js
│ │ ├── byor_page.js
│ │ └── radar_page.js
│ ├── resources
│ │ ├── data.json
│ │ ├── localfiles
│ │ │ ├── radar.csv
│ │ │ └── radar.json
│ │ └── sheet.csv
│ └── specs
│ │ └── end_to_end.cy.js
├── exceptions
│ ├── fileNotFoundError-spec.js
│ ├── invalidConfigError-spec.js
│ └── invalidContentError-spec.js
├── graphing
│ ├── blips-spec.js
│ ├── components
│ │ └── quadrants-spec.js
│ └── config-spec.js
├── helpers
│ └── jsdom.js
├── models
│ ├── blip-spec.js
│ ├── quadrant-spec.js
│ ├── radar-spec.js
│ └── ring-spec.js
├── support
│ └── jasmine.json
└── util
│ ├── contentValidator-spec.js
│ ├── htmlUtil-spec.js
│ ├── inputSanitizer-spec.js
│ ├── mathUtils-spec.js
│ ├── queryParamProcessor-spec.js
│ ├── ringCalculator-spec.js
│ ├── sheet-spec.js
│ ├── urlUtils-spec.js
│ └── util-spec.js
├── src
├── analytics.js
├── common.js
├── config.js
├── error.html
├── exceptions
│ ├── fileNotFoundError.js
│ ├── invalidConfigError.js
│ ├── invalidContentError.js
│ ├── malformedDataError.js
│ ├── sheetNotFoundError.js
│ └── unauthorizedError.js
├── graphing
│ ├── blips.js
│ ├── components
│ │ ├── alternativeRadars.js
│ │ ├── banner.js
│ │ ├── buttons.js
│ │ ├── quadrantSubnav.js
│ │ ├── quadrantTables.js
│ │ ├── quadrants.js
│ │ └── search.js
│ ├── config.js
│ ├── pdfPage.js
│ └── radar.js
├── images
│ ├── arrow-icon.svg
│ ├── arrow-white-icon.svg
│ ├── banner-image-desktop.jpg
│ ├── banner-image-mobile.jpg
│ ├── existing.svg
│ ├── favicon.ico
│ ├── first-quadrant-btn-bg.svg
│ ├── fourth-quadrant-btn-bg.svg
│ ├── logo.png
│ ├── moved.svg
│ ├── new.svg
│ ├── no-change.svg
│ ├── pdf_banner.png
│ ├── radar_legend.png
│ ├── search-active-wave.svg
│ ├── search-logo-2x.svg
│ ├── second-quadrant-btn-bg.svg
│ ├── tech-radar-landing-page-wide.png
│ ├── third-quadrant-btn-bg.svg
│ └── tw-logo.png
├── index.html
├── models
│ ├── blip.js
│ ├── quadrant.js
│ ├── radar.js
│ └── ring.js
├── site.js
├── stylesheets
│ ├── _alternativeradars.scss
│ ├── _buttons.scss
│ ├── _colors.scss
│ ├── _error.scss
│ ├── _fonts.scss
│ ├── _footer.scss
│ ├── _form.scss
│ ├── _header.scss
│ ├── _herobanner.scss
│ ├── _landingpage.scss
│ ├── _layout.scss
│ ├── _loader.scss
│ ├── _mediaqueries.scss
│ ├── _pdfPage.scss
│ ├── _quadrantTables.scss
│ ├── _quadrants.scss
│ ├── _quadrantsubnav.scss
│ ├── _screen.css
│ ├── _search.scss
│ ├── _tip.scss
│ └── base.scss
└── util
│ ├── autoComplete.js
│ ├── contentValidator.js
│ ├── exceptionMessages.js
│ ├── factory.js
│ ├── googleAuth.js
│ ├── htmlUtil.js
│ ├── inputSanitizer.js
│ ├── mathUtils.js
│ ├── queryParamProcessor.js
│ ├── ringCalculator.js
│ ├── sheet.js
│ ├── stringUtil.js
│ └── urlUtils.js
├── webpack.common.js
├── webpack.dev.js
└── webpack.prod.js
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | setup: true
4 |
5 | orbs:
6 | continuation: circleci/continuation@0.2.0
7 |
8 | executors:
9 | base:
10 | docker:
11 | - image: cimg/node:18.12.1
12 | user: root
13 |
14 | commands:
15 | install-node-packages:
16 | description: Install node packages
17 | steps:
18 | - restore_cache:
19 | key: node-cache-v2-{{ checksum "package-lock.json" }}
20 | - run:
21 | name: Install node packages
22 | command: npm install
23 | - save_cache:
24 | paths:
25 | - ./node_modules
26 | key: node-cache-v2-{{ checksum "package-lock.json" }}
27 |
28 | jobs:
29 | tests:
30 | executor: base
31 | steps:
32 | - checkout
33 | - install-node-packages
34 | - run:
35 | name: Run linter and unit tests with coverage
36 | command: npm run quality
37 | check-for-pr:
38 | executor: base
39 | steps:
40 | - checkout
41 | - run: |
42 | if [[ $CIRCLE_PULL_REQUEST ]]; then
43 | circleci-agent step halt
44 | fi
45 | - continuation/continue:
46 | configuration_path: .circleci/deployment-workflow.yml
47 | docker-push:
48 | executor: base
49 | steps:
50 | - checkout
51 | - setup_remote_docker:
52 | version: 20.10.12
53 | docker_layer_caching: true
54 | - run:
55 | name: Build and push Docker image to DockerHub
56 | command: ./docker_push.sh
57 |
58 | workflows:
59 | main:
60 | jobs:
61 | - tests
62 | - check-for-pr:
63 | requires:
64 | - tests
65 | - approve-docker-push:
66 | type: approval
67 | filters:
68 | tags:
69 | only: /^v.*/
70 | branches:
71 | ignore: /.*/
72 | - docker-push:
73 | requires:
74 | - approve-docker-push
75 | filters:
76 | tags:
77 | only: /^v.*/
78 | branches:
79 | ignore: /.*/
80 |
--------------------------------------------------------------------------------
/.circleci/deployment-workflow.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | orbs:
4 | aws-cli: circleci/aws-cli@4.1.1
5 | browser-tools: circleci/browser-tools@1.4.6
6 |
7 | executors:
8 | base:
9 | docker:
10 | - image: cimg/node:18.18.2-browsers
11 | user: root
12 |
13 | commands:
14 | install-node-packages:
15 | description: Install node packages
16 | steps:
17 | - restore_cache:
18 | key: node-cache-v2-{{ checksum "package-lock.json" }}
19 | - run:
20 | name: Install node packages
21 | command: npm install
22 | - save_cache:
23 | paths:
24 | - ./node_modules
25 | key: node-cache-v2-{{ checksum "package-lock.json" }}
26 | install-node-and-cypress-packages:
27 | description: Install node packages and Cypress dependencies
28 | steps:
29 | - restore_cache:
30 | key: node-cache-with-cypress-v1-{{ checksum "package-lock.json" }}
31 | - run:
32 | name: Install Cypress dependencies
33 | command: |
34 | apt-get update
35 | apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb
36 | - run:
37 | name: Install node packages
38 | command: npm install
39 | - save_cache:
40 | paths:
41 | - ./node_modules
42 | - ~/.cache/Cypress
43 | key: node-cache-with-cypress-v1-{{ checksum "package-lock.json" }}
44 | build-and-push:
45 | description: Build and push code to S3 bucket
46 | parameters:
47 | api_key:
48 | type: string
49 | default: ''
50 | client_id:
51 | type: string
52 | default: ''
53 | gtm_id:
54 | type: string
55 | default: ''
56 | adobe_launch_script_url:
57 | type: string
58 | default: ''
59 | bucket_name:
60 | type: string
61 | default: ''
62 | distribution_id:
63 | type: string
64 | default: ''
65 | env:
66 | type: string
67 | default: dev
68 | steps:
69 | - run:
70 | name: Build code
71 | command: API_KEY=<< parameters.api_key >> CLIENT_ID=<< parameters.client_id >> GTM_ID=<< parameters.gtm_id >> ADOBE_LAUNCH_SCRIPT_URL=<< parameters.adobe_launch_script_url >> npm run build:<< parameters.env >>
72 | - when:
73 | condition:
74 | and:
75 | - equal: [<< parameters.env >>, 'prod']
76 | steps:
77 | - run:
78 | name: Set PROD specific env variables
79 | command: |
80 | echo 'export AWS_BYOR_OIDC_ROLE=$AWS_BYOR_OIDC_ROLE_PROD' >> $BASH_ENV
81 | - aws-cli/setup:
82 | role_arn: $AWS_BYOR_OIDC_ROLE
83 | session_duration: '900'
84 | - run:
85 | name: Sync build artifacts to S3
86 | command: aws s3 cp --recursive dist "s3://<< parameters.bucket_name >>/"
87 | - run:
88 | name: Create invalidation in CloudFront
89 | command: aws cloudfront create-invalidation --distribution-id << parameters.distribution_id >> --paths '/*'
90 |
91 | jobs:
92 | e2e-tests:
93 | executor: base
94 | steps:
95 | - checkout
96 | - install-node-and-cypress-packages
97 | - browser-tools/install-chrome
98 | - run:
99 | name: Run e2e test cases
100 | command: API_KEY=$LOCAL_API_KEY ./run_e2e_tests.sh $LOCAL_TEST_URL
101 | qa-deployment:
102 | executor: base
103 | steps:
104 | - checkout
105 | - install-node-packages
106 | - build-and-push:
107 | api_key: $QA_API_KEY
108 | client_id: $QA_CLIENT_ID
109 | gtm_id: $QA_GTM_ID
110 | adobe_launch_script_url: $QA_ADOBE_LAUNCH_SCRIPT_URL
111 | bucket_name: $QA_BUCKET_NAME
112 | distribution_id: $QA_DISTRIBUTION_ID
113 | env: dev
114 | prod-deployment:
115 | executor: base
116 | steps:
117 | - checkout
118 | - install-node-packages
119 | - build-and-push:
120 | api_key: $PROD_API_KEY
121 | client_id: $PROD_CLIENT_ID
122 | gtm_id: $PROD_GTM_ID
123 | adobe_launch_script_url: $PROD_ADOBE_LAUNCH_SCRIPT_URL
124 | bucket_name: $PROD_BUCKET_NAME
125 | distribution_id: $PROD_DISTRIBUTION_ID
126 | env: prod
127 |
128 | workflows:
129 | build-and-deploy:
130 | jobs:
131 | - e2e-tests:
132 | filters:
133 | branches:
134 | only: master
135 | - qa-deployment:
136 | requires:
137 | - e2e-tests
138 | - approve-prod-deployment:
139 | type: approval
140 | requires:
141 | - qa-deployment
142 | - prod-deployment:
143 | requires:
144 | - approve-prod-deployment
145 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/docker-existing-dockerfile
3 | {
4 | "name": "Existing Dockerfile",
5 | // Sets the run context to one level up instead of the .devcontainer folder.
6 | "context": "..",
7 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
8 | "dockerFile": "../Dockerfile",
9 | // Set *default* container specific settings.json values on container create.
10 | "settings": {},
11 | // Add the IDs of extensions you want installed when the container is created.
12 | "extensions": []
13 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
14 | // "forwardPorts": [],
15 | // Uncomment the next line to run commands after the container is created - for example installing curl.
16 | // "postCreateCommand": "apt-get update && apt-get install -y curl",
17 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust
18 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
19 | // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker.
20 | // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ],
21 | // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
22 | // "remoteUser": "vscode"
23 | }
24 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # testing
7 | coverage
8 |
9 | # production
10 | build
11 | dist
12 |
13 | # misc
14 | .DS_Store
15 |
16 | npm-debug.log*
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | bower_components
3 | dist
4 | *.swa
5 | *.swp
6 | *.swo
7 |
8 | coverage/
9 |
10 | .idea/
11 | .vscode/
12 | .DS_Store
13 |
14 | spec/end_to_end_tests/reports/
15 | cypress/
16 |
17 | .nyc_output
18 |
19 | .private.notes
20 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true,
4 | "es2021": true,
5 | "jest": true,
6 | "cypress/globals": true
7 | },
8 | "extends": ["eslint:recommended", "prettier", "plugin:jest/recommended"],
9 | "parserOptions": {
10 | "ecmaVersion": "latest"
11 | },
12 | "rules": {
13 | "cypress/no-assigning-return-values": "error",
14 | "cypress/no-unnecessary-waiting": "error",
15 | "cypress/assertion-before-screenshot": "warn",
16 | "cypress/no-force": "warn",
17 | "cypress/no-async-tests": "error",
18 | "cypress/no-pause": "error",
19 | "jest/no-disabled-tests": "warn",
20 | "jest/no-focused-tests": "error",
21 | "jest/no-identical-title": "off",
22 | "jest/prefer-to-have-length": "warn",
23 | "jest/valid-expect": "error",
24 | "jest/expect-expect": "off"
25 | },
26 | "globals": {
27 | "jest": true
28 | },
29 | "plugins": ["jest", "cypress"]
30 | }
31 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @thoughtworks/tw-digital @will-amaral
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | bower_components
3 | dist
4 | *.swa
5 | *.swp
6 | *.swo
7 |
8 | coverage/
9 |
10 | .idea/
11 | .DS_Store
12 |
13 | spec/end_to_end_tests/reports/
14 | src/stylesheets/_featuretoggles.scss
15 | cypress/
16 |
17 | .nyc_output
18 |
19 | .private.notes
20 |
21 | .talismanrc
22 |
23 | .vscode/
24 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v18
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | LICENSE.md
2 | coverage/
3 | dist/
4 | node_modules/
5 | .vscode/
6 | .idea/
7 | src/stylesheets/_screen.css
8 | spec/end_to_end_tests/reports
9 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "trailingComma": "all",
6 | "printWidth": 120
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "orta.vscode-jest",
6 | "editorconfig.editorconfig"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[javascript]": {
3 | "editor.defaultFormatter": "esbenp.prettier-vscode"
4 | },
5 | "[json]": {
6 | "editor.defaultFormatter": "esbenp.prettier-vscode"
7 | },
8 | "prettier.enable": true,
9 | "prettier.prettierPath": "./node_modules/prettier",
10 | "editor.formatOnSave": true,
11 | "editor.codeActionsOnSave": {
12 | "source.fixAll.eslint": true
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/CONTRIBUTORS.md:
--------------------------------------------------------------------------------
1 | ## BYOR Contributors
2 |
3 | In alphabetic order:
4 |
5 | - [@andyw8](https://github.com/andyw8)
6 |
7 | - [@aschonfeld](https://github.com/aschonfeld)
8 |
9 | - [@br00k](https://github.com/br00k)
10 |
11 | - [@BRUNNEL6](https://github.com/BRUNNEL6)
12 |
13 | - [@camiloribeiro](https://github.com/camiloribeiro)
14 |
15 | - [@filipesabella](https://github.com/filipesabella)
16 |
17 | - [@hkurosawa](https://github.com/hkurosawa)
18 |
19 | - [@jaiganeshg](https://github.com/jaiganeshg)
20 |
21 | - [@kylec32](https://github.com/kylec32)
22 |
23 | - [@lauraionescu](https://github.com/lauraionescu)
24 |
25 | - [@setchy](https://github.com/setchy)
26 |
27 | - [@shahadarsh](https://github.com/shahadarsh)
28 |
29 | - [@thenano](https://github.com/thenano)
30 |
31 | - [@trecenti](https://github.com/trecenti)
32 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:1.23.0
2 |
3 | RUN apt-get update && apt-get upgrade -y
4 |
5 | RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
6 | RUN apt-get install -y nodejs
7 |
8 | RUN \
9 | apt-get install -y \
10 | libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 \
11 | libxss1 libasound2 libxtst6 xauth xvfb g++ make
12 |
13 | WORKDIR /src/build-your-own-radar
14 | COPY package.json ./
15 | COPY package-lock.json ./
16 | RUN npm ci
17 |
18 | COPY . ./
19 |
20 | # Override parent node image's entrypoint script (/usr/local/bin/docker-entrypoint.sh),
21 | # which tries to run CMD as a node command
22 | ENTRYPOINT []
23 | CMD ["./build_and_start_nginx.sh"]
24 |
--------------------------------------------------------------------------------
/build_and_start_nginx.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | cd /src/build-your-own-radar
5 |
6 | echo "Starting webpack build..."
7 | npm run build:prod
8 |
9 | echo "Copying built files to nginx directories..."
10 | mkdir -p /opt/build-your-own-radar
11 | cd /opt/build-your-own-radar
12 | cp -r /src/build-your-own-radar/dist/* ./
13 | mkdir -p files
14 | cp /src/build-your-own-radar/spec/end_to_end_tests/resources/localfiles/* ./files/
15 | cp /src/build-your-own-radar/default.template /etc/nginx/conf.d/default.conf
16 |
17 | echo "Starting nginx server..."
18 | exec nginx -g 'daemon off;'
19 |
--------------------------------------------------------------------------------
/cypress.config.js:
--------------------------------------------------------------------------------
1 | const { defineConfig } = require('cypress')
2 |
3 | module.exports = defineConfig({
4 | chromeWebSecurity: false,
5 | screenshotsFolder: 'spec/end_to_end_tests/reports/screenshots',
6 | reporter: 'mochawesome',
7 | reporterOptions: {
8 | reportDir: 'spec/end_to_end_tests/reports',
9 | reportFilename: 'results',
10 | quiet: 'true',
11 | json: 'false',
12 | },
13 | watchForFileChanges: false,
14 | defaultCommandTimeout: 30000,
15 | pageLoadTimeout: 60000,
16 | viewportHeight: 900,
17 | viewportWidth: 1440,
18 | e2e: {
19 | specPattern: 'spec/end_to_end_tests/specs/**/*.cy.{js,jsx,ts,tsx}',
20 | supportFile: false,
21 | },
22 | video: false,
23 | })
24 |
--------------------------------------------------------------------------------
/default.template:
--------------------------------------------------------------------------------
1 | # server block for static file serving
2 | server {
3 | listen 80;
4 | location / {
5 | root /opt/build-your-own-radar;
6 | index index.html;
7 | }
8 |
9 | # nginx default error page for 50x errors
10 | error_page 500 502 503 504 /50x.html;
11 | location = /50x.html {
12 | root /usr/share/nginx/html;
13 | }
14 |
15 | location ~ /files/ {
16 | root /opt/build-your-own-radar;
17 | autoindex on;
18 | default_type text/plain;
19 | add_header 'Access-Control-Allow-Origin' '*';
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docker_push.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | docker login -u $DOCKER_USER -p $DOCKER_PASS
4 |
5 | export REPO=wwwthoughtworks/build-your-own-radar
6 | export TAG=$CIRCLE_TAG
7 |
8 | docker build -f Dockerfile -t $REPO:latest .
9 | docker tag $REPO:latest $REPO:$TAG
10 |
11 | docker push $REPO:latest
12 | docker push $REPO:$TAG
13 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | clearMocks: true,
3 | collectCoverageFrom: ['src/**/*.js'],
4 | testEnvironment: 'jsdom',
5 | transform: {
6 | '^.+\\.[jt]sx?$': 'babel-jest',
7 | '.+\\.(css|styl|less|sass|scss)$': 'jest-css-modules-transform',
8 | },
9 | testMatch: ['**/spec/**/*-spec.js'],
10 | coverageThreshold: {
11 | global: {
12 | statements: 23.95,
13 | branches: 20.08,
14 | functions: 28.98,
15 | lines: 24.09,
16 | },
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "build-your-own-radar",
3 | "version": "1.1.4",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "build:dev": "webpack --mode development --config webpack.dev.js",
8 | "build:prod": "webpack --mode production --config webpack.prod.js",
9 | "dev": "webpack-dev-server --mode development --config webpack.dev.js",
10 | "test": "jest",
11 | "test:coverage": "jest --coverage",
12 | "test:e2e": "cypress open --env host=$TEST_URL",
13 | "test:e2e-headless": "cypress run --browser chrome --record false --env host=$TEST_URL",
14 | "lint-prettier:check": "eslint . && prettier --check .",
15 | "lint-prettier:fix": "eslint . --fix && prettier --write .",
16 | "quality": "npm run lint-prettier:check && npm run test:coverage"
17 | },
18 | "author": "Thoughtworks",
19 | "repository": {
20 | "type": "git",
21 | "url": "https://github.com/thoughtworks/build-your-own-radar"
22 | },
23 | "keywords": [
24 | "tech-radar"
25 | ],
26 | "license": "AGPL-3.0",
27 | "devDependencies": {
28 | "@babel/core": "^7.23.2",
29 | "@babel/preset-env": "^7.23.2",
30 | "babel-loader": "^9.1.3",
31 | "css-loader": "^6.8.1",
32 | "cssnano": "^6.0.1",
33 | "cypress": "^13.3.1",
34 | "dotenv": "^16.3.1",
35 | "eslint": "^8.51.0",
36 | "eslint-config-prettier": "^9.0.0",
37 | "eslint-plugin-cypress": "^2.15.1",
38 | "eslint-plugin-jest": "^27.4.2",
39 | "expose-loader": "^4.1.0",
40 | "html-webpack-plugin": "^5.5.3",
41 | "jest": "^29.7.0",
42 | "jest-css-modules-transform": "^4.4.2",
43 | "jest-environment-jsdom": "^29.7.0",
44 | "jsdom": "^22.1.0",
45 | "mini-css-extract-plugin": "^2.7.6",
46 | "mochawesome": "^7.1.3",
47 | "postcss-loader": "^7.3.3",
48 | "postcss-preset-env": "^9.2.0",
49 | "prettier": "^3.0.3",
50 | "sass": "^1.69.3",
51 | "sass-loader": "^13.3.2",
52 | "style-loader": "^3.3.3",
53 | "webpack": "^5.89.0",
54 | "webpack-cli": "^5.1.4",
55 | "webpack-dev-server": "^4.15.1",
56 | "yargs": "^17.7.2"
57 | },
58 | "dependencies": {
59 | "chance": "^1.1.11",
60 | "d3": "^7.8.5",
61 | "d3-tip": "^0.9.1",
62 | "jquery": "^3.7.1",
63 | "jquery-ui": "^1.13.2",
64 | "lodash": "^4.17.21",
65 | "sanitize-html": "^2.11.0"
66 | },
67 | "standard": {
68 | "globals": [
69 | "Cypress",
70 | "cy",
71 | "XMLHttpRequest"
72 | ],
73 | "env": [
74 | "jest"
75 | ],
76 | "ignore": [
77 | "radar-spec.js",
78 | "ref-table-spec.js"
79 | ]
80 | },
81 | "engines": {
82 | "node": ">=18",
83 | "npm": ">=9"
84 | },
85 | "private": true
86 | }
87 |
--------------------------------------------------------------------------------
/run_e2e_tests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | TEST_URL=$1
4 |
5 | npm run dev &
6 |
7 | sleep 30
8 | TEST_URL=$TEST_URL npm run test:e2e-headless
9 |
--------------------------------------------------------------------------------
/spec/config-spec.js:
--------------------------------------------------------------------------------
1 | const config = require('../src/config')
2 |
3 | describe('Config Test', () => {
4 | it('should return all env when no env defined', () => {
5 | const actual = config()
6 | expect(actual).toStrictEqual({
7 | production: { featureToggles: { UIRefresh2022: true } },
8 | development: { featureToggles: { UIRefresh2022: true } },
9 | })
10 | })
11 |
12 | it('should return the given env', () => {
13 | const oldEnv = process.env
14 | process.env.ENVIRONMENT = 'development'
15 | const actual = config()
16 | expect(actual).toStrictEqual({
17 | featureToggles: { UIRefresh2022: true },
18 | })
19 |
20 | process.env = oldEnv
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/spec/end_to_end_tests/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "CSV_FILE_URL": "https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/master/spec/end_to_end_tests/resources/sheet.csv",
3 | "JSON_FILE_URL": "https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/master/spec/end_to_end_tests/resources/data.json",
4 | "PUBLIC_GOOGLE_SHEET_URL": "https://docs.google.com/spreadsheets/d/1wZVb8V53O0Lzr4iMaz4qjJZKteA1xQhJNajGq0jE9sw",
5 | "PUBLIC_GOOGLE_SHEET_TITLE": "BYOR Test - Public Google Sheet",
6 | "PUBLIC_GOOGLE_SHEET_RADAR_SHEET_NAMES": ["Build your Technology Radar", "Build your Technology Radar - Sheet 2"],
7 | "QUADRANT_NAMES": ["All quadrants", "Techniques", "Platforms", "Tools", "Languages & Frameworks"],
8 | "RING_NAMES": ["Adopt", "Trial", "Assess", "Hold"],
9 | "PUBLIC_SHEET_QUADRANT_NAMES": ["All quadrants", "Techniques", "Platforms", "Tools", "Languages & Frameworks"],
10 | "QUADRANT_ORDERS": ["first", "second", "third", "fourth"],
11 | "QUADRANT_NAMES_LOWER": ["techniques", "platforms", "tools", "languages---frameworks"]
12 | }
13 |
--------------------------------------------------------------------------------
/spec/end_to_end_tests/pageObjects/base_page.js:
--------------------------------------------------------------------------------
1 | /* eslint no-useless-constructor: "off" */
2 |
3 | class BasePage {
4 | constructor() {}
5 | }
6 |
7 | module.exports = new BasePage()
8 |
--------------------------------------------------------------------------------
/spec/end_to_end_tests/pageObjects/byor_page.js:
--------------------------------------------------------------------------------
1 | const config = require('../config.json')
2 |
3 | class ByorPage {
4 | constructor() {
5 | this.textBox = "[name='documentId']"
6 | this.submit = 'input[type=submit]'
7 | }
8 |
9 | provideCsvName() {
10 | cy.get(this.textBox).clear()
11 | cy.get(this.textBox).type(config.CSV_FILE_URL)
12 | }
13 |
14 | provideJsonName() {
15 | cy.get(this.textBox).clear()
16 | cy.get(this.textBox).type(config.JSON_FILE_URL)
17 | }
18 |
19 | providePublicSheetUrl() {
20 | cy.get(this.textBox).clear()
21 | cy.get(this.textBox).type(config.PUBLIC_GOOGLE_SHEET_URL)
22 | }
23 |
24 | clickSubmitButton() {
25 | cy.get(this.submit).click()
26 | }
27 |
28 | typeString(string) {
29 | cy.get('body').type(string)
30 | }
31 |
32 | typeStringInUrlInput(string) {
33 | cy.get(this.textBox).clear()
34 | cy.get(this.textBox).type(string)
35 | }
36 |
37 | validateUrlInputFocused() {
38 | cy.get(this.textBox).should('have.focus')
39 | }
40 |
41 | validateUrlInputValue(value) {
42 | cy.get(this.textBox).should('have.value', value)
43 | }
44 | }
45 |
46 | module.exports = new ByorPage()
47 |
--------------------------------------------------------------------------------
/spec/end_to_end_tests/pageObjects/radar_page.js:
--------------------------------------------------------------------------------
1 | const config = require('../config.json')
2 |
3 | class RadarPage {
4 | constructor() {
5 | this.blip = '.quadrant-group-second .blip-link:nth-of-type(1)'
6 | this.allBlips = '.blip-link'
7 | this.bannerTitle = '.hero-banner__title-text'
8 | this.graphTitle = '.hero-banner__subtitle-text'
9 | this.quadrantList = '.quadrant-subnav__list-item'
10 | this.quadrantDropdown = '.quadrant-subnav__dropdown'
11 | this.quadrantSelector = '.quadrant-subnav__dropdown-selector'
12 | this.platformsSubnavItem = '.quadrant-subnav__list-item:nth-child(3)'
13 | this.searchBox = '.search-container__input'
14 | this.searchResultItems = '.ui-menu-item'
15 | this.alternateRadarsItems = '.alternative-radars__list-item'
16 | this.blipSelectedOld = '.quadrant-table.selected .blip-list-item.highlight'
17 | this.blipDescriptionOld = '.blip-item-description.expanded p'
18 | this.blipDescription = '.blip-list__item-container.expand .blip-list__item-container__description'
19 | this.autocomplete = '.search-radar'
20 | this.searchValue = 'Component'
21 | this.searchItem = '.ui-menu-item:first'
22 | this.quadrant = '#second-quadrant-mobile'
23 | this.firstQuadrant = '.quadrant-group-first'
24 | this.quadrantTableRings = '.quadrant-table.selected .quadrant-table__ring-name'
25 | this.quadrantTableBlips = '.quadrant-table.selected .blip-list__item'
26 | this.subnavDropdown = '.quadrant-subnav__dropdown'
27 | this.subnavList = '.quadrant-subnav__list'
28 | this.radarGraphSvg = 'svg#radar-plot'
29 | this.mobileQuadrants = '.all-quadrants-mobile'
30 | this.tooltip = '.d3-tip'
31 | this.quadrantTableBlip = function (blipId) {
32 | return `.quadrant-table.selected .blip-list__item-container[data-blip-id="${blipId}"]`
33 | }
34 | this.quadrantTableBlipDescription = function (blipId) {
35 | return `.quadrant-table.selected .blip-list__item-container[data-blip-id="${blipId}"] #blip-description-${blipId}`
36 | }
37 | this.radarGraphBlip = function (blipId) {
38 | return `a.blip-link[data-blip-id="${blipId}"]`
39 | }
40 | this.subnavQuadrant = function (quadrantName) {
41 | return `.quadrant-subnav__list-item#subnav-item-${quadrantName}`
42 | }
43 | this.quadrantGraph = function (quadrantOrder) {
44 | return `.quadrant-group-${quadrantOrder}`
45 | }
46 | this.quadrantGraphTablet = function (quadrantOrder) {
47 | return `#${quadrantOrder}-quadrant-mobile`
48 | }
49 | this.searchResultByIndex = function (index) {
50 | return `.ui-menu-item:nth-child(${index})`
51 | }
52 | this.alternateRadarsItemByIndex = function (index) {
53 | return `.alternative-radars__list-item:nth-child(${index})`
54 | }
55 |
56 | this.quadrantNameGroup = function (order) {
57 | return `.quadrant-group.quadrant-group-${order} .quadrant-name-group`
58 | }
59 |
60 | this.quadrantRingName = function (order, index) {
61 | return `.quadrant-group-${order} .line-text:nth-of-type(${index})`
62 | }
63 | }
64 |
65 | clickTheBlipInFullRadarView() {
66 | cy.get(this.blip).click()
67 | }
68 |
69 | clickTheBlip() {
70 | cy.get(this.blipSelectedOld).click()
71 | }
72 |
73 | clickQuadrantInFullRadarView(quadrantOrder) {
74 | cy.get(this.quadrantGraph(quadrantOrder)).click()
75 | }
76 |
77 | clickQuadrantInFullRadarViewTablet(quadrantOrder) {
78 | cy.get(this.quadrantGraphTablet(quadrantOrder)).click()
79 | }
80 |
81 | clickBlipItemInQuadrantTable(blipId) {
82 | cy.get(this.quadrantTableBlip(blipId)).click()
83 | }
84 |
85 | clickBlipInRadarGraph(blipId) {
86 | cy.get(this.radarGraphBlip(blipId)).click()
87 | }
88 |
89 | hoverBlipInRadarGraph(blipId) {
90 | cy.get(this.radarGraphBlip(blipId)).trigger('mouseover')
91 | }
92 |
93 | clickQuadrantInSubnav(quadrantName) {
94 | cy.get(this.subnavQuadrant(quadrantName)).click()
95 | }
96 |
97 | clickSubnavDropdownTablet() {
98 | cy.get(this.subnavDropdown).click()
99 | }
100 |
101 | clickSearchResult(index) {
102 | cy.get(this.searchResultByIndex(index)).click()
103 | }
104 |
105 | clickAlternateRadarItem(index) {
106 | cy.get(this.alternateRadarsItemByIndex(index)).click()
107 | }
108 |
109 | searchTheBlip() {
110 | cy.get(this.autocomplete).type(this.searchValue)
111 | cy.get(this.searchItem).click()
112 | }
113 |
114 | triggerSearch(query) {
115 | cy.get(this.searchBox).clear()
116 | cy.get(this.searchBox).type(query)
117 | }
118 |
119 | typeString(string) {
120 | cy.get('body').type(string)
121 | }
122 |
123 | typeStringInSearch(string) {
124 | cy.get(this.searchBox).clear()
125 | cy.get(this.searchBox).type(string)
126 | }
127 |
128 | validateBlipDescription(text) {
129 | cy.get(this.blipDescription).contains(text)
130 | }
131 |
132 | validateBlipText(blipId, text) {
133 | cy.get(`#${blipId}`).contains(text)
134 | }
135 |
136 | validateNoBlipToolTip(blipId) {
137 | cy.get(`#${blipId}`).trigger('mouseout') // cleanup of previous hover
138 | cy.get(`#${blipId}`).trigger('mouseover')
139 | cy.get(this.tooltip).should('have.attr', 'style').and('contains', 'opacity: 0')
140 | cy.get(this.tooltip).should('have.attr', 'style').and('contains', 'pointer-events: none')
141 | }
142 |
143 | validateBlipToolTip(blipId, text) {
144 | cy.get(`#${blipId}`).trigger('mouseover')
145 | cy.get(this.tooltip).contains(text)
146 | }
147 |
148 | validateBlipDescriptionOld(text) {
149 | cy.get(this.blipDescriptionOld).contains(text)
150 | }
151 |
152 | validateBlipSearch() {
153 | cy.get(this.blipSelectedOld).contains(this.searchValue)
154 | }
155 |
156 | validateBlipCountForPublicGoogleSheet() {
157 | cy.get(this.allBlips).should('have.length', 115)
158 | }
159 |
160 | validateGraphTitle(title) {
161 | cy.get(this.graphTitle).should('have.text', title)
162 | }
163 |
164 | validateQuadrantNames() {
165 | cy.get(this.quadrantList).should('have.length', 5)
166 |
167 | let i = 1
168 | for (const quadrant of config.QUADRANT_NAMES) {
169 | cy.get(`${this.quadrantList}:nth-child(${i})`).should('have.text', quadrant)
170 | i++
171 | }
172 | }
173 |
174 | validateQuadrantNamesForPublicGoogleSheet() {
175 | cy.get(this.quadrantList).should('have.length', 5)
176 |
177 | let i = 1
178 | for (const quadrant of config.QUADRANT_NAMES) {
179 | cy.get(`${this.quadrantList}:nth-child(${i})`).should('have.text', quadrant)
180 | i++
181 | }
182 | }
183 |
184 | validateSearchResults(query, results) {
185 | this.triggerSearch(query)
186 | cy.get(this.searchResultItems).should('have.length', results)
187 | }
188 |
189 | validateAlternateRadarsForPublicGoogleSheet() {
190 | cy.get(this.alternateRadarsItems).should('have.length', 2)
191 |
192 | let i = 1
193 | for (const name of config.PUBLIC_GOOGLE_SHEET_RADAR_SHEET_NAMES) {
194 | cy.get(`${this.alternateRadarsItems}:nth-child(${i})`).should('have.text', name)
195 | i++
196 | }
197 | }
198 |
199 | validateQuadrantSubnavClick(name) {
200 | cy.get(this.quadrantDropdown).click()
201 | cy.get(this.platformsSubnavItem).click()
202 | cy.get(this.quadrantSelector).should('have.text', name)
203 | }
204 |
205 | validateRingsInQuadrantTable(count) {
206 | cy.get(this.quadrantTableRings).should('have.length', count)
207 | }
208 |
209 | validateBlipsInQuadrantTable(count) {
210 | cy.get(this.quadrantTableBlips).should('have.length', count)
211 | }
212 |
213 | validateBlipDescriptionVibisbleInQuadrantTable(blipId) {
214 | cy.get(this.quadrantTableBlip(blipId)).should('have.class', 'expand')
215 | cy.get(this.quadrantTableBlipDescription(blipId)).should('be.visible')
216 | }
217 |
218 | validBlipHighlightedInQuadrantTable(blipId) {
219 | cy.get(this.quadrantTableBlip(blipId)).parent('.blip-list__item').should('have.class', 'highlight')
220 | }
221 |
222 | validateBlipDescriptionHiddenInQuadrantTable(blipId) {
223 | cy.get(this.quadrantTableBlip(blipId)).should('not.have.class', 'expand')
224 | cy.get(this.quadrantTableBlipDescription(blipId)).should('be.hidden')
225 | }
226 |
227 | validateQuadrantGraphVisible(quadrantOrder) {
228 | cy.get(this.quadrantGraph(quadrantOrder)).should('be.visible')
229 | }
230 |
231 | validateQuadrantGraphHidden(quadrantOrder) {
232 | cy.get(this.quadrantGraph(quadrantOrder)).should('be.hidden')
233 | }
234 |
235 | validateActiveQuadrantInSubnav(quadrantName) {
236 | cy.get(this.subnavQuadrant(quadrantName)).should('have.class', 'active-item')
237 | }
238 |
239 | validateSubnavDropdownVisibleTablet() {
240 | cy.get(this.subnavList).should('be.visible')
241 | }
242 |
243 | validateSubnavDropdownHiddenTablet() {
244 | cy.get(this.subnavList).should('be.hidden')
245 | }
246 |
247 | validateActiveAlternateRadar(index) {
248 | cy.get(this.alternateRadarsItemByIndex(index)).should('have.class', 'active')
249 | }
250 |
251 | validateInactiveAlternateRadar(index) {
252 | cy.get(this.alternateRadarsItemByIndex(index)).should('not.have.class', 'active')
253 | }
254 |
255 | validateMobileQuadrantsVisible() {
256 | cy.get(this.mobileQuadrants).should('be.visible')
257 | }
258 |
259 | validateMobileQuadrantsHidden() {
260 | cy.get(this.mobileQuadrants).should('be.hidden')
261 | }
262 |
263 | validateGraphVisible() {
264 | cy.get(this.radarGraphSvg).should('be.visible')
265 | }
266 |
267 | validateGraphHidden() {
268 | cy.get(this.radarGraphSvg).should('be.hidden')
269 | }
270 |
271 | validateQuadrantOrder() {
272 | let i = 1
273 | for (const order of config.QUADRANT_ORDERS) {
274 | cy.get(this.quadrantNameGroup(order)).should('have.text', config.QUADRANT_NAMES[i])
275 | i += 1
276 | }
277 | }
278 |
279 | validateRingOrder() {
280 | let i = 1
281 | for (const order of config.QUADRANT_ORDERS) {
282 | cy.get(this.quadrantRingName(order, i)).should('have.text', config.RING_NAMES[i - 1])
283 | i += 1
284 | }
285 | }
286 |
287 | validateActiveQuadrant(quadrantName, quadrantOrder) {
288 | this.validateQuadrantGraphVisible(quadrantOrder)
289 |
290 | for (const order of config.QUADRANT_ORDERS.filter(function (order) {
291 | return order !== quadrantOrder
292 | })) {
293 | this.validateQuadrantGraphHidden(order)
294 | }
295 |
296 | this.validateActiveQuadrantInSubnav(quadrantName)
297 | }
298 |
299 | validateSearchVisible() {
300 | cy.get(this.searchBox).should('be.visible')
301 | }
302 |
303 | validateSearchFocused() {
304 | cy.get(this.searchBox).should('have.focus')
305 | }
306 |
307 | validateSearchValue(value) {
308 | cy.get(this.searchBox).should('have.value', value)
309 | }
310 |
311 | resetRadarView() {
312 | cy.scrollTo('top')
313 | cy.get(this.bannerTitle).click()
314 | }
315 | }
316 |
317 | module.exports = new RadarPage()
318 |
--------------------------------------------------------------------------------
/spec/end_to_end_tests/resources/localfiles/radar.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "Babel",
4 | "ring": "adopt",
5 | "quadrant": "tools",
6 | "isNew": "TRUE",
7 | "description": "test"
8 | },
9 | {
10 | "name": "Apache Kafka",
11 | "ring": "trial",
12 | "quadrant": "languages & frameworks",
13 | "isNew": "FALSE",
14 | "description": "test"
15 | },
16 | {
17 | "name": "Android-x86",
18 | "ring": "assess",
19 | "quadrant": "platforms",
20 | "isNew": "TRUE",
21 | "description": "test"
22 | },
23 | {
24 | "name": "GrapCloud lift and shifthQL",
25 | "ring": "hold",
26 | "quadrant": "techniques",
27 | "isNew": "FALSE",
28 | "description": "test"
29 | }
30 | ]
31 |
--------------------------------------------------------------------------------
/spec/exceptions/fileNotFoundError-spec.js:
--------------------------------------------------------------------------------
1 | const FileNotFoundError = require('../../src/exceptions/fileNotFoundError')
2 | describe('File Not Found Error', () => {
3 | it('should create a FileNotFoundException', () => {
4 | const error = new FileNotFoundError("Oops! We can't find the CSV file you've entered")
5 | expect(error).toBeInstanceOf(FileNotFoundError)
6 | expect(error).toBeInstanceOf(Error)
7 | expect(error.message).toStrictEqual("Oops! We can't find the CSV file you've entered")
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/spec/exceptions/invalidConfigError-spec.js:
--------------------------------------------------------------------------------
1 | const InvalidConfigError = require('../../src/exceptions/invalidConfigError')
2 | describe('Invalid Config Error', () => {
3 | it('should create a InvalidConfigError', () => {
4 | const error = new InvalidConfigError('Invalid Configuration')
5 | expect(error).toBeInstanceOf(InvalidConfigError)
6 | expect(error).toBeInstanceOf(Error)
7 | expect(error.message).toStrictEqual('Invalid Configuration')
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/spec/exceptions/invalidContentError-spec.js:
--------------------------------------------------------------------------------
1 | const InvalidContentError = require('../../src/exceptions/invalidContentError')
2 | describe('Invalid Content Error', () => {
3 | it('should create a Invalid content Error', () => {
4 | const error = new InvalidContentError('Invalid Content')
5 | expect(error).toBeInstanceOf(InvalidContentError)
6 | expect(error).toBeInstanceOf(Error)
7 | expect(error.message).toStrictEqual('Invalid Content')
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/spec/graphing/blips-spec.js:
--------------------------------------------------------------------------------
1 | const {
2 | calculateRadarBlipCoordinates,
3 | getRingRadius,
4 | groupBlipsBaseCoords,
5 | transposeQuadrantCoords,
6 | getGroupBlipTooltipText,
7 | blipAssistiveText,
8 | createGroupBlip,
9 | thereIsCollision,
10 | sortBlipCoordinates,
11 | } = require('../../src/graphing/blips')
12 | const Chance = require('chance')
13 | const { graphConfig } = require('../../src/graphing/config')
14 | const Blip = require('../../src/models/blip')
15 | jest.mock('d3', () => {
16 | return {
17 | select: jest.fn(),
18 | }
19 | })
20 |
21 | jest.mock('../../src/graphing/config', () => {
22 | return {
23 | graphConfig: {
24 | effectiveQuadrantHeight: 528,
25 | effectiveQuadrantWidth: 528,
26 | quadrantHeight: 512,
27 | quadrantWidth: 512,
28 | quadrantsGap: 32,
29 | minBlipWidth: 12,
30 | blipWidth: 22,
31 | groupBlipHeight: 24,
32 | newGroupBlipWidth: 84,
33 | existingGroupBlipWidth: 124,
34 | groupBlipAngles: [30, 35, 60, 80],
35 | },
36 | }
37 | })
38 |
39 | const chance = Chance()
40 | const chanceFloatingSpy = jest.spyOn(chance, 'floating')
41 | const chanceIntegerSpy = jest
42 | .spyOn(chance, 'integer')
43 | .mockImplementationOnce((options) => {
44 | return options.max
45 | })
46 | .mockImplementation((options) => {
47 | return options.min
48 | })
49 |
50 | function mockRingBlips(maxBlipCount) {
51 | let ringBlips = []
52 | let blip
53 | for (let blipCounter = 1; blipCounter <= maxBlipCount; blipCounter++) {
54 | blip = new Blip(`blip${blipCounter}`, 'ring1', true, '', '')
55 | blip.setId(blipCounter)
56 | ringBlips.push(blip)
57 | }
58 | return ringBlips
59 | }
60 |
61 | describe('Blips', function () {
62 | it('should return coordinates which fall under the first quadrant and rings provided', function () {
63 | const startAngle = 0
64 | let minRadius = 160
65 | const maxRadius = 300
66 | const coordinates = calculateRadarBlipCoordinates(minRadius, maxRadius, startAngle, 'first', chance, { width: 22 })
67 |
68 | const minRadiusAfterThreshold = minRadius + graphConfig.blipWidth / 2
69 | const maxRadiusAfterThreshold = maxRadius - graphConfig.blipWidth
70 | const xCoordMaxValue =
71 | graphConfig.effectiveQuadrantWidth + maxRadiusAfterThreshold * -1 * 0.9978403633398593 + graphConfig.blipWidth
72 | const yCoordMaxValue = graphConfig.effectiveQuadrantHeight + maxRadiusAfterThreshold * -1 * 0.06568568557743505
73 | const xCoordMinValue = graphConfig.effectiveQuadrantWidth + minRadiusAfterThreshold * -1 * 0.9942914830326867
74 | const yCoordMinValue =
75 | graphConfig.effectiveQuadrantHeight + minRadiusAfterThreshold * -1 * 0.9942914830326867 - graphConfig.blipWidth
76 |
77 | expect(chanceFloatingSpy).toHaveBeenCalledWith({
78 | min: minRadiusAfterThreshold,
79 | max: maxRadiusAfterThreshold,
80 | fixed: 4,
81 | })
82 | expect(chanceIntegerSpy).toHaveBeenCalled()
83 | expect(parseFloat(coordinates[0].toFixed(3))).toBeLessThanOrEqual(parseFloat(xCoordMinValue.toFixed(3)))
84 | expect(parseFloat(coordinates[1].toFixed(1))).toBeGreaterThanOrEqual(parseFloat(yCoordMinValue.toFixed(1)))
85 | expect(parseFloat(coordinates[0].toFixed(3))).toBeLessThanOrEqual(parseFloat(xCoordMaxValue.toFixed(3)))
86 | expect(parseFloat(coordinates[1].toFixed(3))).toBeLessThanOrEqual(parseFloat(yCoordMaxValue.toFixed(3)))
87 | })
88 |
89 | it('should return coordinates for the second quadrant and consider the border offset provided', function () {
90 | const startAngle = -90
91 | let minRadius = 160
92 | const maxRadius = 300
93 | const blipWidth = 22
94 | const coordinates = calculateRadarBlipCoordinates(minRadius, maxRadius, startAngle, 'second', chance, {
95 | width: blipWidth,
96 | })
97 |
98 | const minRadiusAfterThreshold = minRadius + blipWidth / 2
99 | const maxRadiusAfterThreshold = maxRadius - blipWidth
100 | const xCoordMaxValue =
101 | graphConfig.quadrantWidth + maxRadiusAfterThreshold * -1 * 0.0707372016677029 + graphConfig.quadrantsGap + 10
102 | const yCoordMaxValue =
103 | graphConfig.quadrantHeight + maxRadiusAfterThreshold * 0.9999 * 0.27563735581699916 + graphConfig.quadrantsGap
104 | const xCoordMinValue =
105 | graphConfig.quadrantWidth + minRadiusAfterThreshold * -1 * 0.9942914830326867 + graphConfig.quadrantsGap + 10
106 | const yCoordMinValue =
107 | graphConfig.quadrantHeight + minRadiusAfterThreshold * 0.9999 * 0.10670657355889696 + graphConfig.quadrantsGap
108 |
109 | expect(chanceFloatingSpy).toHaveBeenCalledWith({
110 | min: minRadiusAfterThreshold,
111 | max: maxRadiusAfterThreshold,
112 | fixed: 4,
113 | })
114 | expect(chanceIntegerSpy).toHaveBeenCalled()
115 | expect(parseFloat(coordinates[0].toFixed(3))).toBeLessThanOrEqual(parseFloat(xCoordMinValue.toFixed(3)))
116 | expect(coordinates[1]).toBeGreaterThan(yCoordMinValue)
117 | expect(coordinates[0]).toBeLessThan(xCoordMaxValue)
118 | expect(coordinates[1]).toBeLessThan(yCoordMaxValue)
119 | })
120 |
121 | it('should return first quadrant group blip coordinates for ring1', function () {
122 | const baseCoords = groupBlipsBaseCoords(0)
123 |
124 | expect(baseCoords.new).toEqual([419.94200893545406, 442.552])
125 | expect(baseCoords['existing']).toEqual([379.94200893545406, 471.552])
126 | })
127 |
128 | it('should transpose base coords for a new blip in ring1 to other three quadrants', function () {
129 | const newBlipBaseCoords = groupBlipsBaseCoords(0).new
130 |
131 | const coordsMap = transposeQuadrantCoords(newBlipBaseCoords, graphConfig.newGroupBlipWidth)
132 | expect(coordsMap.first).toEqual(newBlipBaseCoords)
133 | expect(coordsMap.second).toEqual([newBlipBaseCoords[0], 589.448])
134 | expect(coordsMap.third).toEqual([552.057991064546, newBlipBaseCoords[1]])
135 | expect(coordsMap.fourth).toEqual([552.057991064546, 589.448])
136 | })
137 |
138 | it('should return first quadrant group blip coordinates for ring2 with index 1', function () {
139 | const baseCoords = groupBlipsBaseCoords(1)
140 | expect(baseCoords.new).toEqual([287.0075702088335, 340.86317046071997])
141 | expect(baseCoords['existing']).toEqual([247.0075702088335, 369.86317046071997])
142 | })
143 |
144 | it('should return first quadrant group blip coordinates for ring3 with index 2', function () {
145 | const baseCoords = groupBlipsBaseCoords(2)
146 | expect(baseCoords.new).toEqual([300.048, 153.99348500067663])
147 | expect(baseCoords['existing']).toEqual([260.048, 182.99348500067663])
148 | })
149 |
150 | it('should return first quadrant group blip coordinates for ring4 with index 3', function () {
151 | const baseCoords = groupBlipsBaseCoords(3)
152 | expect(baseCoords.new).toEqual([408.91602532749283, 23.149928577467563])
153 | expect(baseCoords['existing']).toEqual([368.91602532749283, 52.14992857746756])
154 | })
155 |
156 | it('should return group blip tool tip text as "Click to view all" count is more than 15', function () {
157 | let ringBlips = mockRingBlips(20)
158 | const actualToolTip = getGroupBlipTooltipText(ringBlips)
159 | const expectedToolTip = 'Click to view all'
160 | expect(actualToolTip).toEqual(expectedToolTip)
161 | })
162 |
163 | it('should return group blip tool tip text as all blip names if count is <= 15', function () {
164 | let ringBlips = mockRingBlips(15)
165 | const actualToolTip = getGroupBlipTooltipText(ringBlips)
166 | const expectedToolTip =
167 | '1. blip12. blip23. blip34. blip45. blip56. blip67. blip78. blip89. blip910. blip1011. blip1112. blip1213. blip1314. blip1415. blip15'
168 | expect(actualToolTip).toEqual(expectedToolTip)
169 | })
170 |
171 | it('should return ring radius based on the ring index', function () {
172 | expect(getRingRadius(0)).toBe(0)
173 | expect(getRingRadius(1)).toBe(161.792)
174 | expect(getRingRadius(2)).toBe(333.824)
175 | expect(getRingRadius(3)).toBe(425.984)
176 | expect(getRingRadius(4)).toBe(507.904)
177 | expect(getRingRadius(5)).toBe(0)
178 | })
179 |
180 | it('should return group blip assistive text for group blip', function () {
181 | const blip = {
182 | isGroup: () => true,
183 | ring: () => {
184 | return {
185 | name: () => 'ring1',
186 | }
187 | },
188 | blipText: () => '12 New Blips',
189 | name: 'blip1',
190 | isNew: () => true,
191 | status: () => null,
192 | }
193 |
194 | const actual = blipAssistiveText(blip)
195 | expect(actual).toEqual('`ring1 ring, group of 12 New Blips')
196 | })
197 |
198 | it('should return correct assistive text for new blip', function () {
199 | const blip = {
200 | isGroup: () => false,
201 | ring: () => {
202 | return {
203 | name: () => 'Trial',
204 | }
205 | },
206 | name: () => 'Some cool tech',
207 | status: () => 'New',
208 | }
209 |
210 | const actual = blipAssistiveText(blip)
211 | expect(actual).toEqual('Trial ring, Some cool tech, New.')
212 | })
213 |
214 | it('should return correct assistive text for existing blip', function () {
215 | const blip = {
216 | isGroup: () => false,
217 | ring: () => {
218 | return {
219 | name: () => 'Trial',
220 | }
221 | },
222 | name: () => 'Some cool tech',
223 | status: () => 'No change',
224 | }
225 |
226 | const actual = blipAssistiveText(blip)
227 | expect(actual).toEqual('Trial ring, Some cool tech, No change.')
228 | })
229 |
230 | it('should return correct assistive text for moved in blip', function () {
231 | const blip = {
232 | isGroup: () => false,
233 | ring: () => {
234 | return {
235 | name: () => 'Trial',
236 | }
237 | },
238 | name: () => 'Some cool tech',
239 | status: () => 'Moved in',
240 | }
241 |
242 | const actual = blipAssistiveText(blip)
243 | expect(actual).toEqual('Trial ring, Some cool tech, Moved in.')
244 | })
245 |
246 | it('should return correct assistive text for moved out blip', function () {
247 | const blip = {
248 | isGroup: () => false,
249 | ring: () => {
250 | return {
251 | name: () => 'Trial',
252 | }
253 | },
254 | name: () => 'Some cool tech',
255 | status: () => 'Moved out',
256 | }
257 |
258 | const actual = blipAssistiveText(blip)
259 | expect(actual).toEqual('Trial ring, Some cool tech, Moved out.')
260 | })
261 |
262 | it('should return group blip with appropriate values', function () {
263 | const ringBlips = mockRingBlips(20)
264 | const groupBlip = createGroupBlip(ringBlips, 'New', { name: () => 'ring1' }, 'first')
265 | expect(groupBlip).toBeTruthy()
266 | expect(groupBlip.blipText()).toEqual('20 New blips')
267 | expect(groupBlip.id()).toEqual('first-ring1-group-new-blips')
268 | expect(groupBlip.isGroup()).toEqual(true)
269 | })
270 |
271 | it('should return true when the given coords are colliding with existing coords', function () {
272 | const existingCoords = [{ coordinates: [10, 10], width: 22 }]
273 |
274 | expect(thereIsCollision([10, 10], existingCoords, 22)).toBe(true)
275 | expect(thereIsCollision([41, 41], existingCoords, 22)).toBe(true)
276 | expect(thereIsCollision([42, 42], existingCoords, 22)).toBe(false)
277 | })
278 |
279 | it('should sort blips coordinates', function () {
280 | const existingCoords = [
281 | { coordinates: [500, 400], width: 22 },
282 | { coordinates: [200, 200], width: 22 },
283 | { coordinates: [40, 40], width: 22 },
284 | ]
285 |
286 | expect(sortBlipCoordinates(existingCoords, 'first')).toEqual([
287 | { coordinates: [200, 200], width: 22 },
288 | { coordinates: [40, 40], width: 22 },
289 | { coordinates: [500, 400], width: 22 },
290 | ])
291 | expect(sortBlipCoordinates(existingCoords, 'third')).toEqual([
292 | { coordinates: [200, 200], width: 22 },
293 | { coordinates: [40, 40], width: 22 },
294 | { coordinates: [500, 400], width: 22 },
295 | ])
296 | expect(sortBlipCoordinates(existingCoords, 'second')).toEqual([
297 | { coordinates: [500, 400], width: 22 },
298 | { coordinates: [200, 200], width: 22 },
299 | { coordinates: [40, 40], width: 22 },
300 | ])
301 | expect(sortBlipCoordinates(existingCoords, 'fourth')).toEqual([
302 | { coordinates: [500, 400], width: 22 },
303 | { coordinates: [200, 200], width: 22 },
304 | { coordinates: [40, 40], width: 22 },
305 | ])
306 | })
307 | })
308 |
--------------------------------------------------------------------------------
/spec/graphing/components/quadrants-spec.js:
--------------------------------------------------------------------------------
1 | const { wrapQuadrantNameInMultiLine } = require('../../../src/graphing/components/quadrants')
2 | jest.mock('d3', () => {
3 | return {
4 | select: jest.fn(),
5 | }
6 | })
7 |
8 | describe('Quadrants', function () {
9 | let element, mockedD3Element, quadrantGroup, tip
10 | beforeEach(() => {
11 | document.body.innerHTML = '
' + '
'
12 |
13 | window.Element.prototype.getBoundingClientRect = function () {
14 | return {
15 | x: 0,
16 | y: 0,
17 | bottom: 0,
18 | height: 0,
19 | left: 0,
20 | right: 0,
21 | top: 0,
22 | width: this.textContent.length * 10,
23 | }
24 | }
25 | element = document.querySelector('#my-elem')
26 | mockedD3Element = { node: () => element }
27 | quadrantGroup = { on: jest.fn() }
28 | tip = { show: jest.fn(), hide: jest.fn() }
29 | })
30 |
31 | it('should render the text in one line if length is not above the max length', function () {
32 | element.innerHTML = 'Tools'
33 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip)
34 | let expectedTSpanTags = element.querySelectorAll('tspan')
35 | expect(expectedTSpanTags).toHaveLength(1)
36 | expect(expectedTSpanTags[0].textContent).toEqual('Tools')
37 | expect(expectedTSpanTags[0].getAttribute('dy')).toBeNull()
38 | })
39 |
40 | it('should render the text in two lines if length is above the max length', function () {
41 | element.innerHTML = 'Languages & Frameworks'
42 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip)
43 | let expectedTSpanTags = element.querySelectorAll('tspan')
44 | expect(expectedTSpanTags).toHaveLength(2)
45 | expect(expectedTSpanTags[0].textContent).toEqual('Languages & ')
46 | expect(expectedTSpanTags[1].textContent).toEqual('Frameworks')
47 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20')
48 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20')
49 | })
50 |
51 | it('should split the first word by hyphen and render the text in two lines if its longer than max length', function () {
52 | element.innerHTML = 'Pneumonoultramicroscopic'
53 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip)
54 | let expectedTSpanTags = element.querySelectorAll('tspan')
55 | expect(expectedTSpanTags).toHaveLength(2)
56 | expect(expectedTSpanTags[0].textContent).toEqual('Pneumonoultram-')
57 | expect(expectedTSpanTags[1].textContent).toEqual('icroscopic ')
58 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20')
59 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20')
60 | })
61 |
62 | it('should split the first word by hyphen and render the text in two lines with ellipsis if its longer than max length after splitting also', function () {
63 | element.innerHTML = 'Pneumonoultramicro scopicsilicovolcanoconiosis'
64 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip)
65 | let expectedTSpanTags = element.querySelectorAll('tspan')
66 | expect(expectedTSpanTags).toHaveLength(2)
67 | expect(expectedTSpanTags[0].textContent).toEqual('Pneumonoultram-')
68 | expect(expectedTSpanTags[1].textContent).toEqual('icro scopics...')
69 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20')
70 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20')
71 | })
72 |
73 | it('should render the text in two lines with ellipsis if its longer than max length', function () {
74 | element.innerHTML = 'Pneumonoultra microscopicsilicovolcanoconiosis'
75 | wrapQuadrantNameInMultiLine(mockedD3Element, false, quadrantGroup, tip)
76 | let expectedTSpanTags = element.querySelectorAll('tspan')
77 | expect(expectedTSpanTags).toHaveLength(2)
78 | expect(expectedTSpanTags[0].textContent).toEqual('Pneumonoultra ')
79 | expect(expectedTSpanTags[1].textContent).toEqual('microscopics...')
80 | expect(expectedTSpanTags[0].getAttribute('dy')).toBe('-20')
81 | expect(expectedTSpanTags[1].getAttribute('dy')).toBe('20')
82 | })
83 | })
84 |
--------------------------------------------------------------------------------
/spec/graphing/config-spec.js:
--------------------------------------------------------------------------------
1 | const {
2 | getScale,
3 | getGraphSize,
4 | getScaledQuadrantWidth,
5 | getScaledQuadrantHeightWithGap,
6 | isValidConfig,
7 | } = require('../../src/graphing/config')
8 | describe('Graphing Config', () => {
9 | it('should get the scale size for different window size', () => {
10 | window.innerWidth = 1440
11 | expect(getScale()).toStrictEqual(1.25)
12 |
13 | window.innerWidth = 1880
14 | expect(getScale()).toStrictEqual(1.5)
15 | })
16 |
17 | it('should get the graph size', () => {
18 | expect(getGraphSize()).toStrictEqual(1056)
19 | })
20 |
21 | it('should get the scaled quadrant width', () => {
22 | expect(getScaledQuadrantWidth(1.25)).toStrictEqual(640)
23 | })
24 |
25 | it('should get the scaled quadrant height with gap', () => {
26 | expect(getScaledQuadrantHeightWithGap(1.25)).toStrictEqual(680)
27 | })
28 |
29 | it('should validate the configs for quadrants and rings', () => {
30 | const oldEnv = process.env
31 | expect(isValidConfig()).toBeTruthy()
32 |
33 | process.env.QUADRANTS = '["radar"]'
34 | expect(isValidConfig()).toBeFalsy()
35 |
36 | process.env.QUADRANTS = '["radar", "r", "ra", "rad", "rada"]'
37 | expect(isValidConfig()).toBeFalsy()
38 |
39 | process.env.RINGS = '[]'
40 | expect(isValidConfig()).toBeFalsy()
41 |
42 | process.env.RINGS = '["radar", "r", "ra", "rad", "rada"]'
43 | expect(isValidConfig()).toBeFalsy()
44 |
45 | process.env = oldEnv
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/spec/helpers/jsdom.js:
--------------------------------------------------------------------------------
1 | const JSDOM = require('jsdom').JSDOM
2 | const dom = new JSDOM('')
3 | global.document = dom.window.document
4 | global.window = dom.window
5 | global.navigator = dom.window.navigator
6 | global.jQuery = global.$ = global.jquery = require('jquery')
7 | require('jquery-ui')
8 |
--------------------------------------------------------------------------------
/spec/models/blip-spec.js:
--------------------------------------------------------------------------------
1 | const Blip = require('../../src/models/blip')
2 | const Ring = require('../../src/models/ring')
3 | const { graphConfig } = require('../../src/graphing/config')
4 |
5 | describe('Blip', function () {
6 | let blip
7 |
8 | beforeEach(function () {
9 | blip = new Blip('My Blip', new Ring('My Ring'))
10 | })
11 |
12 | it('has a name', function () {
13 | expect(blip.name()).toEqual('My Blip')
14 | })
15 |
16 | it('has a topic', function () {
17 | blip = new Blip('My Blip', new Ring('My Ring'), true, null, 'topic', 'description')
18 |
19 | expect(blip.topic()).toEqual('topic')
20 | })
21 |
22 | it('has empty topic when not provided', function () {
23 | expect(blip.topic()).toEqual('')
24 | })
25 |
26 | it('has a description', function () {
27 | blip = new Blip('My Blip', new Ring('My Ring'), true, null, 'topic', 'description')
28 |
29 | expect(blip.description()).toEqual('description')
30 | })
31 |
32 | it('has empty description when not provided', function () {
33 | expect(blip.description()).toEqual('')
34 | })
35 |
36 | it('has a ring', function () {
37 | expect(blip.ring().name()).toEqual('My Ring')
38 | })
39 |
40 | it('has a default blip text', function () {
41 | expect(blip.blipText()).toEqual('')
42 | })
43 |
44 | it('sets the blip text', function () {
45 | blip.setBlipText('blip text1')
46 | expect(blip.blipText()).toEqual('blip text1')
47 | })
48 |
49 | it('has a default blip id', function () {
50 | expect(blip.id()).toEqual(-1)
51 | })
52 |
53 | it('sets the blip id', function () {
54 | blip.setId(123)
55 | expect(blip.id()).toEqual(123)
56 | })
57 |
58 | it('is new', function () {
59 | blip = new Blip('My Blip', new Ring('My Ring'), true, null, 'My Topic', 'My Description')
60 |
61 | expect(blip.isNew()).toBe(true)
62 | })
63 |
64 | it('is not new', function () {
65 | blip = new Blip('My Blip', new Ring('My Ring'), false, null, 'My Topic', 'My Description')
66 |
67 | expect(blip.isNew()).toBe(false)
68 | })
69 |
70 | it('status is new', function () {
71 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'new', 'My Topic', 'My Description')
72 |
73 | expect(blip.isNew()).toBe(true)
74 | })
75 |
76 | it('status has moved in', function () {
77 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'Moved In', 'My Topic', 'My Description')
78 |
79 | expect(blip.hasMovedIn()).toBe(true)
80 | })
81 |
82 | it('status has moved out', function () {
83 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'Moved Out', 'My Topic', 'My Description')
84 |
85 | expect(blip.hasMovedOut()).toBe(true)
86 | })
87 |
88 | it('status has no change', function () {
89 | blip = new Blip('My Blip', new Ring('My Ring'), null, 'No Change', 'My Topic', 'My Description')
90 |
91 | expect(blip.hasNoChange()).toBe(true)
92 | })
93 |
94 | it('has false as default value for isGroup', function () {
95 | expect(blip.isGroup()).toEqual(false)
96 | })
97 |
98 | it('sets the blip group', function () {
99 | blip.setIsGroup(true)
100 | expect(blip.isGroup()).toEqual(true)
101 | })
102 |
103 | it('has blank group id by default', function () {
104 | expect(blip.groupIdInGraph()).toEqual('')
105 | })
106 |
107 | it('sets the group id as passed value', function () {
108 | blip.setGroupIdInGraph('group-id-value')
109 | expect(blip.groupIdInGraph()).toEqual('group-id-value')
110 | })
111 |
112 | it('get respective group blip width', function () {
113 | const existingBlip = new Blip('My Blip', new Ring('My Ring'), false)
114 | const newBlip = new Blip('My Blip', new Ring('My Ring'), true)
115 |
116 | expect(existingBlip.groupBlipWidth()).toEqual(graphConfig.existingGroupBlipWidth)
117 | expect(newBlip.groupBlipWidth()).toEqual(graphConfig.newGroupBlipWidth)
118 | })
119 | })
120 |
--------------------------------------------------------------------------------
/spec/models/quadrant-spec.js:
--------------------------------------------------------------------------------
1 | const Quadrant = require('../../src/models/quadrant')
2 | const Blip = require('../../src/models/blip')
3 |
4 | describe('Quadrant', function () {
5 | it('has a name', function () {
6 | var quadrant = new Quadrant('My Quadrant')
7 |
8 | expect(quadrant.name()).toEqual('My Quadrant')
9 | })
10 |
11 | it('has no blips by default', function () {
12 | var quadrant = new Quadrant('My Quadrant')
13 |
14 | expect(quadrant.blips()).toEqual([])
15 | })
16 |
17 | it('can add a single blip', function () {
18 | var quadrant = new Quadrant('My Quadrant')
19 |
20 | quadrant.add(new Blip())
21 |
22 | expect(quadrant.blips()).toHaveLength(1)
23 | })
24 |
25 | it('can add multiple blips', function () {
26 | var quadrant = new Quadrant('My Quadrant')
27 |
28 | quadrant.add([new Blip(), new Blip()])
29 |
30 | expect(quadrant.blips()).toHaveLength(2)
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/spec/models/radar-spec.js:
--------------------------------------------------------------------------------
1 | const Radar = require('../../src/models/radar')
2 | const Quadrant = require('../../src/models/quadrant')
3 | const Ring = require('../../src/models/ring')
4 | const Blip = require('../../src/models/blip')
5 | const MalformedDataError = require('../../src/exceptions/malformedDataError')
6 | const ExceptionMessages = require('../../src/util/exceptionMessages')
7 | const { graphConfig } = require('../../src/graphing/config')
8 |
9 | describe('Radar', function () {
10 | beforeEach(() => {
11 | process.env.ENVIRONMENT = 'development'
12 | })
13 |
14 | it('has no quadrants by default', function () {
15 | var radar = new Radar()
16 |
17 | expect(radar.quadrants()[0].quadrant).not.toBeDefined()
18 | expect(radar.quadrants()[1].quadrant).not.toBeDefined()
19 | expect(radar.quadrants()[2].quadrant).not.toBeDefined()
20 | expect(radar.quadrants()[3].quadrant).not.toBeDefined()
21 | })
22 |
23 | it('sets the first quadrant', function () {
24 | var quadrant, radar, blip
25 |
26 | blip = new Blip('A', new Ring('First'))
27 | quadrant = new Quadrant('First')
28 | quadrant.add([blip])
29 | radar = new Radar()
30 |
31 | radar.addQuadrant(quadrant)
32 |
33 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant)
34 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1)
35 | })
36 |
37 | it('sets the second quadrant', function () {
38 | var quadrant, radar, blip
39 |
40 | blip = new Blip('A', new Ring('First'))
41 | quadrant = new Quadrant('Second')
42 | quadrant.add([blip])
43 | radar = new Radar()
44 |
45 | radar.addQuadrant(quadrant)
46 |
47 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant)
48 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1)
49 | })
50 |
51 | it('sets the third quadrant', function () {
52 | var quadrant, radar, blip
53 |
54 | blip = new Blip('A', new Ring('First'))
55 | quadrant = new Quadrant('Third')
56 | quadrant.add([blip])
57 | radar = new Radar()
58 |
59 | radar.addQuadrant(quadrant)
60 |
61 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant)
62 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1)
63 | })
64 |
65 | it('sets the fourth quadrant', function () {
66 | var quadrant, radar, blip
67 |
68 | blip = new Blip('A', new Ring('First'))
69 | quadrant = new Quadrant('Fourth')
70 | quadrant.add([blip])
71 | radar = new Radar()
72 |
73 | radar.addQuadrant(quadrant)
74 |
75 | expect(radar.quadrants()[0].quadrant).toEqual(quadrant)
76 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1)
77 | })
78 |
79 | it('sets the current sheet', function () {
80 | const radar = new Radar()
81 | let sheetName = 'The current sheet'
82 | radar.setCurrentSheet(sheetName)
83 | expect(radar.getCurrentSheet()).toEqual(sheetName)
84 | })
85 |
86 | it('throws an error if too many quadrants are added', function () {
87 | var quadrant, radar, blip
88 |
89 | blip = new Blip('A', new Ring('First'))
90 | quadrant = new Quadrant('First')
91 | quadrant.add([blip])
92 | radar = new Radar()
93 |
94 | radar.addQuadrant(quadrant)
95 | radar.addQuadrant(new Quadrant('Second'))
96 | radar.addQuadrant(new Quadrant('Third'))
97 | radar.addQuadrant(new Quadrant('Fourth'))
98 |
99 | expect(function () {
100 | radar.addQuadrant(new Quadrant('Fifth'))
101 | }).toThrow(new MalformedDataError(ExceptionMessages.TOO_MANY_QUADRANTS))
102 | })
103 |
104 | describe('blip numbers', function () {
105 | var firstQuadrant, secondQuadrant, radar, firstRing
106 |
107 | beforeEach(function () {
108 | firstRing = new Ring('Adopt', 0)
109 | firstQuadrant = new Quadrant('First')
110 | secondQuadrant = new Quadrant('Second')
111 | firstQuadrant.add([new Blip('A', firstRing), new Blip('B', firstRing)])
112 | secondQuadrant.add([new Blip('C', firstRing), new Blip('D', firstRing)])
113 | radar = new Radar()
114 | })
115 |
116 | it('sets blip numbers starting on the first quadrant', function () {
117 | radar.addQuadrant(firstQuadrant)
118 |
119 | expect(radar.quadrants()[0].quadrant.blips()[0].blipText()).toEqual(1)
120 | expect(radar.quadrants()[0].quadrant.blips()[1].blipText()).toEqual(2)
121 | })
122 |
123 | it('continues the number from the previous quadrant set', function () {
124 | radar.addQuadrant(firstQuadrant)
125 | radar.addQuadrant(secondQuadrant)
126 |
127 | expect(radar.quadrants()[1].quadrant.blips()[0].blipText()).toEqual(3)
128 | expect(radar.quadrants()[1].quadrant.blips()[1].blipText()).toEqual(4)
129 | })
130 | })
131 |
132 | describe('alternatives', function () {
133 | it('returns a provided alternatives', function () {
134 | var radar = new Radar()
135 |
136 | var alternative1 = 'alternative1'
137 | var alternative2 = 'alternative2'
138 |
139 | radar.addAlternative(alternative1)
140 | radar.addAlternative(alternative2)
141 |
142 | expect(radar.getAlternatives()).toEqual([alternative1, alternative2])
143 | })
144 | })
145 |
146 | describe('rings : new UI', function () {
147 | let quadrant,
148 | radar,
149 | firstRing,
150 | secondRing,
151 | otherQuadrant,
152 | thirdRing,
153 | fourthRing,
154 | invalidRing,
155 | rings = []
156 |
157 | beforeEach(function () {
158 | process.env.ENVIRONMENT = 'development'
159 | firstRing = new Ring('hold', 0)
160 | secondRing = new Ring('ADOPT', 1)
161 | thirdRing = new Ring('TRiAl', 2)
162 | fourthRing = new Ring('assess', 3)
163 | invalidRing = new Ring('invalid', 3)
164 | quadrant = new Quadrant('Fourth')
165 | otherQuadrant = new Quadrant('Other')
166 | radar = new Radar()
167 | graphConfig.rings.forEach((ring, index) => rings.push(new Ring(ring, index)))
168 | radar.addRings(rings)
169 | })
170 |
171 | it('returns an array of rings in configured order and ignore the order in which blips are provided', function () {
172 | quadrant.add([
173 | new Blip('A', firstRing),
174 | new Blip('B', secondRing),
175 | new Blip('C', thirdRing),
176 | new Blip('D', fourthRing),
177 | ])
178 |
179 | radar.addQuadrant(quadrant)
180 | radar.addQuadrant(otherQuadrant)
181 | radar.addQuadrant(otherQuadrant)
182 | radar.addQuadrant(otherQuadrant)
183 |
184 | expect(radar.rings()[0].name().toLowerCase()).toEqual(secondRing.name().toLowerCase())
185 | expect(radar.rings()[1].name().toLowerCase()).toEqual(thirdRing.name().toLowerCase())
186 | expect(radar.rings()[2].name().toLowerCase()).toEqual(fourthRing.name().toLowerCase())
187 | expect(radar.rings()[3].name().toLowerCase()).toEqual(firstRing.name().toLowerCase())
188 | })
189 |
190 | it('should not return invalid rings other than the configured ones', function () {
191 | quadrant.add([new Blip('A', firstRing), new Blip('E', invalidRing)])
192 |
193 | radar.addQuadrant(quadrant)
194 |
195 | expect(radar.rings()[3].name().toLowerCase()).toEqual(firstRing.name().toLowerCase())
196 | expect(
197 | radar
198 | .rings()
199 | .map((ring) => ring.name().toLowerCase())
200 | .includes(invalidRing.name().toLowerCase()),
201 | ).toBe(false)
202 | })
203 | })
204 | })
205 |
--------------------------------------------------------------------------------
/spec/models/ring-spec.js:
--------------------------------------------------------------------------------
1 | const Ring = require('../../src/models/ring')
2 |
3 | describe('Ring', function () {
4 | it('has a name', function () {
5 | var ring = Ring('My Ring')
6 |
7 | expect(ring.name()).toEqual('My Ring')
8 | })
9 |
10 | it('has a order', function () {
11 | var ring = new Ring('My Ring', 0)
12 |
13 | expect(ring.order()).toEqual(0)
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/spec/support/jasmine.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec_dir": "spec",
3 | "spec_files": ["**/*[sS]pec.js"],
4 | "helpers": ["helpers/**/*.js"],
5 | "stopSpecOnExpectationFailure": false,
6 | "random": false
7 | }
8 |
--------------------------------------------------------------------------------
/spec/util/contentValidator-spec.js:
--------------------------------------------------------------------------------
1 | const ContentValidator = require('../../src/util/contentValidator')
2 | const MalformedDataError = require('../../src/exceptions/malformedDataError')
3 | const ExceptionMessages = require('../../src/util/exceptionMessages')
4 |
5 | describe('ContentValidator', function () {
6 | describe('verifyContent', function () {
7 | it('does not return anything if content is valid', function () {
8 | var columnNames = ['name', 'ring', 'quadrant', 'isNew', 'description']
9 | var contentValidator = new ContentValidator(columnNames)
10 |
11 | expect(contentValidator.verifyContent()).not.toBeDefined()
12 | })
13 |
14 | it('raises an error if content is empty', function () {
15 | var columnNames = []
16 | var contentValidator = new ContentValidator(columnNames)
17 |
18 | expect(function () {
19 | contentValidator.verifyContent()
20 | }).toThrow(new MalformedDataError(ExceptionMessages.MISSING_CONTENT))
21 | })
22 | })
23 |
24 | describe('verifyHeaders', function () {
25 | it('raises an error if one of the headers is empty', function () {
26 | var columnNames = ['ring', 'quadrant', 'isNew', 'description']
27 | var contentValidator = new ContentValidator(columnNames)
28 |
29 | expect(function () {
30 | contentValidator.verifyHeaders()
31 | }).toThrow(new MalformedDataError(ExceptionMessages.MISSING_HEADERS))
32 | })
33 |
34 | it('does not return anything if the all required headers are present', function () {
35 | var columnNames = ['name', 'ring', 'quadrant', 'isNew', 'description']
36 | var contentValidator = new ContentValidator(columnNames)
37 |
38 | expect(contentValidator.verifyHeaders()).not.toBeDefined()
39 | })
40 |
41 | it('does not care about white spaces in the headers', function () {
42 | var columnNames = [' name', 'ring ', ' quadrant', 'isNew ', ' description ']
43 | var contentValidator = new ContentValidator(columnNames)
44 |
45 | expect(contentValidator.verifyHeaders()).not.toBeDefined()
46 | })
47 | })
48 | })
49 |
--------------------------------------------------------------------------------
/spec/util/htmlUtil-spec.js:
--------------------------------------------------------------------------------
1 | const { getElementWidth, getElementHeight, decodeHTML } = require('../../src/util/htmlUtil')
2 |
3 | describe('HTML Utils', () => {
4 | let mockWidth, mockHeight, mockD3Element
5 |
6 | beforeEach(() => {
7 | mockWidth = 10
8 | mockHeight = 10
9 |
10 | mockD3Element = {
11 | node: function () {
12 | return {
13 | getBoundingClientRect: function () {
14 | return {
15 | width: mockWidth,
16 | height: mockHeight,
17 | }
18 | },
19 | }
20 | },
21 | }
22 | })
23 |
24 | it('should return width of D3 element', () => {
25 | expect(getElementWidth(mockD3Element)).toEqual(mockWidth)
26 | })
27 |
28 | it('should return height of D3 element', () => {
29 | expect(getElementHeight(mockD3Element)).toEqual(mockHeight)
30 | })
31 |
32 | it('should decode encoded HTML entity', () => {
33 | expect(decodeHTML('<>&"'')).toEqual(`<>&"'`)
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/spec/util/inputSanitizer-spec.js:
--------------------------------------------------------------------------------
1 | const InputSanitizer = require('../../src/util/inputSanitizer')
2 |
3 | describe('InputSanitizer', function () {
4 | var sanitizer, rawBlip, blip
5 |
6 | beforeAll(function () {
7 | sanitizer = new InputSanitizer()
8 | var description = "Hello there heading "
9 | rawBlip = {
10 | name: "Hello there blip ",
11 | description: description,
12 | ring: 'Adopt ',
13 | quadrant: 'techniques and tools ',
14 | isNew: 'true ',
15 | }
16 |
17 | blip = sanitizer.sanitize(rawBlip)
18 | })
19 |
20 | it('strips out script tags from blip descriptions', function () {
21 | expect(blip.description).toEqual('Hello there heading ')
22 | })
23 |
24 | it('strips out all tags from blip name', function () {
25 | expect(blip.name).toEqual('Hello there blip')
26 | })
27 |
28 | it('strips out all tags from blip status', function () {
29 | expect(blip.isNew).toEqual('true')
30 | })
31 |
32 | it('strips out all tags from blip ring', function () {
33 | expect(blip.ring).toEqual('Adopt')
34 | })
35 |
36 | it('strips out all tags from blip quadrant', function () {
37 | expect(blip.quadrant).toEqual('techniques and tools')
38 | })
39 |
40 | it('trims white spaces in keys and values', function () {
41 | rawBlip = {
42 | ' name': ' Some name ',
43 | ' ring ': ' Some ring name ',
44 | }
45 | blip = sanitizer.sanitize(rawBlip)
46 |
47 | expect(blip.name).toEqual('Some name')
48 | expect(blip.ring).toEqual('Some ring name')
49 | })
50 | })
51 |
52 | describe('Input Santizer for Protected sheet', function () {
53 | var sanitizer, rawBlip, blip, header
54 | beforeAll(function () {
55 | sanitizer = new InputSanitizer()
56 | header = ['name', 'quadrant', 'ring', 'isNew', 'description']
57 |
58 | rawBlip = [
59 | "Hello there blip ",
60 | 'techniques & tools ',
61 | "Adopt ",
62 | 'true ',
63 | "Hello there heading ",
64 | ]
65 |
66 | blip = sanitizer.sanitizeForProtectedSheet(rawBlip, header)
67 | })
68 |
69 | it('strips out script tags from blip descriptions', function () {
70 | expect(blip.description).toEqual('Hello there heading ')
71 | })
72 |
73 | it('strips out all tags from blip name', function () {
74 | expect(blip.name).toEqual('Hello there blip')
75 | })
76 |
77 | it('strips out all tags from blip status', function () {
78 | expect(blip.isNew).toEqual('true')
79 | })
80 |
81 | it('strips out all tags from blip ring', function () {
82 | expect(blip.ring).toEqual('Adopt')
83 | })
84 |
85 | it('strips out all tags from blip quadrant', function () {
86 | expect(blip.quadrant).toEqual('techniques & tools')
87 | })
88 |
89 | it('trims white spaces in keys and values', function () {
90 | rawBlip = {
91 | ' name': ' Some name ',
92 | ' ring ': ' Some ring name ',
93 | }
94 | blip = sanitizer.sanitize(rawBlip)
95 |
96 | expect(blip.name).toEqual('Some name')
97 | expect(blip.ring).toEqual('Some ring name')
98 | })
99 |
100 | it('should return blip with empty values if headers are empty', function () {
101 | const emptyHeader = []
102 | const emptyBlip = sanitizer.sanitizeForProtectedSheet(rawBlip, emptyHeader)
103 |
104 | expect(emptyBlip).toStrictEqual({
105 | name: '',
106 | description: '',
107 | ring: '',
108 | quadrant: '',
109 | isNew: '',
110 | status: '',
111 | })
112 | })
113 | })
114 |
--------------------------------------------------------------------------------
/spec/util/mathUtils-spec.js:
--------------------------------------------------------------------------------
1 | const { toRadian } = require('../../src/util/mathUtils')
2 |
3 | describe('Math Utils', function () {
4 | it('should convert into radian', function () {
5 | expect(toRadian(180)).toEqual(Math.PI)
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/spec/util/queryParamProcessor-spec.js:
--------------------------------------------------------------------------------
1 | const QueryParams = require('../../src/util/queryParamProcessor')
2 |
3 | describe('QueryParams', function () {
4 | it('retrieves one parameter', function () {
5 | var params = QueryParams('param1=value1')
6 |
7 | expect(params.param1).toEqual('value1')
8 | })
9 |
10 | it('retrieves zero parameter', function () {
11 | var params = QueryParams('')
12 |
13 | expect(params).toEqual({})
14 | })
15 |
16 | it('retrieves two parameters', function () {
17 | var params = QueryParams('param1=value1¶m2=value2')
18 |
19 | expect(params.param1).toEqual('value1')
20 | expect(params.param2).toEqual('value2')
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/spec/util/ringCalculator-spec.js:
--------------------------------------------------------------------------------
1 | const RingCalculator = require('../../src/util/ringCalculator')
2 |
3 | describe('ringCalculator', function () {
4 | var ringLength, radarSize, ringCalculator
5 | beforeAll(function () {
6 | ringLength = 4
7 | radarSize = 500
8 | ringCalculator = new RingCalculator(ringLength, radarSize)
9 | })
10 |
11 | it('sums up the sequences', function () {
12 | expect(ringCalculator.sum(ringLength)).toEqual(16)
13 | })
14 |
15 | it('calculates the correct radius', function () {
16 | expect(ringCalculator.getRadius(ringLength)).toEqual(radarSize)
17 | })
18 |
19 | it('calculates the ring radius', function () {
20 | expect(ringCalculator.getRingRadius(1)).toEqual(158)
21 | })
22 |
23 | it('calculates the ring radius for invalid ring as 0', function () {
24 | expect(ringCalculator.getRingRadius(10)).toEqual(0)
25 | })
26 | })
27 |
--------------------------------------------------------------------------------
/spec/util/sheet-spec.js:
--------------------------------------------------------------------------------
1 | const Sheet = require('../../src/util/sheet')
2 | const config = require('../../src/config')
3 |
4 | jest.mock('../../src/config')
5 | describe('sheet', function () {
6 | const oldEnv = process.env
7 | beforeEach(() => {
8 | config.mockReturnValue({ featureToggles: { UIRefresh2022: true } })
9 | process.env.API_KEY = 'API_KEY'
10 | })
11 |
12 | afterEach(() => {
13 | jest.clearAllMocks()
14 | process.env = oldEnv
15 | })
16 |
17 | it('knows to find the sheet id from published URL', function () {
18 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId/pubhtml')
19 |
20 | expect(sheet.id).toEqual('sheetId')
21 | })
22 |
23 | it('knows to find the sheet id from publicly shared URL having query params', function () {
24 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId?abc=xyz')
25 |
26 | expect(sheet.id).toEqual('sheetId')
27 | })
28 |
29 | it('knows to find the sheet id from publicly shared URL having extra path and query params', function () {
30 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId/edit?usp=sharing')
31 |
32 | expect(sheet.id).toEqual('sheetId')
33 | })
34 |
35 | it('knows to find the sheet id from publicly shared URL having no extra path or query params', function () {
36 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId')
37 |
38 | expect(sheet.id).toEqual('sheetId')
39 | })
40 |
41 | it('knows to find the sheet id from publicly shared URL with trailing slash', function () {
42 | const sheet = new Sheet('https://docs.google.com/spreadsheets/d/sheetId/')
43 |
44 | expect(sheet.id).toEqual('sheetId')
45 | })
46 |
47 | it('can identify a plain sheet ID', function () {
48 | const sheet = new Sheet('sheetId')
49 |
50 | expect(sheet.id).toEqual('sheetId')
51 | })
52 |
53 | it('calls back with nothing if the sheet exists', () => {
54 | const mockCallback = jest.fn()
55 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 200, response: 'response' }
56 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock)
57 |
58 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y')
59 | sheet.validate(mockCallback)
60 | xhrMock.onreadystatechange(new Event(''))
61 |
62 | expect(xhrMock.open).toHaveBeenCalledTimes(1)
63 | expect(xhrMock.open).toHaveBeenCalledWith(
64 | 'GET',
65 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y',
66 | true,
67 | )
68 | expect(xhrMock.send).toHaveBeenCalledTimes(1)
69 | expect(xhrMock.send).toHaveBeenCalledWith(null)
70 | expect(mockCallback).toHaveBeenCalledTimes(1)
71 | expect(mockCallback).toHaveBeenCalledWith(null, 'API_KEY')
72 | })
73 |
74 | it('calls back with error if sheet does not exist', function () {
75 | const mockCallback = jest.fn()
76 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 401, response: 'response' }
77 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock)
78 |
79 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y')
80 | sheet.validate(mockCallback)
81 | xhrMock.onreadystatechange(new Event(''))
82 |
83 | expect(xhrMock.open).toHaveBeenCalledTimes(1)
84 | expect(xhrMock.open).toHaveBeenCalledWith(
85 | 'GET',
86 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y',
87 | true,
88 | )
89 | expect(xhrMock.send).toHaveBeenCalledTimes(1)
90 | expect(xhrMock.send).toHaveBeenCalledWith(null)
91 | expect(mockCallback).toHaveBeenCalledTimes(1)
92 | expect(mockCallback).toHaveBeenCalledWith({ message: 'UNAUTHORIZED' }, 'API_KEY')
93 | })
94 |
95 | it('should give the sheet not found error with new message', () => {
96 | const errorMessage = 'Oops! We can’t find the Google Sheet you’ve entered, please check the URL of your sheet.'
97 | const mockCallback = jest.fn()
98 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 404, response: 'response' }
99 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock)
100 |
101 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y')
102 | sheet.validate(mockCallback)
103 | xhrMock.onreadystatechange(new Event(''))
104 |
105 | expect(xhrMock.open).toHaveBeenCalledTimes(1)
106 | expect(xhrMock.open).toHaveBeenCalledWith(
107 | 'GET',
108 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y',
109 | true,
110 | )
111 | expect(xhrMock.send).toHaveBeenCalledTimes(1)
112 | expect(xhrMock.send).toHaveBeenCalledWith(null)
113 | expect(mockCallback).toHaveBeenCalledTimes(1)
114 | expect(mockCallback).toHaveBeenCalledWith({ message: errorMessage }, 'API_KEY')
115 | })
116 |
117 | it('should give the sheet not found error with old message', () => {
118 | const errorMessage = 'Oops! We can’t find the Google Sheet you’ve entered. Can you check the URL?'
119 | const mockCallback = jest.fn()
120 | const xhrMock = { open: jest.fn(), send: jest.fn(), readyState: 4, status: 404, response: 'response' }
121 | jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock)
122 | config.mockReturnValue({ featureToggles: { UIRefresh2022: false } })
123 |
124 | const sheet = new Sheet('http://example.com/a/b/c/d/?x=y')
125 | sheet.validate(mockCallback)
126 | xhrMock.onreadystatechange(new Event(''))
127 |
128 | expect(xhrMock.open).toHaveBeenCalledTimes(1)
129 | expect(xhrMock.open).toHaveBeenCalledWith(
130 | 'GET',
131 | 'https://docs.google.com/spreadsheets/d/http://example.com/a/b/c/d/?x=y',
132 | true,
133 | )
134 | expect(xhrMock.send).toHaveBeenCalledTimes(1)
135 | expect(xhrMock.send).toHaveBeenCalledWith(null)
136 | expect(mockCallback).toHaveBeenCalledTimes(1)
137 | expect(mockCallback).toHaveBeenCalledWith({ message: errorMessage }, 'API_KEY')
138 | })
139 | })
140 |
--------------------------------------------------------------------------------
/spec/util/urlUtils-spec.js:
--------------------------------------------------------------------------------
1 | const { constructSheetUrl, getDocumentOrSheetId, getSheetName } = require('../../src/util/urlUtils')
2 | const queryParams = require('../../src/util/queryParamProcessor')
3 |
4 | jest.mock('../../src/util/queryParamProcessor')
5 | describe('Url Utils', () => {
6 | it('should construct the sheet url', () => {
7 | queryParams.mockReturnValue({ documentId: 'documentId' })
8 | delete window.location
9 | window.location = Object.create(window)
10 | window.location.href = 'https://thoughtworks.com/radar?sheet=radar'
11 | window.location.search = '?'
12 | const sheetUrl = constructSheetUrl('radar')
13 |
14 | expect(sheetUrl).toStrictEqual('https://thoughtworks.com/radar?documentId=documentId&sheetName=radar')
15 | expect(queryParams).toHaveBeenCalledTimes(1)
16 | })
17 |
18 | it('should construct the sheet url if sheetId is used', () => {
19 | queryParams.mockReturnValue({ sheetId: 'sheetId' })
20 | delete window.location
21 | window.location = Object.create(window)
22 | window.location.href = 'https://thoughtworks.com/radar?sheet=radar'
23 | window.location.search = '?'
24 | const sheetUrl = constructSheetUrl('radar')
25 |
26 | expect(sheetUrl).toStrictEqual('https://thoughtworks.com/radar?sheetId=sheetId&sheetName=radar')
27 | expect(queryParams).toHaveBeenCalledTimes(1)
28 | })
29 |
30 | it('should prioritize documentId before legacy sheetId', () => {
31 | queryParams.mockReturnValue({ documentId: 'documentId', sheetId: 'sheetId' })
32 | delete window.location
33 | window.location = Object.create(window)
34 | window.location.href = 'https://thoughtworks.com/radar?documentId=documentId&sheetId=sheetId'
35 | window.location.search = '?'
36 |
37 | const id = getDocumentOrSheetId()
38 |
39 | expect(id).toEqual('documentId')
40 | })
41 |
42 | it('supports documentId', () => {
43 | queryParams.mockReturnValue({ documentId: 'documentId' })
44 | delete window.location
45 | window.location = Object.create(window)
46 | window.location.href = 'https://thoughtworks.com/radar?documentId=documentId'
47 | window.location.search = '?'
48 |
49 | const id = getDocumentOrSheetId()
50 |
51 | expect(id).toEqual('documentId')
52 | })
53 |
54 | it('supports sheetId', () => {
55 | queryParams.mockReturnValue({ sheetId: 'sheetId' })
56 | delete window.location
57 | window.location = Object.create(window)
58 | window.location.href = 'https://thoughtworks.com/radar?sheetId=sheetId'
59 | window.location.search = '?'
60 |
61 | const id = getDocumentOrSheetId()
62 |
63 | expect(id).toEqual('sheetId')
64 | })
65 |
66 | it('supports sheetName', () => {
67 | queryParams.mockReturnValue({ sheetName: 'sheetName' })
68 | delete window.location
69 | window.location = Object.create(window)
70 | window.location.href = 'https://thoughtworks.com/radar?sheetName=sheetName'
71 | window.location.search = '?'
72 |
73 | const sheetName = getSheetName()
74 |
75 | expect(sheetName).toEqual('sheetName')
76 | })
77 | })
78 |
--------------------------------------------------------------------------------
/spec/util/util-spec.js:
--------------------------------------------------------------------------------
1 | const { getRingIdString } = require('../../src/util/stringUtil')
2 |
3 | describe('Utils', () => {
4 | it('should return id string for ring name', () => {
5 | expect(getRingIdString('Tools')).toStrictEqual('tools')
6 | expect(getRingIdString('Platform Tools')).toStrictEqual('platform-tools')
7 | expect(getRingIdString('Languages & Frameworks')).toStrictEqual('languages---frameworks')
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/src/analytics.js:
--------------------------------------------------------------------------------
1 | if (process.env.GTM_ID) {
2 | ;(function (w, d, s, l, i) {
3 | w[l] = w[l] || []
4 | w[l].push({ 'gtm.start': new Date().getTime(), event: 'analytics.js' })
5 | var f = d.getElementsByTagName(s)[0]
6 | var j = d.createElement(s)
7 | var dl = l !== 'dataLayer' ? '&l=' + l : ''
8 | j.async = true
9 | j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl
10 | f.parentNode.insertBefore(j, f)
11 | })(window, document, 'script', 'dataLayer', process.env.GTM_ID)
12 | }
13 | if (process.env.ADOBE_LAUNCH_SCRIPT_URL) {
14 | ;(function (w, d, s, i) {
15 | const l = d.createElement(s)
16 | l.async = true
17 | l.src = i
18 | l.type = 'text/javascript'
19 | d.head.append(l)
20 | })(window, document, 'script', process.env.ADOBE_LAUNCH_SCRIPT_URL)
21 | }
22 |
--------------------------------------------------------------------------------
/src/common.js:
--------------------------------------------------------------------------------
1 | require('./stylesheets/base.scss')
2 | require('./images/tech-radar-landing-page-wide.png')
3 | require('./images/tw-logo.png')
4 | require('./images/favicon.ico')
5 | require('./images/search-logo-2x.svg')
6 | require('./images/banner-image-mobile.jpg')
7 | require('./images/banner-image-desktop.jpg')
8 | require('./images/new.svg')
9 | require('./images/moved.svg')
10 | require('./images/existing.svg')
11 | require('./images/no-change.svg')
12 | require('./images/arrow-icon.svg')
13 | require('./images/first-quadrant-btn-bg.svg')
14 | require('./images/second-quadrant-btn-bg.svg')
15 | require('./images/third-quadrant-btn-bg.svg')
16 | require('./images/fourth-quadrant-btn-bg.svg')
17 | require('./images/arrow-white-icon.svg')
18 | require('./images/search-active-wave.svg')
19 | require('./images/pdf_banner.png')
20 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | const config = () => {
2 | const env = {
3 | production: {
4 | featureToggles: {
5 | UIRefresh2022: true,
6 | },
7 | },
8 | development: {
9 | featureToggles: {
10 | UIRefresh2022: true,
11 | },
12 | },
13 | }
14 | return process.env.ENVIRONMENT ? env[process.env.ENVIRONMENT] : env
15 | }
16 | module.exports = config
17 |
--------------------------------------------------------------------------------
/src/error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/exceptions/fileNotFoundError.js:
--------------------------------------------------------------------------------
1 | class FileNotFoundError extends Error {
2 | constructor(message) {
3 | super(message)
4 | this.message = message
5 | }
6 | }
7 |
8 | module.exports = FileNotFoundError
9 |
--------------------------------------------------------------------------------
/src/exceptions/invalidConfigError.js:
--------------------------------------------------------------------------------
1 | class InvalidConfigError extends Error {
2 | constructor(message) {
3 | super(message)
4 | this.message = message
5 | }
6 | }
7 |
8 | module.exports = InvalidConfigError
9 |
--------------------------------------------------------------------------------
/src/exceptions/invalidContentError.js:
--------------------------------------------------------------------------------
1 | class InvalidContentError extends Error {
2 | constructor(message) {
3 | super(message)
4 | this.message = message
5 | }
6 | }
7 |
8 | module.exports = InvalidContentError
9 |
--------------------------------------------------------------------------------
/src/exceptions/malformedDataError.js:
--------------------------------------------------------------------------------
1 | const MalformedDataError = function (message) {
2 | this.message = message
3 | }
4 |
5 | Object.setPrototypeOf(MalformedDataError, Error)
6 | MalformedDataError.prototype = Object.create(Error.prototype)
7 | MalformedDataError.prototype.name = 'MalformedDataError'
8 | MalformedDataError.prototype.message = ''
9 | MalformedDataError.prototype.constructor = MalformedDataError
10 |
11 | module.exports = MalformedDataError
12 |
--------------------------------------------------------------------------------
/src/exceptions/sheetNotFoundError.js:
--------------------------------------------------------------------------------
1 | const SheetNotFoundError = function (message) {
2 | this.message = message
3 | }
4 |
5 | Object.setPrototypeOf(SheetNotFoundError, Error)
6 | SheetNotFoundError.prototype = Object.create(Error.prototype)
7 | SheetNotFoundError.prototype.name = 'SheetNotFoundError'
8 | SheetNotFoundError.prototype.message = ''
9 | SheetNotFoundError.prototype.constructor = SheetNotFoundError
10 |
11 | module.exports = SheetNotFoundError
12 |
--------------------------------------------------------------------------------
/src/exceptions/unauthorizedError.js:
--------------------------------------------------------------------------------
1 | const UnauthorizedError = function (message) {
2 | this.message = message
3 | }
4 |
5 | Object.setPrototypeOf(UnauthorizedError, Error)
6 | UnauthorizedError.prototype = Object.create(Error.prototype)
7 | UnauthorizedError.prototype.name = 'UnauthorizedError'
8 | UnauthorizedError.prototype.message = ''
9 | UnauthorizedError.prototype.constructor = UnauthorizedError
10 |
11 | module.exports = UnauthorizedError
12 |
--------------------------------------------------------------------------------
/src/graphing/components/alternativeRadars.js:
--------------------------------------------------------------------------------
1 | const d3 = require('d3')
2 | const { constructSheetUrl } = require('../../util/urlUtils')
3 |
4 | function renderAlternativeRadars(radarFooter, alternatives, currentSheet) {
5 | const alternativesContainer = radarFooter.append('div').classed('alternative-radars', true)
6 |
7 | for (let i = 0; alternatives.length > 0; i++) {
8 | const list = alternatives.splice(0, 5)
9 |
10 | const alternativesList = alternativesContainer
11 | .append('ul')
12 | .classed(`alternative-radars__list`, true)
13 | .classed(`alternative-radars__list__row-${i}`, true)
14 |
15 | list.forEach(function (alternative) {
16 | const alternativeListItem = alternativesList.append('li').classed('alternative-radars__list-item', true)
17 |
18 | alternativeListItem
19 | .append('a')
20 | .classed('alternative-radars__list-item-link', true)
21 | .attr('href', constructSheetUrl(alternative))
22 | .attr('role', 'tab')
23 | .text(alternative)
24 |
25 | if (currentSheet === alternative) {
26 | alternativeListItem.classed('active', true)
27 |
28 | d3.selectAll('.alternative-radars__list-item a').attr('aria-selected', null)
29 | alternativeListItem.select('a').attr('aria-selected', 'true')
30 | }
31 | })
32 | }
33 | }
34 |
35 | module.exports = {
36 | renderAlternativeRadars,
37 | }
38 |
--------------------------------------------------------------------------------
/src/graphing/components/banner.js:
--------------------------------------------------------------------------------
1 | const d3 = require('d3')
2 |
3 | const config = require('../../config')
4 | const { addPdfCoverTitle } = require('../pdfPage')
5 | const featureToggles = config().featureToggles
6 |
7 | function renderBanner(renderFullRadar) {
8 | if (featureToggles.UIRefresh2022) {
9 | const documentTitle = document.title[0].toUpperCase() + document.title.slice(1)
10 |
11 | document.title = documentTitle
12 | d3.select('.hero-banner__wrapper').append('p').classed('hero-banner__subtitle-text', true).text(document.title)
13 | d3.select('.hero-banner__title-text').on('click', renderFullRadar)
14 |
15 | addPdfCoverTitle(documentTitle)
16 | } else {
17 | const header = d3.select('body').insert('header', '#radar')
18 | header
19 | .append('div')
20 | .attr('class', 'radar-title')
21 | .append('div')
22 | .attr('class', 'radar-title__text')
23 | .append('h1')
24 | .text(document.title)
25 | .style('cursor', 'pointer')
26 | .on('click', renderFullRadar)
27 |
28 | header
29 | .select('.radar-title')
30 | .append('div')
31 | .attr('class', 'radar-title__logo')
32 | .html(' ')
33 | }
34 | }
35 |
36 | module.exports = {
37 | renderBanner,
38 | }
39 |
--------------------------------------------------------------------------------
/src/graphing/components/buttons.js:
--------------------------------------------------------------------------------
1 | function renderButtons(radarFooter) {
2 | const buttonsRow = radarFooter.append('div').classed('buttons', true)
3 |
4 | buttonsRow
5 | .append('button')
6 | .classed('buttons__wave-btn', true)
7 | .text('Print this Radar')
8 | .on('click', window.print.bind(window))
9 |
10 | buttonsRow
11 | .append('a')
12 | .classed('buttons__flamingo-btn', true)
13 | .attr('href', window.location.href.substring(0, window.location.href.indexOf(window.location.search)))
14 | .text('Generate new Radar')
15 | }
16 |
17 | module.exports = {
18 | renderButtons,
19 | }
20 |
--------------------------------------------------------------------------------
/src/graphing/components/quadrantSubnav.js:
--------------------------------------------------------------------------------
1 | const d3 = require('d3')
2 | const { selectRadarQuadrant, removeScrollListener } = require('./quadrants')
3 | const { getRingIdString } = require('../../util/stringUtil')
4 | const { uiConfig } = require('../config')
5 |
6 | function addListItem(quadrantList, name, callback) {
7 | quadrantList
8 | .append('li')
9 | .attr('id', `subnav-item-${getRingIdString(name)}`)
10 | .classed('quadrant-subnav__list-item', true)
11 | .attr('title', name)
12 | .append('button')
13 | .classed('quadrant-subnav__list-item__button', true)
14 | .attr('role', 'tab')
15 | .text(name)
16 | .on('click', function (e) {
17 | d3.select('#radar').classed('no-blips', false)
18 | d3.select('#auto-complete').property('value', '')
19 | removeScrollListener()
20 |
21 | d3.select('.graph-header').node().scrollIntoView({
22 | behavior: 'smooth',
23 | })
24 |
25 | d3.select('span.quadrant-subnav__dropdown-selector').text(e.target.innerText)
26 |
27 | const subnavArrow = d3.select('.quadrant-subnav__dropdown-arrow')
28 | subnavArrow.classed('rotate', !d3.select('span.quadrant-subnav__dropdown-arrow').classed('rotate'))
29 | quadrantList.classed('show', !d3.select('ul.quadrant-subnav__list').classed('show'))
30 |
31 | const subnavDropdown = d3.select('.quadrant-subnav__dropdown')
32 | subnavDropdown.attr('aria-expanded', subnavDropdown.attr('aria-expanded') === 'false' ? 'true' : 'false')
33 |
34 | d3.selectAll('.blip-list__item-container.expand').classed('expand', false)
35 |
36 | if (callback) {
37 | callback()
38 | }
39 | })
40 | }
41 |
42 | function renderQuadrantSubnav(radarHeader, quadrants, renderFullRadar) {
43 | const subnavContainer = radarHeader.append('nav').classed('quadrant-subnav', true)
44 |
45 | const subnavDropdown = subnavContainer
46 | .append('div')
47 | .classed('quadrant-subnav__dropdown', true)
48 | .attr('aria-expanded', 'false')
49 | subnavDropdown.append('span').classed('quadrant-subnav__dropdown-selector', true).text('All quadrants')
50 | const subnavArrow = subnavDropdown.append('span').classed('quadrant-subnav__dropdown-arrow', true)
51 |
52 | const quadrantList = subnavContainer.append('ul').classed('quadrant-subnav__list', true)
53 | addListItem(quadrantList, 'All quadrants', renderFullRadar)
54 | d3.select('li.quadrant-subnav__list-item').classed('active-item', true).select('button').attr('aria-selected', 'true')
55 |
56 | subnavDropdown.on('click', function () {
57 | subnavArrow.classed('rotate', !d3.select('span.quadrant-subnav__dropdown-arrow').classed('rotate'))
58 | quadrantList.classed('show', !d3.select('ul.quadrant-subnav__list').classed('show'))
59 |
60 | subnavDropdown.attr('aria-expanded', subnavDropdown.attr('aria-expanded') === 'false' ? 'true' : 'false')
61 | })
62 |
63 | quadrants.forEach(function (quadrant) {
64 | addListItem(quadrantList, quadrant.quadrant.name(), () =>
65 | selectRadarQuadrant(quadrant.order, quadrant.startAngle, quadrant.quadrant.name()),
66 | )
67 | })
68 |
69 | const subnavOffset =
70 | (window.innerWidth < 1024 ? uiConfig.tabletBannerHeight : uiConfig.bannerHeight) + uiConfig.headerHeight
71 |
72 | window.addEventListener('scroll', function () {
73 | if (subnavOffset <= window.scrollY) {
74 | d3.select('.quadrant-subnav').classed('sticky', true)
75 | d3.select('.search-container').classed('sticky-offset', true)
76 | } else {
77 | d3.select('.quadrant-subnav').classed('sticky', false)
78 | d3.select('.search-container').classed('sticky-offset', false)
79 | }
80 | })
81 | }
82 |
83 | module.exports = {
84 | renderQuadrantSubnav,
85 | }
86 |
--------------------------------------------------------------------------------
/src/graphing/components/quadrantTables.js:
--------------------------------------------------------------------------------
1 | const d3 = require('d3')
2 | const { graphConfig, getScale, uiConfig } = require('../config')
3 | const { stickQuadrantOnScroll } = require('./quadrants')
4 | const { removeAllSpaces } = require('../../util/stringUtil')
5 |
6 | function fadeOutAllBlips() {
7 | d3.selectAll('g > a.blip-link').attr('opacity', 0.3)
8 | }
9 |
10 | function fadeInSelectedBlip(selectedBlipOnGraph) {
11 | selectedBlipOnGraph.attr('opacity', 1.0)
12 | }
13 |
14 | function highlightBlipInTable(selectedBlip) {
15 | selectedBlip.classed('highlight', true)
16 | }
17 |
18 | function highlightBlipInGraph(blipIdToFocus) {
19 | fadeOutAllBlips()
20 | const selectedBlipOnGraph = d3.select(`g > a.blip-link[data-blip-id='${blipIdToFocus}'`)
21 | fadeInSelectedBlip(selectedBlipOnGraph)
22 | }
23 |
24 | function renderBlipDescription(blip, ring, quadrant, tip, groupBlipTooltipText) {
25 | let blipTableItem = d3.select(`.quadrant-table.${quadrant.order} ul[data-ring-order='${ring.order()}']`)
26 | if (!groupBlipTooltipText) {
27 | blipTableItem = blipTableItem.append('li').classed('blip-list__item', true)
28 | const blipItemDiv = blipTableItem
29 | .append('div')
30 | .classed('blip-list__item-container', true)
31 | .attr('data-blip-id', blip.id())
32 |
33 | if (blip.groupIdInGraph()) {
34 | blipItemDiv.attr('data-group-id', blip.groupIdInGraph())
35 | }
36 |
37 | const blipItemContainer = blipItemDiv
38 | .append('button')
39 | .classed('blip-list__item-container__name', true)
40 | .attr('aria-expanded', 'false')
41 | .attr('aria-controls', `blip-description-${blip.id()}`)
42 | .attr('aria-hidden', 'true')
43 | .attr('tabindex', -1)
44 | .on('click search-result-click', function (e) {
45 | e.stopPropagation()
46 |
47 | const expandFlag = d3.select(e.target.parentElement).classed('expand')
48 |
49 | d3.selectAll('.blip-list__item-container.expand').classed('expand', false)
50 | d3.select(e.target.parentElement).classed('expand', !expandFlag)
51 |
52 | d3.selectAll('.blip-list__item-container__name').attr('aria-expanded', 'false')
53 | d3.select('.blip-list__item-container.expand .blip-list__item-container__name').attr('aria-expanded', 'true')
54 |
55 | if (window.innerWidth >= uiConfig.tabletViewWidth) {
56 | stickQuadrantOnScroll()
57 | }
58 | })
59 |
60 | blipItemContainer
61 | .append('span')
62 | .classed('blip-list__item-container__name-value', true)
63 | .text(`${blip.blipText()}. ${blip.name()}`)
64 | blipItemContainer.append('span').classed('blip-list__item-container__name-arrow', true)
65 |
66 | blipItemDiv
67 | .append('div')
68 | .classed('blip-list__item-container__description', true)
69 | .attr('id', `blip-description-${blip.id()}`)
70 | .html(blip.description())
71 | }
72 | const blipGraphItem = d3.select(`g a#blip-link-${removeAllSpaces(blip.id())}`)
73 | const mouseOver = function (e) {
74 | const targetElement = e.target.classList.contains('blip-link') ? e.target : e.target.parentElement
75 | const isGroupIdInGraph = !targetElement.classList.contains('blip-link') ? true : false
76 | const blipWrapper = d3.select(targetElement)
77 | const blipIdToFocus = blip.groupIdInGraph() ? blipWrapper.attr('data-group-id') : blipWrapper.attr('data-blip-id')
78 | const selectedBlipOnGraph = d3.select(`g > a.blip-link[data-blip-id='${blipIdToFocus}'`)
79 | highlightBlipInGraph(blipIdToFocus)
80 | highlightBlipInTable(blipTableItem)
81 |
82 | const isQuadrantView = d3.select('svg#radar-plot').classed('quadrant-view')
83 | const displayToolTip = blip.isGroup() ? !isQuadrantView : !blip.groupIdInGraph()
84 | const toolTipText = blip.isGroup() ? groupBlipTooltipText : blip.name()
85 |
86 | if (displayToolTip && !isGroupIdInGraph) {
87 | tip.show(toolTipText, selectedBlipOnGraph.node())
88 |
89 | const selectedBlipCoords = selectedBlipOnGraph.node().getBoundingClientRect()
90 |
91 | const tipElement = d3.select('div.d3-tip')
92 | const tipElementCoords = tipElement.node().getBoundingClientRect()
93 |
94 | tipElement
95 | .style(
96 | 'left',
97 | `${parseInt(
98 | selectedBlipCoords.left + window.scrollX - tipElementCoords.width / 2 + selectedBlipCoords.width / 2,
99 | )}px`,
100 | )
101 | .style('top', `${parseInt(selectedBlipCoords.top + window.scrollY - tipElementCoords.height)}px`)
102 | }
103 | }
104 |
105 | const mouseOut = function () {
106 | d3.selectAll('g > a.blip-link').attr('opacity', 1.0)
107 | blipTableItem.classed('highlight', false)
108 | tip.hide().style('left', 0).style('top', 0)
109 | }
110 |
111 | const blipClick = function (e) {
112 | const isQuadrantView = d3.select('svg#radar-plot').classed('quadrant-view')
113 | const targetElement = e.target.classList.contains('blip-link') ? e.target : e.target.parentElement
114 | if (isQuadrantView) {
115 | e.stopPropagation()
116 | }
117 |
118 | const blipId = d3.select(targetElement).attr('data-blip-id')
119 | highlightBlipInGraph(blipId)
120 |
121 | d3.selectAll('.blip-list__item-container.expand').classed('expand', false)
122 |
123 | let selectedBlipContainer = d3.select(`.blip-list__item-container[data-blip-id="${blipId}"`)
124 | selectedBlipContainer.classed('expand', true)
125 |
126 | setTimeout(
127 | () => {
128 | if (window.innerWidth >= uiConfig.tabletViewWidth) {
129 | stickQuadrantOnScroll()
130 | }
131 |
132 | const isGroupBlip = isNaN(parseInt(blipId))
133 | if (isGroupBlip) {
134 | selectedBlipContainer = d3.select(`.blip-list__item-container[data-group-id="${blipId}"`)
135 | }
136 | const elementToFocus = selectedBlipContainer.select('button.blip-list__item-container__name')
137 | elementToFocus.node()?.scrollIntoView({
138 | behavior: 'smooth',
139 | })
140 | },
141 | isQuadrantView ? 0 : 1500,
142 | )
143 | }
144 |
145 | !groupBlipTooltipText &&
146 | blipTableItem.on('mouseover', mouseOver).on('mouseout', mouseOut).on('focusin', mouseOver).on('focusout', mouseOut)
147 | blipGraphItem
148 | .on('mouseover', mouseOver)
149 | .on('mouseout', mouseOut)
150 | .on('focusin', mouseOver)
151 | .on('focusout', mouseOut)
152 | .on('click', blipClick)
153 | }
154 |
155 | function renderQuadrantTables(quadrants, rings) {
156 | const radarContainer = d3.select('#radar')
157 |
158 | const quadrantTablesContainer = radarContainer.append('div').classed('quadrant-table__container', true)
159 | quadrants.forEach(function (quadrant) {
160 | const scale = getScale()
161 | let quadrantContainer
162 | if (window.innerWidth < uiConfig.tabletViewWidth && window.innerWidth >= uiConfig.mobileViewWidth) {
163 | quadrantContainer = quadrantTablesContainer
164 | .append('div')
165 | .classed('quadrant-table', true)
166 | .classed(quadrant.order, true)
167 | .style(
168 | 'margin',
169 | `${
170 | graphConfig.quadrantHeight * scale +
171 | graphConfig.quadrantsGap * scale +
172 | graphConfig.quadrantsGap * 2 +
173 | uiConfig.legendsHeight
174 | }px auto 0px`,
175 | )
176 | .style('left', '0')
177 | .style('right', 0)
178 | } else {
179 | quadrantContainer = quadrantTablesContainer
180 | .append('div')
181 | .classed('quadrant-table', true)
182 | .classed(quadrant.order, true)
183 | }
184 |
185 | const ringNames = Array.from(
186 | new Set(
187 | quadrant.quadrant
188 | .blips()
189 | .map((blip) => blip.ring())
190 | .map((ring) => ring.name()),
191 | ),
192 | )
193 | ringNames.forEach(function (ringName) {
194 | quadrantContainer
195 | .append('h2')
196 | .classed('quadrant-table__ring-name', true)
197 | .attr('data-ring-name', ringName)
198 | .text(ringName)
199 | quadrantContainer
200 | .append('ul')
201 | .classed('blip-list', true)
202 | .attr('data-ring-order', rings.filter((ring) => ring.name() === ringName)[0].order())
203 | })
204 | })
205 | }
206 |
207 | module.exports = {
208 | renderQuadrantTables,
209 | renderBlipDescription,
210 | }
211 |
--------------------------------------------------------------------------------
/src/graphing/components/search.js:
--------------------------------------------------------------------------------
1 | const d3 = require('d3')
2 |
3 | const AutoComplete = require('../../util/autoComplete')
4 | const { selectRadarQuadrant, removeScrollListener } = require('../components/quadrants')
5 |
6 | function renderSearch(radarHeader, quadrants) {
7 | const searchContainer = radarHeader.append('div').classed('search-container', true)
8 |
9 | searchContainer
10 | .append('input')
11 | .classed('search-container__input', true)
12 | .attr('placeholder', 'Search this radar')
13 | .attr('id', 'auto-complete')
14 |
15 | AutoComplete('#auto-complete', quadrants, function (e, ui) {
16 | const blipId = ui.item.blip.id()
17 | const quadrant = ui.item.quadrant
18 |
19 | selectRadarQuadrant(quadrant.order, quadrant.startAngle, quadrant.quadrant.name())
20 | const blipElement = d3.select(
21 | `.blip-list__item-container[data-blip-id="${blipId}"] .blip-list__item-container__name`,
22 | )
23 |
24 | removeScrollListener()
25 | blipElement.dispatch('search-result-click')
26 |
27 | setTimeout(() => {
28 | blipElement.node().scrollIntoView({
29 | behavior: 'smooth',
30 | })
31 | }, 1500)
32 | })
33 | }
34 |
35 | module.exports = {
36 | renderSearch,
37 | }
38 |
--------------------------------------------------------------------------------
/src/graphing/config.js:
--------------------------------------------------------------------------------
1 | const quadrantSize = 512
2 | const quadrantGap = 32
3 |
4 | const getQuadrants = () => {
5 | return JSON.parse(process.env.QUADRANTS || null) || ['Techniques', 'Platforms', 'Tools', 'Languages & Frameworks']
6 | }
7 |
8 | const getRings = () => {
9 | return JSON.parse(process.env.RINGS || null) || ['Adopt', 'Trial', 'Assess', 'Hold']
10 | }
11 |
12 | const isBetween = (number, startNumber, endNumber) => {
13 | return startNumber <= number && number <= endNumber
14 | }
15 | const isValidConfig = () => {
16 | return getQuadrants().length === 4 && isBetween(getRings().length, 1, 4)
17 | }
18 |
19 | const graphConfig = {
20 | effectiveQuadrantHeight: quadrantSize + quadrantGap / 2,
21 | effectiveQuadrantWidth: quadrantSize + quadrantGap / 2,
22 | quadrantHeight: quadrantSize,
23 | quadrantWidth: quadrantSize,
24 | quadrantsGap: quadrantGap,
25 | minBlipWidth: 12,
26 | blipWidth: 22,
27 | groupBlipHeight: 24,
28 | newGroupBlipWidth: 88,
29 | existingGroupBlipWidth: 124,
30 | rings: getRings(),
31 | quadrants: getQuadrants(),
32 | groupBlipAngles: [30, 35, 60, 80],
33 | maxBlipsInRings: [8, 22, 17, 18],
34 | }
35 |
36 | const uiConfig = {
37 | subnavHeight: 60,
38 | bannerHeight: 200,
39 | tabletBannerHeight: 300,
40 | headerHeight: 80,
41 | legendsHeight: 42,
42 | tabletViewWidth: 1280,
43 | mobileViewWidth: 768,
44 | }
45 |
46 | function getScale() {
47 | return window.innerWidth < 1800 ? 1.25 : 1.5
48 | }
49 |
50 | function getGraphSize() {
51 | return graphConfig.effectiveQuadrantHeight + graphConfig.effectiveQuadrantWidth
52 | }
53 |
54 | function getScaledQuadrantWidth(scale) {
55 | return graphConfig.quadrantWidth * scale
56 | }
57 |
58 | function getScaledQuadrantHeightWithGap(scale) {
59 | return (graphConfig.quadrantHeight + graphConfig.quadrantsGap) * scale
60 | }
61 |
62 | module.exports = {
63 | graphConfig,
64 | uiConfig,
65 | getScale,
66 | getGraphSize,
67 | getScaledQuadrantWidth,
68 | getScaledQuadrantHeightWithGap,
69 | isValidConfig,
70 | }
71 |
--------------------------------------------------------------------------------
/src/graphing/pdfPage.js:
--------------------------------------------------------------------------------
1 | const d3 = require('d3')
2 |
3 | const addPdfCoverTitle = (title) => {
4 | d3.select('main #pdf-cover-page .pdf-title').text(title)
5 | }
6 |
7 | const addRadarLinkInPdfView = () => {
8 | d3.select('#generated-radar-link').attr('href', window.location.href)
9 | }
10 |
11 | const addQuadrantNameInPdfView = (order, quadrantName) => {
12 | d3.select(`.quadrant-table.${order}`)
13 | .insert('div', ':first-child')
14 | .attr('class', 'quadrant-table__name')
15 | .text(quadrantName)
16 | }
17 |
18 | module.exports = { addPdfCoverTitle, addQuadrantNameInPdfView, addRadarLinkInPdfView }
19 |
--------------------------------------------------------------------------------
/src/images/arrow-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/images/arrow-white-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/images/banner-image-desktop.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/banner-image-desktop.jpg
--------------------------------------------------------------------------------
/src/images/banner-image-mobile.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/banner-image-mobile.jpg
--------------------------------------------------------------------------------
/src/images/existing.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/favicon.ico
--------------------------------------------------------------------------------
/src/images/first-quadrant-btn-bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/images/fourth-quadrant-btn-bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/logo.png
--------------------------------------------------------------------------------
/src/images/moved.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/images/new.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
8 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/src/images/no-change.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/images/pdf_banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/pdf_banner.png
--------------------------------------------------------------------------------
/src/images/radar_legend.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/radar_legend.png
--------------------------------------------------------------------------------
/src/images/search-active-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/images/search-logo-2x.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | search@2x
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/images/second-quadrant-btn-bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/images/tech-radar-landing-page-wide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/tech-radar-landing-page-wide.png
--------------------------------------------------------------------------------
/src/images/third-quadrant-btn-bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/images/tw-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtworks/build-your-own-radar/9e1f13a6cd0078d261e6a7fac5114b9c0f589dbd/src/images/tw-logo.png
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
21 |
22 |
23 |
Build your own Radar
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
Powered by
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Once you've created your Radar you can use this service
40 | to generate an interactive version of your Technology Radar. Not sure how?
41 | Read this first.
42 |
43 |
44 | Building your radar...
45 | Your Technology Radar will be available in just a few seconds
46 |
52 |
53 |
54 |
55 |
72 |
73 |
74 |
75 | There are no blips on this quadrant, please check your Google sheet/CSV/JSON file once.
76 |
77 |
78 |
79 |
80 |
81 |
82 |
94 |
95 | Powered by Thoughtworks . By using this service you agree to
96 | Thoughtworks' terms of use . You also agree to our
97 | privacy policy , which describes how we will gather,
98 | use and protect any personal data contained in your public Google Sheet. This software is
99 | open source and available for download and
100 | self-hosting.
101 |
102 |
103 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/src/models/blip.js:
--------------------------------------------------------------------------------
1 | const { graphConfig } = require('../graphing/config')
2 | const IDEAL_BLIP_WIDTH = 22
3 | const Blip = function (name, ring, isNew, status, topic, description) {
4 | let self, blipText, isGroup, id, groupIdInGraph
5 |
6 | self = {}
7 | isGroup = false
8 |
9 | self.width = IDEAL_BLIP_WIDTH
10 |
11 | self.name = function () {
12 | return name
13 | }
14 |
15 | self.id = function () {
16 | return id || -1
17 | }
18 |
19 | self.groupBlipWidth = function () {
20 | return isNew ? graphConfig.newGroupBlipWidth : graphConfig.existingGroupBlipWidth
21 | }
22 |
23 | self.topic = function () {
24 | return topic || ''
25 | }
26 |
27 | self.description = function () {
28 | return description || ''
29 | }
30 |
31 | self.isNew = function () {
32 | if (status) {
33 | return status.toLowerCase() === 'new'
34 | }
35 |
36 | return isNew
37 | }
38 |
39 | self.hasMovedIn = function () {
40 | return status.toLowerCase() === 'moved in'
41 | }
42 |
43 | self.hasMovedOut = function () {
44 | return status.toLowerCase() === 'moved out'
45 | }
46 |
47 | self.hasNoChange = function () {
48 | return status.toLowerCase() === 'no change'
49 | }
50 |
51 | self.status = function () {
52 | return status.toLowerCase() || ''
53 | }
54 |
55 | self.isGroup = function () {
56 | return isGroup
57 | }
58 |
59 | self.groupIdInGraph = function () {
60 | return groupIdInGraph || ''
61 | }
62 |
63 | self.setGroupIdInGraph = function (groupId) {
64 | groupIdInGraph = groupId
65 | }
66 |
67 | self.ring = function () {
68 | return ring
69 | }
70 |
71 | self.blipText = function () {
72 | return blipText || ''
73 | }
74 |
75 | self.setBlipText = function (newBlipText) {
76 | blipText = newBlipText
77 | }
78 |
79 | self.setId = function (newId) {
80 | id = newId
81 | }
82 |
83 | self.setIsGroup = function (isAGroupBlip) {
84 | isGroup = isAGroupBlip
85 | }
86 |
87 | return self
88 | }
89 |
90 | module.exports = Blip
91 |
--------------------------------------------------------------------------------
/src/models/quadrant.js:
--------------------------------------------------------------------------------
1 | const Quadrant = function (name) {
2 | var self, blips
3 |
4 | self = {}
5 | blips = []
6 |
7 | self.name = function () {
8 | return name
9 | }
10 |
11 | self.add = function (newBlips) {
12 | if (Array.isArray(newBlips)) {
13 | blips = blips.concat(newBlips)
14 | } else {
15 | blips.push(newBlips)
16 | }
17 | }
18 |
19 | self.blips = function () {
20 | return blips.slice(0)
21 | }
22 |
23 | return self
24 | }
25 |
26 | module.exports = Quadrant
27 |
--------------------------------------------------------------------------------
/src/models/radar.js:
--------------------------------------------------------------------------------
1 | const MalformedDataError = require('../exceptions/malformedDataError')
2 | const ExceptionMessages = require('../util/exceptionMessages')
3 |
4 | const _ = {
5 | map: require('lodash/map'),
6 | uniqBy: require('lodash/uniqBy'),
7 | sortBy: require('lodash/sortBy'),
8 | }
9 |
10 | const Radar = function () {
11 | const config = require('../config')
12 | const featureToggles = config().featureToggles
13 |
14 | let self, quadrants, blipNumber, addingQuadrant, alternatives, currentSheetName, rings
15 |
16 | blipNumber = 0
17 | addingQuadrant = 0
18 | quadrants = featureToggles.UIRefresh2022
19 | ? [
20 | { order: 'first', startAngle: 0 },
21 | { order: 'second', startAngle: -90 },
22 | { order: 'third', startAngle: 90 },
23 | { order: 'fourth', startAngle: -180 },
24 | ]
25 | : [
26 | { order: 'first', startAngle: 90 },
27 | { order: 'second', startAngle: 0 },
28 | { order: 'third', startAngle: -90 },
29 | { order: 'fourth', startAngle: -180 },
30 | ]
31 | alternatives = []
32 | currentSheetName = ''
33 | self = {}
34 | rings = {}
35 |
36 | function setNumbers(blips) {
37 | blips.forEach(function (blip) {
38 | ++blipNumber
39 | blip.setBlipText(blipNumber)
40 | blip.setId(blipNumber)
41 | })
42 | }
43 |
44 | self.addAlternative = function (sheetName) {
45 | alternatives.push(sheetName)
46 | }
47 |
48 | self.getAlternatives = function () {
49 | return alternatives
50 | }
51 |
52 | self.setCurrentSheet = function (sheetName) {
53 | currentSheetName = sheetName
54 | }
55 |
56 | self.getCurrentSheet = function () {
57 | return currentSheetName
58 | }
59 |
60 | self.addQuadrant = function (quadrant) {
61 | if (addingQuadrant >= 4) {
62 | throw new MalformedDataError(ExceptionMessages.TOO_MANY_QUADRANTS)
63 | }
64 | quadrants[addingQuadrant].quadrant = quadrant
65 | setNumbers(quadrant.blips())
66 | addingQuadrant++
67 | }
68 |
69 | self.addRings = function (allRings) {
70 | rings = allRings
71 | }
72 |
73 | function allQuadrants() {
74 | if (addingQuadrant < 4) {
75 | throw new MalformedDataError(ExceptionMessages.LESS_THAN_FOUR_QUADRANTS)
76 | }
77 |
78 | return _.map(quadrants, 'quadrant')
79 | }
80 |
81 | function allBlips() {
82 | return allQuadrants().reduce(function (blips, quadrant) {
83 | return blips.concat(quadrant.blips())
84 | }, [])
85 | }
86 |
87 | self.rings = function () {
88 | if (featureToggles.UIRefresh2022) {
89 | return rings
90 | }
91 |
92 | return _.sortBy(
93 | _.map(
94 | _.uniqBy(allBlips(), function (blip) {
95 | return blip.ring().name()
96 | }),
97 | function (blip) {
98 | return blip.ring()
99 | },
100 | ),
101 | function (ring) {
102 | return ring.order()
103 | },
104 | )
105 | }
106 |
107 | self.quadrants = function () {
108 | return quadrants
109 | }
110 |
111 | return self
112 | }
113 |
114 | module.exports = Radar
115 |
--------------------------------------------------------------------------------
/src/models/ring.js:
--------------------------------------------------------------------------------
1 | const Ring = function (name, order) {
2 | var self = {}
3 |
4 | self.name = function () {
5 | return name
6 | }
7 |
8 | self.order = function () {
9 | return order
10 | }
11 |
12 | return self
13 | }
14 |
15 | module.exports = Ring
16 |
--------------------------------------------------------------------------------
/src/site.js:
--------------------------------------------------------------------------------
1 | require('./common')
2 | require('./images/logo.png')
3 | require('./images/radar_legend.png')
4 | require('./analytics.js')
5 |
6 | const Factory = require('./util/factory')
7 |
8 | Factory().build()
9 |
--------------------------------------------------------------------------------
/src/stylesheets/_alternativeradars.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | @if $UIRefresh2022 {
5 | .graph-footer {
6 | display: flex;
7 | flex-direction: column;
8 | width: 100%;
9 |
10 | @include media-query-medium {
11 | align-items: center;
12 | }
13 | }
14 |
15 | .alternative-radars {
16 | display: flex;
17 | flex-direction: column;
18 | align-items: center;
19 |
20 | margin: 32px auto 0;
21 |
22 | @include layout-margin(1, $screen-small);
23 | @include layout-margin(1, $screen-medium);
24 | @include layout-margin(1, $screen-large);
25 | @include layout-margin(1, $screen-xlarge);
26 | @include layout-margin(1, $screen-xxlarge);
27 | @include layout-margin(calc(10 / 12), $screen-xxxlarge);
28 |
29 | &__list {
30 | width: 100%;
31 | display: flex;
32 | flex-direction: column;
33 | list-style: none;
34 | padding: 0;
35 | justify-content: center;
36 | font-size: 1.125rem;
37 | font-family: $baseFontFamily;
38 | font-weight: 630;
39 | margin-block: 0;
40 | margin-bottom: 16px;
41 | margin-left: 0;
42 |
43 | @include media-query-medium {
44 | font-size: 1.25rem;
45 | flex-direction: row;
46 | }
47 |
48 | @include media-query-xxlarge {
49 | font-size: 1.5rem;
50 | }
51 |
52 | &__row-0 {
53 | @include media-query-medium {
54 | margin-top: 48px;
55 | }
56 | }
57 |
58 | &-item {
59 | flex-grow: 1;
60 | flex-basis: 0;
61 | text-align: center;
62 | padding: 0 4px;
63 | border-bottom: 1px solid $mist;
64 |
65 | &.active {
66 | border-bottom: 3px solid $flamingo-s40;
67 | color: $flamingo-s40;
68 |
69 | &:hover {
70 | color: $link-hover;
71 | }
72 | }
73 |
74 | &:only-child {
75 | flex-basis: unset;
76 | flex-grow: unset;
77 | padding: 0 32px 4px;
78 | }
79 |
80 | &-link {
81 | display: inline-block;
82 | width: 100%;
83 | margin-top: 32px;
84 | padding: 0 8px 4px;
85 | box-sizing: border-box;
86 | border: 1px solid transparent;
87 | border-width: 0px 1px;
88 |
89 | display: -webkit-box;
90 | line-height: 1.75rem;
91 | -webkit-box-orient: vertical;
92 |
93 | @include media-query-medium {
94 | -webkit-line-clamp: 1;
95 | overflow: hidden;
96 | text-overflow: ellipsis;
97 | margin-top: 0;
98 | }
99 |
100 | &:hover {
101 | border-color: transparent !important;
102 | }
103 | }
104 | }
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/stylesheets/_buttons.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | @if $UIRefresh2022 {
5 | .buttons {
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 | justify-content: center;
10 | margin: 64px auto 56px;
11 |
12 | @include layout-margin(calc(3 / 4), $screen-small);
13 | @include layout-margin(calc(6 / 8), $screen-medium);
14 | @include layout-margin(calc(8 / 12), $screen-large);
15 | @include layout-margin(calc(6 / 12), $screen-xlarge);
16 | @include layout-margin(calc(6 / 12), $screen-xxlarge);
17 | @include layout-margin(calc(4 / 12), $screen-xxxlarge);
18 |
19 | @include media-query-medium {
20 | flex-direction: row;
21 | }
22 |
23 | button {
24 | border: none;
25 | font-size: 20px;
26 | color: $white;
27 | width: 220px;
28 | height: 48px;
29 | margin: 0 15px 32px;
30 | cursor: pointer;
31 |
32 | @include media-query-medium {
33 | margin: 0 15px;
34 | }
35 |
36 | a {
37 | border: none;
38 | color: $white;
39 |
40 | &:hover {
41 | color: $white;
42 | }
43 | }
44 | }
45 |
46 | &__wave-btn {
47 | background-color: $wave;
48 | }
49 |
50 | &__flamingo-btn {
51 | background-color: $flamingo-s40;
52 | color: $white;
53 |
54 | display: inline-flex;
55 | align-items: center;
56 | justify-content: center;
57 |
58 | width: 220px;
59 | height: 48px;
60 |
61 | border: none;
62 | font-size: 20px;
63 |
64 | &:hover {
65 | color: $white;
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/stylesheets/_colors.scss:
--------------------------------------------------------------------------------
1 | $green: #86b782;
2 | $blue: #1ebccd;
3 | $orange: #f38a3e;
4 | $violet: #b32059;
5 | $pink: #ee0b77;
6 |
7 | $grey-darkest: #bababa;
8 | $grey-dark: #cacaca;
9 | $grey: #dadada;
10 | $grey-light: #eee;
11 | $grey-lightest: #fafafa;
12 | $grey-even-darker: #d7d7d7;
13 |
14 | $white: #fff;
15 | $black: #000;
16 | $grey-text: rgb(51, 51, 51);
17 |
18 | $grey-alpha-03: rgba(255, 255, 255, 0.3);
19 |
20 | // Brand colors for use across theme.
21 | $black: #000000; //onyx
22 | $white: #ffffff; //white
23 | $wave: #163c4d; //wave
24 | $wave-light: #6b8591; //wave-light
25 | $flamingo: #e16a7c; //flamingo
26 | $mist: #edf1f3; //mist (ededed)
27 | $sapphire: #47a1ad; //$sapphire
28 | $jade: #6b9e78; //jade
29 | $turmeric: #cc850a; //turmeric
30 | $amethyst: #634f7d; //amethyst
31 |
32 | // Other colors
33 | $mist-s30: #71777d;
34 | $mist-s20: #d5d9db;
35 | $mist-s10: #e1e5e7;
36 | $mist-light: #f7fafc;
37 | $flamingo-s40: #bd4257;
38 |
39 | $button-normal: $wave; //button color
40 | $button-disabled: #909090; //disabled state for links and buttons
41 |
42 | $link-normal: $black; //links color
43 | $link-hover: #9b293c; //links hover state color
44 | $error-text: #d14234;
45 |
46 | $flamingo-dark: #9b293c; //flamingo-dark
47 | $sapphire-dark: #1f8290; //$sapphire-dark
48 | $jade-dark: #517b5c; //jade-dark
49 | $turmeric-dark: #a06908; //turmeric-dark
50 |
--------------------------------------------------------------------------------
/src/stylesheets/_error.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | .error-container {
5 | text-align: center;
6 | padding-top: 48px;
7 |
8 | .error-container__message {
9 | @if not $UIRefresh2022 {
10 | width: 60%;
11 | display: inline-block;
12 | }
13 | @if $UIRefresh2022 {
14 | p:first-child {
15 | color: $error-text;
16 | }
17 | p {
18 | margin: 0;
19 | }
20 | }
21 | }
22 |
23 | .error-title {
24 | font-weight: 400;
25 | }
26 |
27 | .error-subtitle {
28 | margin-top: -10px;
29 | }
30 |
31 | .switch-account-button {
32 | margin: 0 0 35px 0;
33 | }
34 |
35 | .switch-account-button-newui {
36 | background-color: $button-normal;
37 | color: $white;
38 | font-size: 20px;
39 | font-family: $baseFontFamily;
40 | line-height: 24px;
41 | font-weight: bold;
42 | padding-left: 30px;
43 | padding-right: 30px;
44 | margin-top: 10px;
45 | margin-bottom: 18px;
46 | border: none;
47 | text-transform: none;
48 | cursor: pointer;
49 | &:focus {
50 | outline: auto;
51 | }
52 | }
53 | }
54 |
55 | .input-sheet {
56 | .page-not-found {
57 | font-size: 40px;
58 | font-weight: 900;
59 | margin-bottom: 20px;
60 | }
61 |
62 | .message p {
63 | font-size: 25px;
64 | }
65 | }
66 |
67 | .support {
68 | margin-top: 20px;
69 |
70 | p {
71 | font-weight: 500;
72 | font-size: 30px;
73 | margin-bottom: 1px;
74 | }
75 | }
76 |
77 | .support-link {
78 | font-size: 26px;
79 | padding: 20px;
80 |
81 | span {
82 | padding: 0 40px;
83 | display: table-cell;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/stylesheets/_fonts.scss:
--------------------------------------------------------------------------------
1 | $baseFont: 17px;
2 | $baseWeight: 100;
3 | $baseFontFamily: 'Inter', sans-serif;
4 |
--------------------------------------------------------------------------------
/src/stylesheets/_footer.scss:
--------------------------------------------------------------------------------
1 | @import 'layout';
2 | @import 'fonts';
3 |
4 | #footer {
5 | text-align: center;
6 | clear: both;
7 |
8 | .footer-content {
9 | width: 50%;
10 | margin: 0 auto;
11 |
12 | p {
13 | padding-top: 60px;
14 | font-size: $baseFont;
15 | font-weight: $baseWeight;
16 | text-align: left;
17 | }
18 | }
19 | }
20 |
21 | @if $UIRefresh2022 {
22 | footer {
23 | margin-bottom: 40px;
24 |
25 | p {
26 | @include eight-column-layout-margin;
27 | font-family: $baseFontFamily;
28 | font-weight: normal;
29 | margin-top: 30px;
30 | font-size: 16px !important;
31 |
32 | @include media-query-large {
33 | font-size: 18px !important;
34 | text-align: center;
35 | }
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/stylesheets/_form.scss:
--------------------------------------------------------------------------------
1 | .input-sheet {
2 | margin: 60px auto;
3 |
4 | p {
5 | font-weight: 100;
6 | color: $black;
7 | font-size: 18px;
8 | }
9 |
10 | .input-sheet__logo {
11 | text-align: center;
12 | padding-bottom: 40px;
13 | a {
14 | border-bottom: none;
15 |
16 | img {
17 | width: 200px;
18 | }
19 | }
20 | }
21 |
22 | .radar-footer {
23 | padding-top: 200px;
24 | }
25 |
26 | .input-sheet__banner {
27 | background-image: url('/images/tech-radar-landing-page-wide.png');
28 | background-repeat: no-repeat;
29 | background-color: $grey-light;
30 | background-size: cover;
31 | background-position: center;
32 | width: 100%;
33 | margin: 0 auto;
34 | text-align: center;
35 | min-height: 285px;
36 | display: table;
37 |
38 | div {
39 | display: table-cell;
40 | vertical-align: middle;
41 | }
42 | p,
43 | h1 {
44 | color: $white;
45 | }
46 |
47 | a {
48 | color: $white;
49 | border-bottom-color: $white;
50 | }
51 | }
52 |
53 | .input-sheet__form {
54 | width: 50%;
55 | margin: 0 auto;
56 | text-align: center;
57 | padding-top: 30px;
58 |
59 | button {
60 | font-family: inherit;
61 | border: none;
62 | background-color: transparent;
63 | margin: 0;
64 | padding: 0;
65 | }
66 | }
67 |
68 | input[type='text'] {
69 | border-bottom: 2px solid $grey-even-darker;
70 | display: block;
71 | font-size: 18px;
72 | margin-bottom: 30px;
73 | padding: 10px;
74 | transition:
75 | box-shadow 0.3s,
76 | border 0.3s;
77 |
78 | &:focus,
79 | &.focus {
80 | outline: none;
81 | border-bottom: 2px solid $grey-even-darker;
82 | box-shadow: none;
83 | }
84 | }
85 |
86 | form,
87 | input,
88 | a {
89 | font-family: inherit;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/stylesheets/_header.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | header {
5 | text-align: center;
6 | }
7 |
8 | .radar-title,
9 | .buttons-group,
10 | #alternative-buttons {
11 | display: flex;
12 | flex-direction: row;
13 | justify-content: flex-start;
14 | align-items: center;
15 | align-content: space-between;
16 | }
17 |
18 | .radar-title {
19 | background-color: $grey-light;
20 | padding: 30px 0;
21 |
22 | display: table;
23 | margin: auto;
24 | width: 100%;
25 |
26 | .radar-title__text {
27 | display: table-cell;
28 | width: 70%;
29 |
30 | text-align: left;
31 | padding-left: 10%;
32 |
33 | h1 {
34 | font-size: 55px;
35 | font-weight: 900;
36 | letter-spacing: -0.04em;
37 | line-height: 0.8em;
38 | margin: 0;
39 | text-transform: uppercase;
40 | }
41 | }
42 |
43 | .radar-title__logo {
44 | flex: 0 0 30%;
45 | margin-left: auto;
46 |
47 | width: 30%;
48 | display: table-cell;
49 | vertical-align: middle;
50 |
51 | a {
52 | border-bottom: none;
53 | }
54 |
55 | img {
56 | vertical-align: middle;
57 | width: 34%;
58 | }
59 | }
60 | }
61 |
62 | .quadrant-btn--group,
63 | .multiple-sheet-button-group {
64 | text-align: left;
65 | padding-left: 10%;
66 | }
67 |
68 | .print-radar-btn,
69 | .search-box {
70 | margin-left: auto;
71 | margin-right: 10%;
72 | }
73 |
74 | .buttons-group {
75 | padding: 15px 0 25px;
76 | }
77 |
78 | .home-link {
79 | color: $pink;
80 | margin-bottom: 10px;
81 | line-height: normal;
82 | cursor: pointer;
83 | display: inline-block;
84 | font-size: $baseFont;
85 | text-align: left;
86 | width: 80%;
87 | }
88 |
89 | .button {
90 | font-size: $baseFont;
91 | text-transform: capitalize;
92 | margin-right: 20px;
93 | border-radius: 2px;
94 | padding: 10px 20px;
95 | cursor: pointer;
96 | transition: all 0.2s ease-out;
97 |
98 | background-color: $grey-light;
99 | color: $black;
100 |
101 | &.no-capitalize {
102 | text-transform: none;
103 | }
104 |
105 | &:hover,
106 | &.selected {
107 | transform: translate(0, -2px);
108 | opacity: 0.85;
109 |
110 | &.first {
111 | color: white;
112 | background-color: $green;
113 | }
114 |
115 | &.second {
116 | color: white;
117 | background-color: $blue;
118 | }
119 |
120 | &.third {
121 | color: white;
122 | background-color: $orange;
123 | }
124 |
125 | &.fourth {
126 | color: white;
127 | background-color: $violet;
128 | }
129 | }
130 |
131 | &.full-view {
132 | &.first {
133 | background-color: $green;
134 | color: $white;
135 | }
136 |
137 | &.second {
138 | background-color: $blue;
139 | color: $white;
140 | }
141 |
142 | &.third {
143 | background-color: $orange;
144 | color: $white;
145 | }
146 |
147 | &.fourth {
148 | background-color: $violet;
149 | color: $white;
150 | }
151 | }
152 | }
153 |
154 | #alternative-buttons {
155 | margin-bottom: 50px;
156 |
157 | .highlight {
158 | border-bottom: none;
159 | font-weight: bold;
160 | }
161 |
162 | p {
163 | font-size: 16px;
164 | font-weight: 700;
165 | margin-top: 0;
166 | margin-bottom: 10px;
167 | }
168 |
169 | .multiple-sheet-button {
170 | margin-right: 10px;
171 | }
172 |
173 | .search-radar {
174 | border: 1px solid #aaa;
175 | background-color: inherit;
176 | background-image: url('/images/search-logo-2x.svg');
177 | background-repeat: no-repeat;
178 | background-position: 10px;
179 | }
180 |
181 | input {
182 | padding-left: 35px;
183 | width: 275px;
184 | }
185 | }
186 |
187 | @if not $UIRefresh2022 {
188 | .ui-autocomplete {
189 | width: 275px !important;
190 |
191 | .ui-autocomplete-quadrant {
192 | font-size: 14px;
193 | font-weight: 600;
194 | padding: 5px;
195 | }
196 |
197 | .ui-menu-item {
198 | white-space: normal;
199 | font-size: 14px;
200 | font-weight: 400;
201 |
202 | .ui-menu-item-wrapper {
203 | padding: 0 10px;
204 | }
205 | }
206 | }
207 | }
208 |
209 | @if $UIRefresh2022 {
210 | header {
211 | height: $headerHeight;
212 | width: 100%;
213 | display: flex;
214 | align-items: center;
215 |
216 | div {
217 | display: flex;
218 | flex-direction: row;
219 |
220 | margin: auto;
221 | width: 90%;
222 |
223 | @include layout-margin(1, $screen-small);
224 | @include layout-margin(1, $screen-medium);
225 | @include layout-margin(1, $screen-large);
226 | @include layout-margin(1, $screen-xlarge);
227 | @include layout-margin(1, $screen-xxlarge);
228 | @include layout-margin(1, $screen-xxxlarge);
229 |
230 | span {
231 | margin-right: 5px;
232 | font-family: $baseFontFamily;
233 | font-weight: 630;
234 | }
235 |
236 | a {
237 | display: block;
238 | border-bottom: none;
239 |
240 | img {
241 | height: 20px;
242 | width: auto;
243 | margin-top: 1.5px;
244 | }
245 | }
246 | }
247 | }
248 | }
249 |
--------------------------------------------------------------------------------
/src/stylesheets/_herobanner.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | @if $UIRefresh2022 {
5 | .hero-banner {
6 | height: $tabletBannerHeight;
7 | background-color: $amethyst;
8 | background-image: url('/images/banner-image-mobile.jpg');
9 | background-size: contain;
10 | background-repeat: no-repeat;
11 | background-position: right;
12 | color: $white;
13 | display: flex;
14 | align-items: center;
15 | margin: 0 !important;
16 |
17 | @include media-query-large {
18 | background-image: url('/images/banner-image-mobile.jpg');
19 | }
20 |
21 | @include media-query-xlarge {
22 | background-image: url('/images/banner-image-desktop.jpg');
23 | height: $bannerHeight;
24 | background-size: cover;
25 | }
26 |
27 | &__wrapper {
28 | margin: auto;
29 | width: 90%;
30 |
31 | @include layout-margin(1, $screen-small);
32 | @include layout-margin(1, $screen-medium);
33 | @include layout-margin(1, $screen-large);
34 | @include layout-margin(1, $screen-xlarge);
35 | @include layout-margin(1, $screen-xxlarge);
36 | @include layout-margin(1, $screen-xxxlarge);
37 | }
38 |
39 | &__title-text {
40 | width: fit-content;
41 | text-align: left;
42 | text-transform: none;
43 | letter-spacing: 0;
44 | cursor: pointer;
45 | }
46 |
47 | &__subtitle-text {
48 | font-weight: 630;
49 | width: 50%;
50 | text-align: left;
51 | text-transform: none;
52 | letter-spacing: 0;
53 | overflow: hidden;
54 | text-overflow: ellipsis;
55 | display: -webkit-box;
56 | line-height: 1.75rem;
57 | -webkit-line-clamp: 4;
58 | -webkit-box-orient: vertical;
59 |
60 | @include media-query-medium {
61 | width: 37.5%;
62 | -webkit-line-clamp: 2;
63 | }
64 |
65 | @include media-query-large {
66 | width: 25%;
67 | }
68 |
69 | @include media-query-xlarge {
70 | width: 33%;
71 | }
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/stylesheets/_landingpage.scss:
--------------------------------------------------------------------------------
1 | @import 'layout';
2 | @import 'colors';
3 | @import 'fonts';
4 |
5 | .helper-description {
6 | @include eight-column-layout-margin;
7 | padding-top: 32px;
8 | margin-top: 24px;
9 |
10 | p {
11 | font-weight: bold;
12 | margin: 0;
13 | }
14 |
15 | .loader-text {
16 | display: none;
17 | text-align: center;
18 | &__title {
19 | display: inline-block;
20 | font-size: 2.5rem;
21 | font-weight: bold;
22 | margin-bottom: 1rem;
23 | }
24 | }
25 | }
26 |
27 | .input-sheet-form {
28 | @include eight-column-layout-margin;
29 | display: flex;
30 | flex-direction: column;
31 |
32 | p {
33 | margin-top: 88px;
34 | text-align: center;
35 | }
36 |
37 | p.with-error {
38 | margin-top: 48px;
39 | }
40 |
41 | form {
42 | display: flex;
43 | flex-direction: column;
44 | justify-content: center;
45 | flex-wrap: nowrap;
46 | align-items: center;
47 |
48 | input#document-input {
49 | font-family: $baseFontFamily;
50 | background-color: $mist;
51 | border: 1px solid #d5d9db;
52 | letter-spacing: 0.06px;
53 | height: 48px;
54 | margin-bottom: 20px;
55 | color: $black;
56 | font-size: 18px;
57 |
58 | &::placeholder {
59 | color: #3c606f;
60 | }
61 | }
62 |
63 | input[type='submit'] {
64 | background-color: $button-normal;
65 | color: $white;
66 | font-size: 20px;
67 | font-family: $baseFontFamily;
68 | line-height: 24px;
69 | font-weight: bold;
70 | padding-left: 30px;
71 | padding-right: 30px;
72 | margin-bottom: 18px;
73 | border: none;
74 | cursor: pointer;
75 | &:focus {
76 | outline: auto;
77 | }
78 | }
79 | input:disabled {
80 | cursor: not-allowed;
81 | opacity: 0.9;
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/stylesheets/_layout.scss:
--------------------------------------------------------------------------------
1 | @import 'mediaqueries';
2 |
3 | @mixin layout-margin($factor, $screen-size) {
4 | @if $screen-size <= $screen-medium {
5 | @media screen and (min-width: $screen-size) {
6 | width: calc(0.9 * $factor * 100%);
7 | }
8 | } @else {
9 | @media screen and (min-width: $screen-size) {
10 | width: calc(0.8 * $factor * $screen-size);
11 | }
12 | }
13 | }
14 |
15 | @mixin eight-column-layout-margin {
16 | margin: auto;
17 | max-width: 80%;
18 |
19 | @include media-query-large {
20 | max-width: 54%;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/stylesheets/_loader.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | .loading-spinner {
3 | &-blocks {
4 | width: 100%;
5 | height: 100%;
6 | display: flex;
7 | align-items: center;
8 | flex-direction: column;
9 | margin: 50px 0;
10 | overflow: hidden;
11 |
12 | &.hide {
13 | display: none;
14 | }
15 | }
16 | }
17 |
18 | .loader-wrapper {
19 | max-width: 60px;
20 | height: 60px;
21 | display: flex;
22 | flex-wrap: wrap;
23 | justify-content: space-between;
24 | align-content: space-between;
25 | margin: 2rem auto;
26 | }
27 | .loader-wrapper div {
28 | box-sizing: content-box;
29 | }
30 |
31 | .loader-wrapper div {
32 | width: 30px;
33 | height: 30px;
34 | background: $mist;
35 | animation: loader 1s linear infinite;
36 | }
37 |
38 | @keyframes loader {
39 | 0% {
40 | background: $wave;
41 | }
42 | 50% {
43 | background: $mist;
44 | }
45 | 100% {
46 | background: $mist;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/stylesheets/_mediaqueries.scss:
--------------------------------------------------------------------------------
1 | $screen-small: 360px;
2 | $screen-medium: 768px;
3 | $screen-large: 1024px;
4 | $screen-xlarge: 1280px;
5 | $screen-xxlarge: 1440px;
6 | $screen-xxxlarge: 1800px;
7 |
8 | @mixin media-query-small {
9 | @media screen and (min-width: $screen-small) {
10 | // Mobile - 360px and above
11 | @content;
12 | }
13 | }
14 |
15 | @mixin media-query-medium {
16 | @media screen and (min-width: $screen-medium) {
17 | // iPad - 768px and above
18 | @content;
19 | }
20 | }
21 |
22 | @mixin media-query-large {
23 | @media screen and (min-width: $screen-large) {
24 | // Desktop - 1024px and above
25 | @content;
26 | }
27 | }
28 |
29 | @mixin media-query-xlarge {
30 | @media screen and (min-width: $screen-xlarge) {
31 | // Desktop - 1280px and above
32 | @content;
33 | }
34 | }
35 |
36 | @mixin media-query-xxlarge {
37 | @media screen and (min-width: $screen-xxlarge) {
38 | // Desktop - 1440px and above
39 | @content;
40 | }
41 | }
42 |
43 | @mixin media-query-xxxlarge {
44 | @media screen and (min-width: $screen-xxxlarge) {
45 | // Desktop - 1800px and above
46 | @content;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/stylesheets/_pdfPage.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 |
3 | @media print {
4 | body {
5 | margin: 0;
6 | -webkit-print-color-adjust: exact !important;
7 | print-color-adjust: exact !important;
8 | }
9 |
10 | @page {
11 | margin: 20mm;
12 | }
13 |
14 | #pdf-cover-page {
15 | .pdf-banner-image {
16 | width: 100%;
17 | height: 50%;
18 | }
19 |
20 | .pdf-title {
21 | font-family: 'Bitter', serif;
22 | font-size: 4rem;
23 | margin: 24mm 12mm;
24 | font-weight: bold;
25 | overflow: hidden;
26 | text-overflow: ellipsis;
27 | display: -webkit-box;
28 | display: -webkit-inline-box;
29 | line-height: 4.25rem;
30 | -webkit-line-clamp: 3;
31 | -webkit-box-orient: vertical;
32 | min-height: 200px;
33 | width: calc(100% - 24mm);
34 | }
35 | }
36 | .pdf-powered-by-text {
37 | float: right;
38 | margin-right: 12mm;
39 | display: flex;
40 | align-items: center;
41 | gap: 1.5rem;
42 |
43 | .pdf-tw-logo {
44 | height: 28px;
45 | }
46 | }
47 |
48 | footer.home-page {
49 | a {
50 | color: black;
51 | }
52 |
53 | p {
54 | margin: 0;
55 | font-style: italic;
56 | max-width: none;
57 |
58 | &.agree-terms {
59 | a:after {
60 | content: ' <' attr(href) '> ';
61 | font-weight: normal;
62 | color: black;
63 | }
64 | }
65 | }
66 | }
67 |
68 | .pdf-footer {
69 | page-break-before: always;
70 |
71 | .pdf-powered-by-text {
72 | flex-direction: column;
73 | align-items: flex-start;
74 | float: left;
75 | width: 100%;
76 | margin-bottom: 1.5rem;
77 | gap: 0;
78 |
79 | .pdf-tw-logo {
80 | margin: 2rem 0;
81 | }
82 |
83 | .pdf-footer-title {
84 | font-weight: bold;
85 | font-size: 1.5rem;
86 | }
87 |
88 | a {
89 | color: black;
90 | font-size: 1.25rem;
91 |
92 | &.radar-link {
93 | font-style: italic;
94 | margin-top: 0.5rem;
95 | }
96 | }
97 | }
98 | }
99 |
100 | #radar {
101 | display: block !important;
102 | height: max-content !important;
103 |
104 | &.no-blips .quadrant-table {
105 | display: block !important;
106 | }
107 |
108 | .no-blip-text {
109 | display: none !important;
110 | }
111 |
112 | a {
113 | color: black;
114 | }
115 |
116 | a:after {
117 | content: ' <' attr(href) '> ';
118 | font-weight: normal;
119 | color: black;
120 | }
121 | }
122 |
123 | .quadrant-table__container {
124 | width: 100%;
125 | display: block;
126 |
127 | .quadrant-table {
128 | float: none !important;
129 | page-break-before: always;
130 | max-height: initial !important;
131 | opacity: 1 !important;
132 | position: static !important;
133 | margin-bottom: 20px;
134 |
135 | &__name {
136 | font-size: 2rem;
137 | font-weight: bold;
138 | font-family: 'Bitter', serif;
139 | margin-bottom: 2.5rem;
140 | display: block !important;
141 | }
142 |
143 | &__ring-name {
144 | margin-bottom: 2rem;
145 | }
146 |
147 | &.first .quadrant-table__name {
148 | color: $sapphire;
149 | }
150 |
151 | &.second .quadrant-table__name {
152 | color: $turmeric;
153 | }
154 |
155 | &.third .quadrant-table__name {
156 | color: $jade;
157 | }
158 |
159 | &.fourth .quadrant-table__name {
160 | color: $flamingo;
161 | }
162 |
163 | .blip-list__item {
164 | pointer-events: none;
165 | }
166 |
167 | .blip-list__item-container {
168 | background-color: white !important;
169 |
170 | &__name {
171 | border-bottom: none;
172 | padding: 0 !important;
173 |
174 | &-value {
175 | font-weight: bold;
176 | font-size: 1.25rem;
177 | }
178 |
179 | &-arrow {
180 | display: none;
181 | }
182 | }
183 |
184 | &__description {
185 | display: block;
186 | }
187 | }
188 |
189 | ul.blip-list {
190 | list-style: none;
191 | margin: 0 0 3rem;
192 | padding: 0;
193 |
194 | .blip-list__item-container__description {
195 | padding-left: 0;
196 | line-height: 1.5 !important;
197 | }
198 | }
199 |
200 | & > ul.blip-list:last-child {
201 | margin-bottom: 0 !important;
202 | }
203 | }
204 | }
205 |
206 | .pdf-page-footer {
207 | display: none;
208 | position: fixed;
209 | bottom: 0;
210 | color: $mist-s30;
211 | justify-content: space-between;
212 | width: 98%;
213 | }
214 |
215 | .input-sheet__logo,
216 | .hero-banner,
217 | main > *:not(#pdf-cover-page, #radar),
218 | #radar > #radar-plot,
219 | .radar-legends,
220 | .ui-helper-hidden-accessible,
221 | #onetrust-consent-sdk {
222 | display: none !important;
223 | }
224 | }
225 |
226 | @media print and (orientation: landscape) {
227 | #pdf-cover-page {
228 | .pdf-banner-image {
229 | height: 310px;
230 | object-fit: cover;
231 | }
232 |
233 | .pdf-title {
234 | margin: 12mm 24mm 10mm;
235 | width: calc(100% - 48mm);
236 | }
237 | }
238 | }
239 |
240 | @media print and (orientation: portrait) {
241 | #pdf-cover-page {
242 | .pdf-banner-image {
243 | height: 50%;
244 | }
245 | }
246 | }
247 |
248 | @media screen {
249 | .pdf-footer,
250 | .pdf-page-footer,
251 | #pdf-cover-page {
252 | display: none !important;
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/src/stylesheets/_quadrantTables.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | @if $UIRefresh2022 {
5 | .quadrant-table {
6 | @include layout-margin(calc(12 / 12), $screen-small);
7 | @include layout-margin(calc(12 / 12), $screen-medium);
8 | @include layout-margin(calc(12 / 12), $screen-large);
9 | @include layout-margin(calc(4 / 12), $screen-xlarge);
10 | @include layout-margin(calc(5 / 12), $screen-xxlarge);
11 | @include layout-margin(calc(5 / 12), $screen-xxxlarge);
12 |
13 | &__ring-name {
14 | text-transform: none;
15 | margin: 0;
16 | scroll-margin-top: $subnavHeight;
17 | font-family: $baseFontFamily;
18 | }
19 |
20 | @include media-query-xlarge {
21 | max-width: 40%;
22 | margin-top: 0 !important;
23 | }
24 |
25 | &__container {
26 | display: flex;
27 | justify-content: center;
28 |
29 | @include media-query-xlarge {
30 | display: block;
31 | justify-content: unset;
32 | }
33 | }
34 | }
35 |
36 | .blip-list {
37 | width: 100%;
38 | margin-bottom: 64px;
39 |
40 | &__item {
41 | width: 100%;
42 |
43 | &:hover,
44 | &.highlight {
45 | background-color: $mist;
46 | }
47 |
48 | &-container {
49 | &.expand {
50 | background-color: $mist;
51 | }
52 |
53 | &.expand &__name {
54 | &-arrow {
55 | rotate: -135deg;
56 | margin-top: 10px;
57 | }
58 | }
59 |
60 | &.expand &__description {
61 | display: block;
62 | }
63 |
64 | &__name {
65 | padding: 20px;
66 | width: 100%;
67 | border: none;
68 | background-color: transparent;
69 | border-bottom: 1px solid $mist-s20;
70 | text-align: unset;
71 | display: flex;
72 | justify-content: space-between;
73 | align-items: center;
74 | cursor: pointer;
75 | scroll-margin-top: $subnavHeight;
76 |
77 | & > * {
78 | pointer-events: none;
79 | }
80 |
81 | &-value {
82 | pointer-events: none;
83 | display: inline-block;
84 | width: 90%;
85 | font-size: 1rem;
86 | font-family: $baseFontFamily;
87 |
88 | @include media-query-large {
89 | font-size: 1.125rem;
90 | }
91 | }
92 |
93 | &-arrow {
94 | width: 8px;
95 | height: 8px;
96 | display: inline-flex;
97 | rotate: 45deg;
98 | border: 1px solid $flamingo;
99 | border-width: 0 2px 2px 0;
100 | -webkit-transition: all 0.2s ease;
101 | transition: all 0.2s ease;
102 | }
103 | }
104 |
105 | &__description {
106 | display: none;
107 | padding: 20px;
108 | font-size: 1rem;
109 | font-family: $baseFontFamily;
110 | line-height: 36px;
111 |
112 | @include media-query-large {
113 | font-size: 1.125rem;
114 | }
115 |
116 | & > * {
117 | font-family: inherit !important;
118 | font-size: inherit !important;
119 | line-height: inherit !important;
120 | margin: 0 !important;
121 | }
122 | }
123 | }
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/stylesheets/_quadrants.scss:
--------------------------------------------------------------------------------
1 | #radar {
2 | width: 80%;
3 | margin: 0 auto;
4 | position: relative;
5 |
6 | @if $UIRefresh2022 {
7 | width: 100%;
8 |
9 | @include media-query-xlarge {
10 | width: 80%;
11 | min-width: calc($quadrantWidth * 2 + $quadrantsGap);
12 | }
13 |
14 | @include layout-margin(calc(12 / 12), $screen-xlarge);
15 | @include layout-margin(calc(12 / 12), $screen-xxlarge);
16 | @include layout-margin(calc(12 / 12), $screen-xxxlarge);
17 |
18 | .no-blip-text {
19 | display: none;
20 | text-align: center;
21 | font-size: 24px;
22 | font-weight: bold;
23 | }
24 |
25 | &.no-blips {
26 | height: auto !important;
27 | .no-blip-text {
28 | display: block;
29 | }
30 |
31 | .quadrant-group,
32 | .quadrant-table,
33 | .radar-legends {
34 | display: none !important;
35 | }
36 | }
37 |
38 | .mobile {
39 | display: block;
40 | }
41 |
42 | &:not(.mobile) {
43 | display: none;
44 | }
45 |
46 | @include media-query-xlarge {
47 | &:not(.mobile) {
48 | display: block;
49 | }
50 | }
51 | }
52 |
53 | svg#radar-plot {
54 | margin: 0 auto;
55 | transition: all 1s ease;
56 | position: absolute;
57 | left: 0;
58 | right: 0;
59 |
60 | @if $UIRefresh2022 {
61 | display: none;
62 | transition: none;
63 |
64 | margin: 0 auto;
65 |
66 | &.enable-transition {
67 | transition: all 1s ease;
68 | }
69 |
70 | @include media-query-medium {
71 | display: block;
72 | }
73 | }
74 |
75 | &.quadrant-view {
76 | .quadrant-group {
77 | pointer-events: none;
78 | }
79 |
80 | .quadrant-name-group {
81 | display: none;
82 | }
83 |
84 | @include media-query-xlarge {
85 | &.sticky {
86 | position: fixed;
87 | transition: none;
88 | top: $subnavHeight !important;
89 | }
90 | }
91 | }
92 |
93 | @if $UIRefresh2022 {
94 | pointer-events: none;
95 | z-index: 10;
96 | }
97 |
98 | .legend {
99 | visibility: hidden;
100 | transition: visibility 1s ease 1s;
101 | color: $black;
102 | }
103 |
104 | path {
105 | &.ring-arc-3 {
106 | stroke: none;
107 | fill: $grey-light;
108 | }
109 |
110 | &.ring-arc-2 {
111 | stroke: none;
112 | fill: $grey;
113 | }
114 |
115 | &.ring-arc-1 {
116 | stroke: none;
117 | fill: $grey-dark;
118 | }
119 |
120 | &.ring-arc-0 {
121 | stroke: none;
122 | fill: $grey-darkest;
123 | }
124 | }
125 |
126 | @if $UIRefresh2022 {
127 | path {
128 | &.ring-arc-3,
129 | &.ring-arc-2,
130 | &.ring-arc-1,
131 | &.ring-arc-0 {
132 | stroke: $mist-s30;
133 | stroke-width: 1;
134 | fill: white;
135 | }
136 | }
137 |
138 | .quadrant-group {
139 | transition: opacity 0.5s ease-out;
140 | pointer-events: all;
141 | }
142 | }
143 |
144 | .blip-link {
145 | text-decoration: none;
146 | cursor: pointer;
147 | pointer-events: initial;
148 | outline: none;
149 | }
150 |
151 | .quadrant-group {
152 | cursor: pointer;
153 | }
154 |
155 | circle,
156 | polygon,
157 | path {
158 | &.first {
159 | fill: $green;
160 | stroke: none;
161 | }
162 |
163 | &.second {
164 | fill: $blue;
165 | stroke: none;
166 | }
167 |
168 | &.third {
169 | fill: $orange;
170 | stroke: none;
171 | }
172 |
173 | &.fourth {
174 | fill: $violet;
175 | stroke: none;
176 | }
177 | }
178 |
179 | line {
180 | stroke: white;
181 | }
182 |
183 | text {
184 | &.blip-text {
185 | font-size: 9px;
186 | font-style: italic;
187 | fill: $white;
188 | }
189 |
190 | &.line-text {
191 | font-weight: bold;
192 | text-transform: uppercase;
193 | fill: $black;
194 | font-size: 7px;
195 | }
196 | }
197 |
198 | @if $UIRefresh2022 {
199 | circle,
200 | polygon,
201 | rect,
202 | path {
203 | &.first {
204 | fill: $sapphire-dark;
205 | stroke: none;
206 | }
207 |
208 | &.second {
209 | fill: $turmeric-dark;
210 | stroke: none;
211 | }
212 |
213 | &.third {
214 | fill: $jade-dark;
215 | stroke: none;
216 | }
217 |
218 | &.fourth {
219 | fill: $flamingo-dark;
220 | stroke: none;
221 | }
222 | }
223 |
224 | line {
225 | stroke: white;
226 | }
227 |
228 | text {
229 | &.blip-text {
230 | font-size: 9px;
231 | font-style: italic;
232 | fill: $black;
233 | }
234 |
235 | &.line-text {
236 | font-weight: bold;
237 | text-transform: none;
238 | fill: $black;
239 | font-size: 16px;
240 | }
241 | }
242 | }
243 | }
244 |
245 | div.quadrant-table {
246 | .quadrant-table__name {
247 | display: none;
248 | }
249 |
250 | max-height: 0;
251 | @if not $UIRefresh2022 {
252 | max-width: 0;
253 | }
254 | position: absolute;
255 | overflow: hidden;
256 | z-index: 11;
257 | transition: max-height 0.5s ease 1s;
258 |
259 | h3 {
260 | text-transform: uppercase;
261 | font-size: $baseFont;
262 | margin: 0;
263 | font-weight: bold;
264 | }
265 |
266 | @if $UIRefresh2022 {
267 | overflow: clip;
268 | &.first {
269 | float: left;
270 | }
271 |
272 | &.second {
273 | float: left;
274 | }
275 |
276 | &.third {
277 | float: right;
278 | }
279 |
280 | &.fourth {
281 | float: right;
282 | }
283 | } @else {
284 | &.first {
285 | &.selected {
286 | float: right;
287 | }
288 | }
289 |
290 | &.second {
291 | &.selected {
292 | float: left;
293 | }
294 | }
295 |
296 | &.third {
297 | &.selected {
298 | float: left;
299 | }
300 | }
301 |
302 | &.fourth {
303 | &.selected {
304 | float: right;
305 | }
306 | }
307 | }
308 |
309 | &.selected {
310 | position: relative;
311 | max-height: 10000px;
312 |
313 | @if not $UIRefresh2022 {
314 | max-width: 40%;
315 | }
316 | }
317 |
318 | @if $UIRefresh2022 {
319 | max-height: 0;
320 | opacity: 0;
321 | transition: opacity 0.3s ease-out;
322 |
323 | &.selected {
324 | opacity: 1;
325 | transition: opacity 1s ease;
326 |
327 | @include media-query-medium {
328 | transition: opacity 1s ease 1s;
329 | }
330 | }
331 | }
332 |
333 | ul {
334 | padding: 0;
335 | margin-left: 0;
336 |
337 | li {
338 | list-style-type: none;
339 | padding-left: 0;
340 |
341 | .blip-list-item {
342 | padding: 2px 5px;
343 | border-radius: 2px;
344 | cursor: pointer;
345 | font-size: $baseFont;
346 | font-weight: 400;
347 |
348 | &.highlight {
349 | color: white;
350 | background-color: rgba(0, 0, 0, 0.8);
351 | }
352 | }
353 |
354 | .blip-item-description {
355 | max-height: 0;
356 | overflow: hidden;
357 | width: 300px;
358 |
359 | p {
360 | margin: 0;
361 | border-top: 1px solid rgb(119, 119, 119);
362 | border-bottom: 1px solid rgb(119, 119, 119);
363 | padding: 20px;
364 | color: $grey-text;
365 | font-weight: 100;
366 | font-size: 14px;
367 | }
368 |
369 | transition: max-height 0.2s ease;
370 |
371 | &.expanded {
372 | transition: max-height 0.5s ease 0.2s;
373 | max-height: 1000px;
374 | }
375 | }
376 | }
377 | }
378 | }
379 | }
380 |
381 | @if ($UIRefresh2022) {
382 | .radar-legends {
383 | display: none;
384 |
385 | @include media-query-medium {
386 | &.right-view,
387 | &.left-view {
388 | justify-content: unset;
389 | width: unset;
390 | }
391 |
392 | &.sticky {
393 | position: fixed;
394 | }
395 |
396 | display: flex;
397 | align-items: center;
398 | justify-content: center;
399 | margin: $quadrantsGap auto;
400 | width: 100%;
401 | position: absolute;
402 | top: calc($quadrantWidth * 2 + $quadrantsGap);
403 | }
404 |
405 | img:nth-child(n + 2) {
406 | margin-left: 24px;
407 | }
408 | }
409 |
410 | .all-quadrants-mobile {
411 | --quadrant-gap: 12px;
412 | --quadrant-btn-width-mobile: 150px;
413 | --quadrant-btn-height-mobile: 70px;
414 | display: none;
415 | flex-direction: column;
416 | flex-wrap: wrap;
417 | justify-content: space-between;
418 | align-content: space-between;
419 | margin: auto;
420 | margin-bottom: 42px;
421 |
422 | &.show-all-quadrants-mobile {
423 | display: flex;
424 | }
425 | @include media-query-medium {
426 | --quadrant-btn-width-mobile: 345px;
427 | --quadrant-btn-height-mobile: 160px;
428 | }
429 | @include media-query-xlarge {
430 | display: none;
431 | &.show-all-quadrants-mobile {
432 | display: none;
433 | }
434 | }
435 |
436 | width: calc(var(--quadrant-btn-width-mobile) * 2 + var(--quadrant-gap));
437 | height: calc(var(--quadrant-btn-height-mobile) * 2 + var(--quadrant-gap));
438 |
439 | .all-quadrants-mobile--btn {
440 | display: flex;
441 | justify-content: center;
442 | align-items: center;
443 | text-align: center;
444 | margin: 3px;
445 | width: var(--quadrant-btn-width-mobile);
446 | height: var(--quadrant-btn-height-mobile);
447 | background-size: 100%;
448 | background-repeat: no-repeat;
449 | font-size: 16px;
450 | font-weight: bold;
451 | color: white;
452 | border: none;
453 |
454 | @include media-query-medium {
455 | font-size: 24px;
456 | }
457 |
458 | &::after {
459 | content: url('/images/arrow-white-icon.svg');
460 | margin: 4px 4px 0;
461 | }
462 |
463 | .btn-text-wrapper {
464 | text-align: left;
465 | word-break: break-word;
466 | overflow: hidden;
467 | text-overflow: ellipsis;
468 | display: -webkit-box;
469 | -webkit-line-clamp: 3;
470 | -webkit-box-orient: vertical;
471 |
472 | @include media-query-medium {
473 | max-width: 60%;
474 | max-height: 60px;
475 | }
476 | }
477 | }
478 |
479 | #first-quadrant-mobile {
480 | background-color: $sapphire;
481 | }
482 | #second-quadrant-mobile {
483 | background-color: $turmeric;
484 | }
485 | #third-quadrant-mobile {
486 | background-color: $jade;
487 | }
488 | #fourth-quadrant-mobile {
489 | background-color: $flamingo;
490 | }
491 | }
492 | }
493 |
--------------------------------------------------------------------------------
/src/stylesheets/_quadrantsubnav.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | @if $UIRefresh2022 {
5 | .quadrant-subnav {
6 | font-size: 1.125rem;
7 | width: 100%;
8 | background-color: $mist;
9 | display: flex;
10 | flex-direction: column;
11 | height: fit-content;
12 | min-height: $subnavHeight;
13 |
14 | @include media-query-xlarge {
15 | flex-direction: row;
16 | justify-content: center;
17 | height: $subnavHeight;
18 | font-size: 1.25rem;
19 | }
20 |
21 | &.sticky {
22 | position: fixed;
23 | top: 0;
24 | width: 100%;
25 | z-index: 999;
26 | }
27 |
28 | &__dropdown {
29 | height: fit-content;
30 | min-height: $subnavHeight;
31 | font-weight: 630;
32 | display: inline-flex;
33 | border-bottom: 1px solid $mist-s20;
34 | align-items: center;
35 | flex-direction: row;
36 | justify-content: center;
37 | gap: 8px;
38 | cursor: pointer;
39 |
40 | &-arrow {
41 | width: 8px;
42 | height: 8px;
43 | display: inline-flex;
44 | rotate: 45deg;
45 | border: 1px solid $flamingo;
46 | border-width: 0 2px 2px 0;
47 | margin-top: 0;
48 | margin-bottom: 4px;
49 | -webkit-transition: all 0.2s ease;
50 | transition: all 0.2s ease;
51 |
52 | &.rotate {
53 | rotate: -135deg;
54 | margin-bottom: 0;
55 | margin-top: 4px;
56 | }
57 | }
58 |
59 | @include media-query-xlarge {
60 | display: none;
61 | }
62 | }
63 |
64 | &__list {
65 | display: none;
66 | width: 100%;
67 |
68 | &.show {
69 | display: flex;
70 | flex-direction: column;
71 | width: 100%;
72 | list-style-type: none;
73 | margin: 0;
74 | padding: 0;
75 | }
76 |
77 | &-item {
78 | width: 100%;
79 | min-height: $subnavHeight;
80 | display: inline-flex;
81 | align-items: center;
82 | border-bottom: 1px solid $mist-s20;
83 | justify-content: center;
84 | padding: 0;
85 | height: 100%;
86 | box-sizing: border-box;
87 | font-size: 16px;
88 |
89 | @include media-query-xlarge {
90 | max-width: 20% !important;
91 | &.active-item {
92 | padding-top: 4px;
93 | border-bottom: 4px solid transparent;
94 | pointer-events: none;
95 | font-weight: bold;
96 | transition: font-weight 0.3s ease-in-out;
97 |
98 | &:nth-child(1) {
99 | border-color: $mist-s30;
100 | }
101 |
102 | &:nth-child(2) {
103 | border-color: $sapphire;
104 | }
105 |
106 | &:nth-child(3) {
107 | border-color: $turmeric;
108 | }
109 |
110 | &:nth-child(4) {
111 | border-color: $jade;
112 | }
113 |
114 | &:nth-child(5) {
115 | border-color: $flamingo;
116 | }
117 | }
118 | }
119 |
120 | &__button {
121 | text-decoration: none;
122 | border: none;
123 | font: inherit;
124 | cursor: pointer;
125 | background-color: $mist;
126 |
127 | @include media-query-xlarge {
128 | padding: 15px 40px;
129 | margin: 0 1px;
130 | white-space: nowrap;
131 | overflow: hidden;
132 | text-overflow: ellipsis;
133 | }
134 | }
135 | }
136 |
137 | @include media-query-xlarge {
138 | height: 100%;
139 | display: flex;
140 | flex-direction: row;
141 | justify-content: center;
142 | align-items: center;
143 | margin: 0;
144 | padding: 0;
145 |
146 | &.show {
147 | flex-direction: row;
148 | }
149 |
150 | &-item {
151 | width: unset;
152 | min-height: unset;
153 | border: none;
154 |
155 | &:not(.active-item):hover {
156 | color: $flamingo-s40;
157 | text-decoration: underline;
158 | text-underline-offset: 6px;
159 |
160 | & > * {
161 | color: $flamingo-s40;
162 | }
163 | }
164 | }
165 | }
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/stylesheets/_search.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'layout';
3 |
4 | @if $UIRefresh2022 {
5 | .graph-header {
6 | display: flex;
7 | flex-direction: column;
8 | height: auto;
9 |
10 | @include media-query-large {
11 | align-items: center;
12 | }
13 | }
14 |
15 | .search-container {
16 | height: auto;
17 | margin: 48px auto 40px;
18 | display: flex;
19 | justify-content: center;
20 | align-items: center;
21 |
22 | &.sticky-offset {
23 | margin-top: 108px;
24 | }
25 |
26 | @include layout-margin(1, $screen-small);
27 | @include layout-margin(calc(10 / 12), $screen-large);
28 | @include layout-margin(calc(10 / 12), $screen-xlarge);
29 | @include layout-margin(calc(10 / 12), $screen-xxlarge);
30 | @include layout-margin(calc(8 / 12), $screen-xxxlarge);
31 |
32 | @include media-query-medium {
33 | height: 48px;
34 | margin: 64px auto 48px;
35 |
36 | &.sticky-offset {
37 | margin-top: 124px;
38 | }
39 | }
40 |
41 | @include media-query-large {
42 | margin: 72px auto 48px;
43 |
44 | &.sticky-offset {
45 | margin-top: 132px;
46 | }
47 | }
48 |
49 | @include media-query-xlarge {
50 | margin: 32px auto;
51 |
52 | &.sticky-offset {
53 | margin-top: 92px;
54 | }
55 | }
56 |
57 | @include media-query-xxlarge {
58 | margin: 40px auto;
59 |
60 | &.sticky-offset {
61 | margin-top: 100px;
62 | }
63 | }
64 |
65 | &__input {
66 | color: $wave;
67 | height: 48px;
68 | margin-bottom: 30px;
69 | background: #edf1f3 url(images/search-active-wave.svg) no-repeat 98% center;
70 | font-family: $baseFontFamily;
71 | scroll-margin-top: $subnavHeight;
72 |
73 | @include media-query-medium {
74 | height: 100%;
75 | margin-bottom: 0;
76 | flex-grow: 1;
77 | }
78 | }
79 | }
80 |
81 | ul.ui-autocomplete {
82 | max-height: 196px !important;
83 | z-index: 999;
84 |
85 | li div {
86 | height: 48px;
87 | display: flex;
88 | align-items: center;
89 | padding-left: 16px;
90 | border-bottom: 1px solid #edf1f3;
91 | font-family: $baseFontFamily;
92 | font-size: 16px;
93 | color: $black;
94 |
95 | &.ui-state-active {
96 | background-color: $mist !important;
97 | color: $black !important;
98 |
99 | &:active {
100 | background-color: $mist-light !important;
101 | }
102 | }
103 | }
104 | }
105 | }
106 |
107 | .ui-autocomplete {
108 | z-index: 30 !important;
109 | }
110 |
--------------------------------------------------------------------------------
/src/stylesheets/_tip.scss:
--------------------------------------------------------------------------------
1 | .d3-tip {
2 | white-space: nowrap;
3 | line-height: 1;
4 | font-size: 12px;
5 | display: none;
6 | padding: 12px;
7 | background: rgba(0, 0, 0, 0.8);
8 | color: #fff;
9 | border-radius: 4px;
10 | border-color: #000;
11 | pointer-events: none;
12 | z-index: 20;
13 | }
14 |
15 | @media screen and (min-width: 800px) {
16 | .d3-tip {
17 | display: block;
18 | }
19 | }
20 |
21 | .d3-tip:after {
22 | box-sizing: border-box;
23 | display: none;
24 | font-size: 10px;
25 | width: 100%;
26 | line-height: 1;
27 | color: rgba(0, 0, 0, 0.8);
28 | position: absolute;
29 | pointer-events: none;
30 | }
31 |
32 | @media screen and (min-width: 800px) {
33 | .d3-tip:after {
34 | display: inline;
35 | }
36 | }
37 |
38 | .d3-tip.n {
39 | margin: -10px 0 0;
40 | }
41 | .d3-tip.n:after {
42 | content: '\25BC';
43 | margin: -3px 0 0;
44 | top: 100%;
45 | left: 0;
46 | text-align: center;
47 | }
48 |
49 | .d3-tip.ne {
50 | margin: -10px 0 0 -45px;
51 | }
52 | .d3-tip.ne:after {
53 | content: '\25BC';
54 | bottom: -8px;
55 | left: 18px;
56 | text-align: left;
57 | }
58 |
59 | .d3-tip.nw {
60 | margin: -10px 0 0 45px;
61 | }
62 | .d3-tip.nw:after {
63 | content: '\25BC';
64 | bottom: -8px;
65 | right: 18px;
66 | text-align: right;
67 | }
68 |
--------------------------------------------------------------------------------
/src/stylesheets/base.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'fonts';
3 | @import 'tip';
4 | @import 'form';
5 | @import 'error';
6 | @import 'header';
7 | @import 'footer';
8 | @import 'herobanner';
9 | @import 'quadrantsubnav';
10 | @import 'search';
11 | @import 'alternativeradars';
12 | @import 'buttons';
13 | @import 'quadrants';
14 | @import 'quadrantTables';
15 | @import 'mediaqueries';
16 | @import 'layout';
17 | @import 'landingpage';
18 | @import 'loader';
19 | @import 'screen';
20 | @import 'pdfPage';
21 |
22 | :root {
23 | font-family: 'Inter', sans-serif;
24 | }
25 |
26 | @supports (font-variation-settings: normal) {
27 | :root {
28 | font-family: 'Inter var', sans-serif;
29 | }
30 | }
31 |
32 | @media screen {
33 | body {
34 | font: 18px 'Open Sans';
35 | opacity: 0;
36 | @if $UIRefresh2022 {
37 | font-family: $baseFontFamily;
38 | opacity: 1;
39 |
40 | h1 {
41 | font-size: 1.5rem;
42 | font-family: 'Bitter', serif;
43 | text-transform: none;
44 | letter-spacing: normal;
45 |
46 | @include media-query-medium {
47 | font-size: 2rem;
48 | }
49 |
50 | @include media-query-large {
51 | font-size: 2.5rem;
52 | }
53 |
54 | @include media-query-xxlarge {
55 | font-size: 3rem;
56 | }
57 |
58 | @include media-query-xxxlarge {
59 | font-size: 3.5rem;
60 | }
61 | }
62 |
63 | h2 {
64 | font-size: 1.25rem;
65 | font-weight: 630;
66 | font-family: 'Inter', serif;
67 | text-transform: none;
68 | letter-spacing: normal;
69 |
70 | @include media-query-medium {
71 | font-size: 1.25rem;
72 | }
73 |
74 | @include media-query-large {
75 | font-size: 1.5rem;
76 | }
77 |
78 | @include media-query-xlarge {
79 | font-size: 1.5rem;
80 | }
81 |
82 | @include media-query-xxlarge {
83 | font-size: 2rem;
84 | }
85 |
86 | @include media-query-xxxlarge {
87 | font-size: 2rem;
88 | }
89 | }
90 |
91 | p {
92 | font-size: 1rem;
93 | font-family: $baseFontFamily;
94 |
95 | @include media-query-xxlarge {
96 | font-size: 1.125rem;
97 | }
98 | }
99 |
100 | a {
101 | color: $link-normal;
102 | border-color: $link-normal;
103 |
104 | &:hover {
105 | color: $link-hover;
106 | border-color: $link-hover;
107 | }
108 | }
109 | }
110 |
111 | -webkit-font-smoothing: antialiased;
112 | margin: 0;
113 | }
114 |
115 | @if $UIRefresh2022 {
116 | .d3-tip {
117 | font-size: 12px;
118 | display: block;
119 | padding: 12px;
120 | background: $wave;
121 | color: $white;
122 | pointer-events: none;
123 | z-index: 20;
124 | max-width: 170px;
125 | word-wrap: break-word;
126 | white-space: pre-line;
127 | line-height: 2;
128 | border-radius: unset;
129 |
130 | @include media-query-medium {
131 | max-width: 250px;
132 | }
133 | }
134 |
135 | .d3-tip:after {
136 | box-sizing: border-box;
137 | font-size: 10px;
138 | width: 100%;
139 | color: $wave;
140 | position: absolute;
141 | pointer-events: none;
142 | }
143 |
144 | .d3-tip.n {
145 | margin: -10px 0 0;
146 | }
147 |
148 | .d3-tip.n:after {
149 | content: '\25BC';
150 | margin: -3px 0 0;
151 | top: 100%;
152 | left: 0;
153 | text-align: center;
154 | }
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/util/autoComplete.js:
--------------------------------------------------------------------------------
1 | const $ = require('jquery')
2 | require('jquery-ui/ui/widgets/autocomplete')
3 |
4 | const config = require('../config')
5 | const featureToggles = config().featureToggles
6 |
7 | $.widget('custom.radarcomplete', $.ui.autocomplete, {
8 | _create: function () {
9 | this._super()
10 | this.widget().menu('option', 'items', '> :not(.ui-autocomplete-quadrant)')
11 | },
12 | _renderMenu: function (ul, items) {
13 | let currentQuadrant = ''
14 |
15 | items.forEach((item) => {
16 | const quadrantName = item.quadrant.quadrant.name()
17 | if (quadrantName !== currentQuadrant) {
18 | ul.append(`${quadrantName} `)
19 | currentQuadrant = quadrantName
20 | }
21 | const li = this._renderItemData(ul, item)
22 | if (quadrantName) {
23 | li.attr('aria-label', `${quadrantName}:${item.value}`)
24 | }
25 | })
26 | },
27 | })
28 |
29 | const AutoComplete = (el, quadrants, cb) => {
30 | const blips = quadrants.reduce((acc, quadrant) => {
31 | return [...acc, ...quadrant.quadrant.blips().map((blip) => ({ blip, quadrant }))]
32 | }, [])
33 |
34 | if (featureToggles.UIRefresh2022) {
35 | $(el).autocomplete({
36 | appendTo: '.search-container',
37 | source: (request, response) => {
38 | const matches = blips.filter(({ blip }) => {
39 | const searchable = `${blip.name()} ${blip.description()}`.toLowerCase()
40 | return request.term.split(' ').every((term) => searchable.includes(term.toLowerCase()))
41 | })
42 | response(matches.map((item) => ({ ...item, value: item.blip.name() })))
43 | },
44 | select: cb.bind({}),
45 | })
46 | } else {
47 | $(el).radarcomplete({
48 | source: (request, response) => {
49 | const matches = blips.filter(({ blip }) => {
50 | const searchable = `${blip.name()} ${blip.description()}`.toLowerCase()
51 | return request.term.split(' ').every((term) => searchable.includes(term.toLowerCase()))
52 | })
53 | response(matches.map((item) => ({ ...item, value: item.blip.name() })))
54 | },
55 | select: cb.bind({}),
56 | })
57 | }
58 | }
59 |
60 | module.exports = AutoComplete
61 |
--------------------------------------------------------------------------------
/src/util/contentValidator.js:
--------------------------------------------------------------------------------
1 | const _ = {
2 | map: require('lodash/map'),
3 | uniqBy: require('lodash/uniqBy'),
4 | capitalize: require('lodash/capitalize'),
5 | each: require('lodash/each'),
6 | }
7 |
8 | const MalformedDataError = require('../../src/exceptions/malformedDataError')
9 | const ExceptionMessages = require('./exceptionMessages')
10 |
11 | const ContentValidator = function (columnNames) {
12 | var self = {}
13 | columnNames = columnNames.map(function (columnName) {
14 | return columnName.trim()
15 | })
16 |
17 | self.verifyContent = function () {
18 | if (columnNames.length === 0) {
19 | throw new MalformedDataError(ExceptionMessages.MISSING_CONTENT)
20 | }
21 | }
22 |
23 | self.verifyHeaders = function () {
24 | _.each(['name', 'ring', 'quadrant', 'description'], function (field) {
25 | if (columnNames.indexOf(field) === -1) {
26 | throw new MalformedDataError(ExceptionMessages.MISSING_HEADERS)
27 | }
28 | })
29 |
30 | // At least one of isNew or status must be present
31 | if (columnNames.indexOf('isNew') === -1 && columnNames.indexOf('status') === -1) {
32 | throw new MalformedDataError(ExceptionMessages.MISSING_HEADERS)
33 | }
34 | }
35 |
36 | return self
37 | }
38 |
39 | module.exports = ContentValidator
40 |
--------------------------------------------------------------------------------
/src/util/exceptionMessages.js:
--------------------------------------------------------------------------------
1 | const ExceptionMessages = {
2 | TOO_MANY_QUADRANTS: 'There are more than 4 quadrant names listed in your data. Check the quadrant column for errors.',
3 | TOO_MANY_RINGS: 'More than 4 rings.',
4 | MISSING_HEADERS:
5 | 'Document is missing one or more required headers or they are misspelled. ' +
6 | 'Check that your document contains headers for "name", "ring", "quadrant", "isNew", "description".',
7 | MISSING_CONTENT: 'Document is missing content.',
8 | LESS_THAN_FOUR_QUADRANTS:
9 | 'There are less than 4 quadrant names listed in your data. Check the quadrant column for errors.',
10 | SHEET_NOT_FOUND: 'Oops! We can’t find the Google Sheet you’ve entered. Can you check the URL?',
11 | SHEET_NOT_FOUND_NEW: 'Oops! We can’t find the Google Sheet you’ve entered, please check the URL of your sheet.',
12 | UNAUTHORIZED: 'UNAUTHORIZED',
13 | INVALID_CONFIG: 'Unexpected number of quadrants or rings. Please check in the configuration.',
14 | INVALID_JSON_CONTENT: 'Invalid content of JSON file. Please check the content of file.',
15 | INVALID_CSV_CONTENT: 'Invalid content of CSV file. Please check the content of file.',
16 | }
17 |
18 | module.exports = ExceptionMessages
19 |
--------------------------------------------------------------------------------
/src/util/googleAuth.js:
--------------------------------------------------------------------------------
1 | /* global gapi */
2 | const d3 = require('d3')
3 |
4 | // Client ID and API key from the Developer Console
5 | var CLIENT_ID = process.env.CLIENT_ID
6 | var API_KEY = process.env.API_KEY
7 |
8 | // Array of API discovery doc URLs for APIs used by the quickstart
9 | var DISCOVERY_DOCS = ['https://sheets.googleapis.com/$discovery/rest?version=v4']
10 |
11 | // Authorization scopes required by the API multiple scopes can be
12 | // included, separated by spaces.
13 | var SCOPES = 'https://www.googleapis.com/auth/spreadsheets.readonly'
14 |
15 | const GoogleAuth = function () {
16 | const self = {}
17 | self.forceLogin = false
18 | self.isAuthorizedCallbacks = []
19 | self.isLoggedIn = undefined
20 | self.userEmail = ''
21 | let tokenClient
22 | self.gapiInitiated = false
23 | self.gsiInitiated = false
24 |
25 | self.loadAuthAPI = function () {
26 | !self.gapiInitiated &&
27 | self.content.append('script').attr('src', 'https://apis.google.com/js/api.js').on('load', self.handleClientLoad)
28 | }
29 |
30 | self.loadGSI = function () {
31 | !self.gsiInitiated &&
32 | self.content
33 | .append('script')
34 | .attr('src', 'https://accounts.google.com/gsi/client')
35 | .on('load', function () {
36 | self.gsiLogin()
37 | })
38 | }
39 |
40 | self.loadGoogle = function (forceLogin = false, callback) {
41 | self.loadedCallback = callback
42 | self.forceLogin = forceLogin
43 | self.content = d3.select('body')
44 |
45 | if (!self.forceLogin) {
46 | self.loadAuthAPI()
47 | } else {
48 | self.gsiLogin(forceLogin)
49 | }
50 | }
51 |
52 | function parseJwt(token) {
53 | var base64Url = token.split('.')[1]
54 | var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
55 | var jsonPayload = decodeURIComponent(
56 | window
57 | .atob(base64)
58 | .split('')
59 | .map(function (c) {
60 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
61 | })
62 | .join(''),
63 | )
64 |
65 | return JSON.parse(jsonPayload)
66 | }
67 |
68 | self.gsiCallback = async function (credentialResponse) {
69 | let jwToken
70 | if (credentialResponse) {
71 | jwToken = parseJwt(credentialResponse.credential)
72 | }
73 |
74 | tokenClient = await window.google.accounts.oauth2.initTokenClient({
75 | client_id: CLIENT_ID,
76 | scope: SCOPES,
77 | callback: '',
78 | prompt: self.forceLogin ? 'select_account' : '',
79 | hint: self.forceLogin ? '' : jwToken?.email,
80 | })
81 |
82 | self.gsiInitiated = true
83 | self.prompt()
84 | }
85 |
86 | self.gsiLogin = async function (forceLogin = false) {
87 | self.forceLogin = forceLogin
88 | window.google.accounts.id.initialize({
89 | client_id: CLIENT_ID,
90 | callback: self.gsiCallback,
91 | auto_select: self.forceLogin ? false : true,
92 | cancel_on_tap_outside: false,
93 | })
94 | if (!self.forceLogin) {
95 | window.google.accounts.id.prompt((notification) => {
96 | if (notification.isSkippedMoment()) {
97 | plotAuthenticationErrorMessage()
98 | }
99 | })
100 | } else {
101 | await self.gsiCallback()
102 | }
103 | }
104 | function plotAuthenticationErrorMessage() {
105 | let homePageURL = window.location.protocol + '//' + window.location.hostname
106 | homePageURL += window.location.port === '' ? '' : ':' + window.location.port
107 | const message = `Oops! Authentication incomplete. Please try again.`
108 | const goBack = 'Go back '
109 | document.cookie = 'g_state=; path=/; max-age=0'
110 | d3.selectAll('.loader-text').remove()
111 | let content = d3.select('body').select('.error-container').append('div').attr('class', 'input-sheet')
112 | const errorContainer = content.append('div').attr('class', 'error-container__message')
113 | errorContainer.append('div').append('p').attr('class', 'error-title').html(message)
114 | errorContainer
115 | .append('div')
116 | .append('p')
117 | .attr('class', 'error-subtitle')
118 | .html(` ${goBack} and please login. `)
119 | }
120 |
121 | self.handleClientLoad = function () {
122 | gapi.load('client', self.initClient)
123 | }
124 |
125 | self.isAuthorized = function (callback) {
126 | self.isAuthorizedCallbacks.push(callback)
127 | if (self.isLoggedIn !== undefined) {
128 | callback(self.isLoggedIn)
129 | }
130 | }
131 |
132 | self.prompt = async function () {
133 | if (self.gsiInitiated && self.gapiInitiated) {
134 | const token = gapi.client.getToken()
135 | if (token && token.access_token && !self.forceLogin) {
136 | const options = { method: 'GET', headers: { authorization: `Bearer ${token.access_token}` } }
137 | const response = await fetch('https://www.googleapis.com/oauth2/v1/userinfo', options)
138 | const profile = await response.json()
139 | self.userEmail = profile.email
140 | self.loadedCallback()
141 | } else {
142 | tokenClient.callback = () => {
143 | self.forceLogin = false
144 | self.prompt()
145 | }
146 | tokenClient.requestAccessToken()
147 | }
148 | }
149 | }
150 |
151 | self.initClient = async function () {
152 | gapi.client
153 | .init({
154 | apiKey: API_KEY,
155 | discoveryDocs: DISCOVERY_DOCS,
156 | scope: SCOPES,
157 | })
158 | .then(() => {
159 | self.gapiInitiated = true
160 | self.loadedCallback()
161 | })
162 | }
163 |
164 | self.getEmail = () => {
165 | return self.userEmail
166 | }
167 |
168 | return self
169 | }
170 |
171 | module.exports = new GoogleAuth()
172 |
--------------------------------------------------------------------------------
/src/util/htmlUtil.js:
--------------------------------------------------------------------------------
1 | function getElementWidth(element) {
2 | return element.node().getBoundingClientRect().width
3 | }
4 |
5 | function decodeHTML(encodedText) {
6 | const parser = new DOMParser()
7 | return parser.parseFromString(encodedText, 'text/html').body.textContent
8 | }
9 |
10 | function getElementHeight(element) {
11 | return element.node().getBoundingClientRect().height
12 | }
13 |
14 | module.exports = {
15 | getElementWidth,
16 | getElementHeight,
17 | decodeHTML,
18 | }
19 |
--------------------------------------------------------------------------------
/src/util/inputSanitizer.js:
--------------------------------------------------------------------------------
1 | const sanitizeHtml = require('sanitize-html')
2 | const _ = {
3 | forOwn: require('lodash/forOwn'),
4 | }
5 |
6 | const InputSanitizer = function () {
7 | var relaxedOptions = {
8 | allowedTags: ['b', 'i', 'em', 'strong', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'br', 'p', 'u'],
9 | allowedAttributes: {
10 | a: ['href', 'target', 'rel'],
11 | },
12 | }
13 |
14 | var restrictedOptions = {
15 | allowedTags: [],
16 | allowedAttributes: {},
17 | textFilter: function (text) {
18 | return text.replace(/&/, '&')
19 | },
20 | }
21 |
22 | function trimWhiteSpaces(blip) {
23 | var processedBlip = {}
24 | _.forOwn(blip, function (value, key) {
25 | processedBlip[key.trim()] = value.trim()
26 | })
27 | return processedBlip
28 | }
29 |
30 | var self = {}
31 | self.sanitize = function (rawBlip) {
32 | var blip = trimWhiteSpaces(rawBlip)
33 | blip.description = sanitizeHtml(blip.description, relaxedOptions)
34 | blip.name = sanitizeHtml(blip.name, restrictedOptions)
35 | blip.isNew = sanitizeHtml(blip.isNew, restrictedOptions)
36 | blip.status = sanitizeHtml(blip.status, restrictedOptions)
37 | blip.ring = sanitizeHtml(blip.ring, restrictedOptions)
38 | blip.quadrant = sanitizeHtml(blip.quadrant, restrictedOptions)
39 |
40 | return blip
41 | }
42 |
43 | self.sanitizeForProtectedSheet = function (rawBlip, header) {
44 | var blip = trimWhiteSpaces(rawBlip)
45 |
46 | const descriptionIndex = header.indexOf('description')
47 | const nameIndex = header.indexOf('name')
48 | const isNewIndex = header.indexOf('isNew')
49 | const statusIndex = header.indexOf('status')
50 | const quadrantIndex = header.indexOf('quadrant')
51 | const ringIndex = header.indexOf('ring')
52 |
53 | const description = descriptionIndex === -1 ? '' : blip[descriptionIndex]
54 | const name = nameIndex === -1 ? '' : blip[nameIndex]
55 | const isNew = isNewIndex === -1 ? '' : blip[isNewIndex]
56 | const status = statusIndex === -1 ? '' : blip[statusIndex]
57 | const ring = ringIndex === -1 ? '' : blip[ringIndex]
58 | const quadrant = quadrantIndex === -1 ? '' : blip[quadrantIndex]
59 |
60 | blip.description = sanitizeHtml(description, relaxedOptions)
61 | blip.name = sanitizeHtml(name, restrictedOptions)
62 | blip.isNew = sanitizeHtml(isNew, restrictedOptions)
63 | blip.status = sanitizeHtml(status, restrictedOptions)
64 | blip.ring = sanitizeHtml(ring, restrictedOptions)
65 | blip.quadrant = sanitizeHtml(quadrant, restrictedOptions)
66 |
67 | return blip
68 | }
69 |
70 | return self
71 | }
72 |
73 | module.exports = InputSanitizer
74 |
--------------------------------------------------------------------------------
/src/util/mathUtils.js:
--------------------------------------------------------------------------------
1 | function toRadian(angleInDegrees) {
2 | return (Math.PI * angleInDegrees) / 180
3 | }
4 |
5 | module.exports = {
6 | toRadian,
7 | }
8 |
--------------------------------------------------------------------------------
/src/util/queryParamProcessor.js:
--------------------------------------------------------------------------------
1 | const QueryParams = function (queryString) {
2 | var decode = function (s) {
3 | return decodeURIComponent(s.replace(/\+/g, ' '))
4 | }
5 |
6 | var search = /([^&=]+)=?([^&]*)/g
7 |
8 | var queryParams = {}
9 | var match
10 | while ((match = search.exec(queryString))) {
11 | queryParams[decode(match[1])] = decode(match[2])
12 | }
13 |
14 | return queryParams
15 | }
16 |
17 | module.exports = QueryParams
18 |
--------------------------------------------------------------------------------
/src/util/ringCalculator.js:
--------------------------------------------------------------------------------
1 | const RingCalculator = function (numberOfRings, maxRadius) {
2 | var sequence = [0, 6, 5, 3, 2, 1, 1, 1]
3 |
4 | var self = {}
5 |
6 | self.sum = function (length) {
7 | return sequence.slice(0, length + 1).reduce(function (previous, current) {
8 | return previous + current
9 | }, 0)
10 | }
11 |
12 | self.getRadius = function (ring) {
13 | var total = self.sum(numberOfRings)
14 | var sum = self.sum(ring)
15 |
16 | return (maxRadius * sum) / total
17 | }
18 |
19 | self.getRingRadius = function (ringIndex) {
20 | const ratios = [0, 0.316, 0.652, 0.832, 1]
21 | const radius = ratios[ringIndex] * maxRadius
22 | return radius || 0
23 | }
24 |
25 | return self
26 | }
27 |
28 | module.exports = RingCalculator
29 |
--------------------------------------------------------------------------------
/src/util/sheet.js:
--------------------------------------------------------------------------------
1 | /* global gapi */
2 | const SheetNotFoundError = require('../../src/exceptions/sheetNotFoundError')
3 | const UnauthorizedError = require('../../src/exceptions/unauthorizedError')
4 | const ExceptionMessages = require('./exceptionMessages')
5 | const config = require('../config')
6 |
7 | const Sheet = function (sheetReference) {
8 | var self = {}
9 | const featureToggles = config().featureToggles
10 |
11 | ;(function () {
12 | var matches = sheetReference.match('https:\\/\\/docs.google.com\\/spreadsheets\\/d\\/(.*?)($|\\/$|\\/.*|\\?.*)')
13 | self.id = matches !== null ? matches[1] : sheetReference
14 | })()
15 |
16 | self.validate = function (callback) {
17 | var apiKeyEnabled = process.env.API_KEY || false
18 | var feedURL = 'https://docs.google.com/spreadsheets/d/' + self.id
19 |
20 | // TODO: Move this out (as HTTPClient)
21 | var xhr = new XMLHttpRequest()
22 | xhr.open('GET', feedURL, true)
23 | xhr.onreadystatechange = function () {
24 | if (xhr.readyState === 4) {
25 | if (xhr.status === 200) {
26 | return callback(null, apiKeyEnabled)
27 | } else if (xhr.status === 404) {
28 | return callback(self.createSheetNotFoundError(), apiKeyEnabled)
29 | } else {
30 | return callback(new UnauthorizedError(ExceptionMessages.UNAUTHORIZED), apiKeyEnabled)
31 | }
32 | }
33 | }
34 | xhr.send(null)
35 | }
36 |
37 | self.createSheetNotFoundError = function () {
38 | const exceptionMessage = featureToggles.UIRefresh2022
39 | ? ExceptionMessages.SHEET_NOT_FOUND_NEW
40 | : ExceptionMessages.SHEET_NOT_FOUND
41 | return new SheetNotFoundError(exceptionMessage)
42 | }
43 |
44 | self.getSheet = async function () {
45 | try {
46 | self.sheetResponse = await gapi.client.sheets.spreadsheets.get({ spreadsheetId: self.id })
47 | } catch (error) {
48 | self.sheetResponse = error
49 | }
50 | }
51 |
52 | self.getData = function (range) {
53 | return gapi.client.sheets.spreadsheets.values.get({
54 | spreadsheetId: self.id,
55 | range: range,
56 | })
57 | }
58 |
59 | self.processSheetResponse = async function (sheetName, createBlips, handleError) {
60 | return self.sheetResponse.status !== 200
61 | ? handleError(self.sheetResponse)
62 | : processSheetData(sheetName, createBlips, handleError)
63 | }
64 |
65 | function processSheetData(sheetName, createBlips, handleError) {
66 | const sheetNames = self.sheetResponse.result.sheets.map((s) => s.properties.title)
67 | sheetName = !sheetName ? sheetNames[0] : sheetName
68 | self
69 | .getData(sheetName + '!A1:F')
70 | .then((r) => createBlips(self.sheetResponse.result.properties.title, r.result.values, sheetNames))
71 | .catch(handleError)
72 | }
73 |
74 | return self
75 | }
76 |
77 | module.exports = Sheet
78 |
--------------------------------------------------------------------------------
/src/util/stringUtil.js:
--------------------------------------------------------------------------------
1 | function getRingIdString(ringName) {
2 | return ringName.replaceAll(/[^a-zA-Z0-9]/g, '-').toLowerCase()
3 | }
4 |
5 | function replaceSpaceWithHyphens(anyString) {
6 | return anyString.trim().replace(/\s+/g, '-').toLowerCase()
7 | }
8 |
9 | function removeAllSpaces(blipId) {
10 | return blipId.toString().replace(/\s+/g, '')
11 | }
12 |
13 | module.exports = {
14 | getRingIdString,
15 | replaceSpaceWithHyphens,
16 | removeAllSpaces,
17 | }
18 |
--------------------------------------------------------------------------------
/src/util/urlUtils.js:
--------------------------------------------------------------------------------
1 | const QueryParams = require('../util/queryParamProcessor')
2 |
3 | function constructSheetUrl(sheetName) {
4 | const noParamsUrl = window.location.href.substring(0, window.location.href.indexOf(window.location.search))
5 | const queryParams = QueryParams(window.location.search.substring(1))
6 | const sheetUrl =
7 | noParamsUrl +
8 | '?' +
9 | ((queryParams.documentId && `documentId=${encodeURIComponent(queryParams.documentId)}`) ||
10 | (queryParams.sheetId && `sheetId=${encodeURIComponent(queryParams.sheetId)}`) ||
11 | '') +
12 | '&sheetName=' +
13 | encodeURIComponent(sheetName)
14 | return sheetUrl
15 | }
16 |
17 | function getDocumentOrSheetId() {
18 | const queryParams = QueryParams(window.location.search.substring(1))
19 | return queryParams.documentId ?? queryParams.sheetId
20 | }
21 |
22 | function getSheetName() {
23 | const queryParams = QueryParams(window.location.search.substring(1))
24 | return queryParams.sheetName
25 | }
26 |
27 | module.exports = {
28 | constructSheetUrl,
29 | getDocumentOrSheetId,
30 | getSheetName,
31 | }
32 |
--------------------------------------------------------------------------------
/webpack.common.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const webpack = require('webpack')
4 | const path = require('path')
5 | const buildPath = path.resolve(__dirname, 'dist')
6 | const args = require('yargs').argv
7 |
8 | const HtmlWebpackPlugin = require('html-webpack-plugin')
9 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
10 |
11 | const env = args.envFile
12 | if (env) {
13 | // Load env file
14 | require('dotenv').config({ path: env })
15 | }
16 |
17 | const common = ['./src/common.js']
18 |
19 | const ASSET_PATH = process.env.ASSET_PATH || '/'
20 |
21 | const plugins = [
22 | new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' }),
23 | new HtmlWebpackPlugin({
24 | template: './src/index.html',
25 | chunks: ['main'],
26 | inject: 'body',
27 | }),
28 | new webpack.DefinePlugin({
29 | 'process.env.CLIENT_ID': JSON.stringify(process.env.CLIENT_ID),
30 | 'process.env.API_KEY': JSON.stringify(process.env.API_KEY),
31 | 'process.env.ENABLE_GOOGLE_AUTH': JSON.stringify(process.env.ENABLE_GOOGLE_AUTH),
32 | 'process.env.GTM_ID': JSON.stringify(process.env.GTM_ID),
33 | 'process.env.RINGS': JSON.stringify(process.env.RINGS),
34 | 'process.env.QUADRANTS': JSON.stringify(process.env.QUADRANTS),
35 | 'process.env.ADOBE_LAUNCH_SCRIPT_URL': JSON.stringify(process.env.ADOBE_LAUNCH_SCRIPT_URL),
36 | }),
37 | ]
38 |
39 | module.exports = {
40 | context: __dirname,
41 | entry: {
42 | common: common,
43 | },
44 | output: {
45 | path: buildPath,
46 | publicPath: ASSET_PATH,
47 | filename: '[name].[contenthash].js',
48 | assetModuleFilename: 'images/[name][ext]',
49 | },
50 | resolve: {
51 | extensions: ['.js', '.ts'],
52 | fallback: {
53 | fs: false,
54 | },
55 | },
56 |
57 | module: {
58 | rules: [
59 | {
60 | test: /\.js$/,
61 | exclude: /node_modules/,
62 | use: [
63 | {
64 | loader: 'babel-loader',
65 | options: {
66 | presets: ['@babel/preset-env'],
67 | },
68 | },
69 | ],
70 | },
71 | {
72 | test: /\.(eot|otf|ttf|woff|woff2)$/,
73 | type: 'asset/resource',
74 | },
75 | {
76 | test: /\.(png|jpg|jpeg|gif|ico|svg)$/,
77 | exclude: /node_modules/,
78 | type: 'asset/resource',
79 | },
80 | {
81 | test: require.resolve('jquery'),
82 | loader: 'expose-loader',
83 | options: { exposes: ['$', 'jQuery'] },
84 | },
85 | ],
86 | },
87 |
88 | plugins: plugins,
89 | }
90 |
--------------------------------------------------------------------------------
/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const { merge } = require('webpack-merge')
2 | const webpack = require('webpack')
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
4 | const postcssPresetEnv = require('postcss-preset-env')
5 | const cssnano = require('cssnano')
6 |
7 | const common = require('./webpack.common.js')
8 | const config = require('./src/config')
9 | const { graphConfig, uiConfig } = require('./src/graphing/config')
10 |
11 | const featureToggles = config().development.featureToggles
12 | const main = ['./src/site.js']
13 | const scssVariables = []
14 |
15 | Object.entries(graphConfig).forEach(function ([key, value]) {
16 | scssVariables.push(`$${key}: ${value}px;`)
17 | })
18 |
19 | Object.entries(uiConfig).forEach(function ([key, value]) {
20 | scssVariables.push(`$${key}: ${value}px;`)
21 | })
22 |
23 | Object.entries(featureToggles).forEach(function ([key, value]) {
24 | scssVariables.push(`$${key}: ${value};`)
25 | })
26 |
27 | module.exports = merge(common, {
28 | mode: 'development',
29 | entry: { main: main },
30 | performance: {
31 | hints: false,
32 | },
33 | module: {
34 | rules: [
35 | {
36 | test: /\.scss$/,
37 | exclude: /node_modules/,
38 | use: [
39 | 'style-loader',
40 | MiniCssExtractPlugin.loader,
41 | {
42 | loader: 'css-loader',
43 | options: { importLoaders: 1, modules: 'global', url: false },
44 | },
45 | {
46 | loader: 'postcss-loader',
47 | options: {
48 | postcssOptions: {
49 | plugins: [
50 | postcssPresetEnv({ browsers: 'last 2 versions' }),
51 | cssnano({
52 | preset: ['default', { discardComments: { removeAll: true } }],
53 | }),
54 | ],
55 | },
56 | },
57 | },
58 | {
59 | loader: 'sass-loader',
60 | options: {
61 | additionalData: scssVariables.join('\n'),
62 | },
63 | },
64 | ],
65 | },
66 | ],
67 | },
68 | plugins: [
69 | new webpack.DefinePlugin({
70 | 'process.env.ENVIRONMENT': JSON.stringify('development'),
71 | }),
72 | ],
73 | devtool: 'source-map',
74 | })
75 |
--------------------------------------------------------------------------------
/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const { merge } = require('webpack-merge')
2 | const webpack = require('webpack')
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
4 | const postcssPresetEnv = require('postcss-preset-env')
5 | const cssnano = require('cssnano')
6 |
7 | const common = require('./webpack.common.js')
8 | const config = require('./src/config')
9 | const { graphConfig, uiConfig } = require('./src/graphing/config')
10 |
11 | const featureToggles = config().production.featureToggles
12 | const main = ['./src/site.js']
13 | const scssVariables = []
14 |
15 | Object.entries(graphConfig).forEach(function ([key, value]) {
16 | scssVariables.push(`$${key}: ${value}px;`)
17 | })
18 |
19 | Object.entries(uiConfig).forEach(function ([key, value]) {
20 | scssVariables.push(`$${key}: ${value}px;`)
21 | })
22 |
23 | Object.entries(featureToggles).forEach(function ([key, value]) {
24 | scssVariables.push(`$${key}: ${value};`)
25 | })
26 |
27 | module.exports = merge(common, {
28 | mode: 'production',
29 | entry: { main },
30 | performance: {
31 | hints: false,
32 | },
33 | module: {
34 | rules: [
35 | {
36 | test: /\.scss$/,
37 | exclude: /node_modules/,
38 | use: [
39 | 'style-loader',
40 | MiniCssExtractPlugin.loader,
41 | {
42 | loader: 'css-loader',
43 | options: { importLoaders: 1, modules: 'global', url: false },
44 | },
45 | {
46 | loader: 'postcss-loader',
47 | options: {
48 | postcssOptions: {
49 | plugins: [
50 | postcssPresetEnv({ browsers: 'last 2 versions' }),
51 | cssnano({
52 | preset: ['default', { discardComments: { removeAll: true } }],
53 | }),
54 | ],
55 | },
56 | },
57 | },
58 | {
59 | loader: 'sass-loader',
60 | options: {
61 | additionalData: scssVariables.join('\n'),
62 | },
63 | },
64 | ],
65 | },
66 | ],
67 | },
68 | plugins: [
69 | new webpack.NoEmitOnErrorsPlugin(),
70 | new webpack.DefinePlugin({
71 | 'process.env.ENVIRONMENT': JSON.stringify('production'),
72 | }),
73 | ],
74 | })
75 |
--------------------------------------------------------------------------------