├── .babelrc ├── .bundlewatch.config.js ├── .circleci └── config.yml ├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── add-to-project.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── CONTRIBUTING.md ├── DEPLOYMENT.md ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── dist └── .keep ├── docs └── issue_template.md ├── package-lock.json ├── package.json ├── screenshot.png ├── scripts └── heroku-deploy.sh ├── server ├── .eslintrc.json ├── app.js ├── configuration.js └── sheet-data.js ├── src ├── .eslintrc.json ├── __mocks__ │ ├── file-mock.js │ ├── identity-object.js │ ├── simple-page.json │ └── simple-pages.json ├── components │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── page-url-details.test.jsx.snap │ │ ├── change-view.test.jsx │ │ ├── diff-view.test.jsx │ │ ├── environment-banner.test.jsx │ │ ├── login-form.test.jsx │ │ ├── nav-bar.test.jsx │ │ ├── page-details.test.jsx │ │ ├── page-list.test.jsx │ │ ├── page-url-details.test.jsx │ │ ├── sandboxed-html.test.jsx │ │ ├── search-bar.test.jsx │ │ └── source-info.test.jsx │ ├── annotation-form.jsx │ ├── change-view │ │ ├── change-view.css │ │ └── change-view.jsx │ ├── changes-only-diff.jsx │ ├── diff-item.jsx │ ├── diff-settings-form.jsx │ ├── diff-view.jsx │ ├── environment-banner │ │ ├── environment-banner.css │ │ └── environment-banner.jsx │ ├── external-link.jsx │ ├── fake-data.json │ ├── highlighted-text-diff.jsx │ ├── inline-rendered-diff.jsx │ ├── list.jsx │ ├── loading.jsx │ ├── login-form │ │ ├── login-form.css │ │ └── login-form.jsx │ ├── nav-bar │ │ ├── nav-bar.css │ │ └── nav-bar.jsx │ ├── page-details │ │ ├── page-details.css │ │ └── page-details.jsx │ ├── page-list │ │ ├── page-list.css │ │ └── page-list.jsx │ ├── page-tag │ │ ├── page-tag.css │ │ └── page-tag.jsx │ ├── page-url-details │ │ ├── page-url-details.css │ │ └── page-url-details.jsx │ ├── raw-version.jsx │ ├── sandboxed-html.jsx │ ├── search-bar │ │ ├── search-bar.css │ │ └── search-bar.jsx │ ├── search-date-picker.jsx │ ├── select-diff-type.jsx │ ├── select-version.jsx │ ├── side-by-side-raw-versions.jsx │ ├── side-by-side-rendered-diff.jsx │ ├── source-info │ │ ├── source-info.css │ │ └── source-info.jsx │ ├── standard-tooltip.jsx │ ├── version-redirect.jsx │ └── web-monitoring-ui.jsx ├── constants │ └── diff-types.js ├── css │ ├── base.css │ ├── diff.css │ ├── global.css │ └── styles.css ├── img │ └── infinity-loader.svg ├── scripts │ ├── __tests__ │ │ ├── db-helpers.test.js │ │ ├── html-transforms.test.js │ │ ├── layered-storage.test.js │ │ └── media-type.test.js │ ├── bind-component.js │ ├── db-helpers.js │ ├── formatters.js │ ├── html-transforms.js │ ├── http-info.js │ ├── layered-storage.js │ ├── main.jsx │ ├── media-type.js │ └── tools.js ├── services │ ├── web-monitoring-api.js │ └── web-monitoring-db.js └── test-setup.js ├── views └── main.html └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | // Uncomment to check browser query results when building. 5 | // "debug": true, 6 | 7 | // NOTE: the actual query is in package.json, so all tools (not just 8 | // Babel) can make use of it. 9 | // Preview the results a query at: https://browserl.ist/ 10 | // "targets": "xyz" 11 | }], 12 | ["@babel/preset-react", { 13 | "runtime": "automatic" 14 | }] 15 | ], 16 | "plugins": [ 17 | ["react-css-modules", { 18 | // for React Router NavLink 19 | "attributeNames": { "activeStyleName": "activeClassName" }, 20 | "handleMissingStyleName": "warn" 21 | }] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.bundlewatch.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "files": [ 3 | // Catch-all in case we add other JS files 4 | { 5 | "path": "dist/*.js", 6 | "maxSize": "200kB" 7 | }, 8 | 9 | // Main bundles 10 | { 11 | "path": "dist/bundle.js", 12 | "maxSize": "750kB", 13 | "compression": "none" 14 | }, 15 | { 16 | "path": "dist/bundle.js.gz", 17 | "maxSize": "250kB", 18 | // This file is pre-compressed, so bundlewatch needs to be told not to 19 | // compress before comparing. 20 | "compression": "none" 21 | } 22 | ], 23 | 24 | "ci": { 25 | // We use `main` instead of `master` for the primary branch. 26 | "trackBranches": ["main"] 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | commands: 4 | setup_npm: 5 | description: "Set up NPM dependencies" 6 | steps: 7 | - restore_cache: 8 | keys: 9 | - dependency-cache-2-{{ checksum "package-lock.json" }} 10 | - dependency-cache-2- 11 | - run: 12 | name: Dependencies 13 | command: npm ci 14 | - save_cache: 15 | key: dependency-cache-2-{{ checksum "package-lock.json" }} 16 | paths: 17 | - ./node_modules 18 | 19 | jobs: 20 | build: 21 | docker: 22 | - image: cimg/node:18.20.4 23 | steps: 24 | - checkout 25 | - setup_npm 26 | - run: 27 | name: Build 28 | command: npm run build-production 29 | - run: 30 | name: Check Bundle Size 31 | command: npm run bundlewatch 32 | 33 | test: 34 | docker: 35 | - image: cimg/node:18.20.4 36 | steps: 37 | - checkout 38 | - setup_npm 39 | - run: 40 | name: Lint Code 41 | command: npm run lint 42 | - run: 43 | name: Test 44 | command: npm test 45 | 46 | build_docker: 47 | machine: 48 | image: ubuntu-2004:202111-02 49 | steps: 50 | - checkout 51 | - run: 52 | name: Build Image 53 | command: | 54 | docker build -t envirodgi/ui:$CIRCLE_SHA1 . 55 | - run: 56 | name: Save Image 57 | command: | 58 | mkdir /tmp/workspace 59 | docker save --output /tmp/workspace/docker-image envirodgi/ui:$CIRCLE_SHA1 60 | - persist_to_workspace: 61 | root: /tmp/workspace 62 | paths: 63 | - docker-image 64 | 65 | publish_docker: 66 | machine: 67 | image: ubuntu-2004:202111-02 68 | steps: 69 | - attach_workspace: 70 | at: /tmp/workspace 71 | - run: 72 | name: Load Built Docker Image 73 | command: docker load --input /tmp/workspace/docker-image 74 | - run: 75 | name: Docker Login 76 | command: docker login -u $DOCKER_USER -p $DOCKER_PASS 77 | - run: 78 | name: Publish Images 79 | command: | 80 | docker image tag envirodgi/ui:${CIRCLE_SHA1} envirodgi/ui:latest 81 | docker push envirodgi/ui:${CIRCLE_SHA1} 82 | docker push envirodgi/ui:latest 83 | 84 | workflows: 85 | build: 86 | jobs: 87 | - build: 88 | filters: 89 | branches: 90 | ignore: release 91 | - build_docker: 92 | filters: 93 | branches: 94 | ignore: release 95 | - test: 96 | filters: 97 | branches: 98 | ignore: release 99 | 100 | build-and-publish: 101 | jobs: 102 | - build: 103 | filters: 104 | branches: 105 | only: 106 | - release 107 | - build_docker: 108 | filters: 109 | branches: 110 | only: 111 | - release 112 | - publish_docker: 113 | requires: 114 | - build 115 | - build_docker 116 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | .git/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 3 | indent_style = space 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | 7 | [*.{json,babelrc}] 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # If you are developing the API locally, set this to the URL of your local web-monitoring-db instance 2 | # EDGI's staging database (good for testing!) is: 3 | # https://api.monitoring-staging.envirodatagov.org 4 | # And you can use the public account's login credentials on it: 5 | # Username: public.access@envirodatagov.org 6 | # Password: PUBLIC_ACCESS 7 | WEB_MONITORING_DB_URL=https://api.monitoring-staging.envirodatagov.org 8 | 9 | # Redirect all HTTP requests to https (you probably want this off in development) 10 | # FORCE_SSL=true 11 | 12 | # Don't force the user to log in before trying to browse. (Defaults to false) 13 | # Make sure this is aligned with the the API you are connecting to in 14 | # `WEB_MONITORING_DB_URL`! The API does not allow public users and you set this 15 | # to `true`, you're gonna have a bad time: most requests will result in errors. 16 | # ALLOW_PUBLIC_VIEW='true' 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "brace-style": [ 13 | "error", 14 | "stroustrup", 15 | {"allowSingleLine": true} 16 | ], 17 | "indent": [ 18 | "error", 19 | 2, 20 | {"SwitchCase": 1} 21 | ], 22 | "linebreak-style": [ 23 | "error", 24 | "unix" 25 | ], 26 | "no-unused-vars": [ 27 | "error", 28 | // Allow functions to include the full signature of a protocol they adhere 29 | // to, even if they don’t use some args. 30 | {"args": "none"} 31 | ], 32 | "quotes": [ 33 | "error", 34 | "single", 35 | {"avoidEscape": true} 36 | ], 37 | "object-curly-spacing": [ 38 | "error", 39 | "always" 40 | ], 41 | "semi": [ 42 | "error", 43 | "always" 44 | ], 45 | "space-before-function-paren": [ 46 | "error", 47 | "always" 48 | ], 49 | "space-infix-ops": ["error"] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | _extends: .github 2 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add tasks to WM overview project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | - transferred 9 | pull_request: 10 | types: 11 | - opened 12 | - reopened 13 | 14 | jobs: 15 | add-to-project: 16 | name: Add to project 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/add-to-project@v1.0.2 20 | with: 21 | project-url: https://github.com/orgs/edgi-govdata-archiving/projects/32 22 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Build Output 15 | dist/bundle.js 16 | dist/**/*.css 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules 38 | jspm_packages 39 | 40 | # Optional npm cache directory 41 | .npm 42 | 43 | # Optional eslint cache 44 | .eslintcache 45 | 46 | # Optional REPL history 47 | .node_repl_history 48 | 49 | # Output of 'npm pack' 50 | *.tgz 51 | 52 | # Yarn Integrity file 53 | .yarn-integrity 54 | 55 | ### JetBrains template 56 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 57 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 58 | 59 | # User-specific stuff: 60 | .idea/workspace.xml 61 | .idea/tasks.xml 62 | 63 | # Sensitive or high-churn files: 64 | .idea/dataSources/ 65 | .idea/dataSources.ids 66 | .idea/dataSources.xml 67 | .idea/dataSources.local.xml 68 | .idea/sqlDataSources.xml 69 | .idea/dynamic.xml 70 | .idea/uiDesigner.xml 71 | 72 | # Gradle: 73 | .idea/gradle.xml 74 | .idea/libraries 75 | 76 | # Mongo Explorer plugin: 77 | .idea/mongoSettings.xml 78 | 79 | ## File-based project format: 80 | *.iws 81 | 82 | ## Plugin-specific files: 83 | 84 | # IntelliJ 85 | /out/ 86 | 87 | # mpeltonen/sbt-idea plugin 88 | .idea_modules/ 89 | 90 | # JIRA plugin 91 | atlassian-ide-plugin.xml 92 | 93 | # Crashlytics plugin (for Android Studio and IntelliJ) 94 | com_crashlytics_export_strings.xml 95 | crashlytics.properties 96 | crashlytics-build.properties 97 | fabric.properties 98 | 99 | .idea/ 100 | archives/.idea/ 101 | 102 | .env 103 | .vscode/ 104 | .DS_Store 105 | dist 106 | scratch* 107 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # Pre-commit git hook 2 | npm run lint 3 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.20.4 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | We love improvements to our tools! EDGI has general [guidelines for contributing][edgi-contributing] and a [code of conduct][edgi-conduct] for all of our organizational repos. 4 | 5 | ## Here are some notes specific to this project: 6 | 7 | ### Submitting Web Monitoring Issues 8 | 9 | Issues that are project-wide, or relate heavily to the interaction between different components, should be added to our [Web Monitoring issue queue](https://github.com/edgi-govdata-archiving/web-monitoring/issues). Component-specific issues should be added to their respective repository. 10 | 11 | 12 | ### Code Style / Best Practices 13 | 14 | The following are recommended code styling and best practices for the web-monitoring-ui repository. We also have best practices for related to all the [web-monitoring] project (https://github.com/edgi-govdata-archiving/web-monitoring/blob/main/CONTRIBUTING.md) repo. 15 | 16 | 17 | ### UI - Support 18 | 19 | #### Browser Support: 20 | 21 | Last two of every major browser (Chrome, Safari, Firefox, Edge) except IE 22 | 23 | 24 | #### ES6+ allowed everywhere: 25 | 26 | ES6 and above is allowed. A feature is allowed if it has been formally published or is a Stage 4 proposal. Otherwise, it is not allowed. 27 | 28 | 29 | ### UI - Code Style / Best Practices 30 | 31 | #### CSS Methodologies: 32 | 33 | This project uses [CSS Modules](https://github.com/css-modules/css-modules) with the majority of the classes scoped at the component level. There is a `global.css` file to put classes that should be applied sitewide. There is a `base.css` file consisting of non-global shared classes that are imported by multiple components. 34 | 35 | 36 | #### Organization of React files: 37 | 38 | 1. Constructor 39 | 2. Lifecycle methods 40 | 3. Public methods, usually passed as props to child components 41 | 4. Render 42 | 5. Additional render methods 43 | 6. Private 44 | 7. Utility functions outside of class definition -- these are useful only to the class so not worth pulling out into a file. They are not dependent on having a context. We neither use 'this' in the function, and calling 'this.utility' is wordy. 45 | 46 | 47 | #### Private Functions/Methods: 48 | 49 | In general, try to avoid “private” methods on objects or classes. If they are really needed, prefix their names with an underscore. 50 | 51 | Private functions in a module (that is, functions that are not exported) are fine. 52 | 53 | 54 | #### Spacing in code: 55 | 56 | Overall, we recommend [Stroustrup](https://en.wikipedia.org/wiki/Indentation_style#Variant:_Stroustrup) spacing for blocks: 57 | 58 | ```js 59 | if (foo) { 60 | bar(); 61 | } 62 | else { 63 | baz(); 64 | } 65 | ``` 66 | 67 | Separate the `if`/`for`/`while` keyword from the condition, but don’t add extra spaces inside the parentheses in conditionals and loops: 68 | 69 | ```js 70 | if (!this.state.pageId) { 71 | ``` 72 | 73 | Don’t add spaces between the function name and parentheses or within the parentheses when calling a function: 74 | 75 | ```js 76 | this.props.onChange(versions.find(v => v.uuid === newValue)); 77 | ``` 78 | 79 | Add spaces between the brackets and content in object literals: 80 | 81 | ```js 82 | this.setState({ updating: true }); 83 | ``` 84 | 85 | Add spaces between the brackets and and variable names when de-structuring: 86 | 87 | ```js 88 | const { page } = this.props; 89 | ``` 90 | 91 | Use spaces between the function keyword, name, and arguments in function declarations: 92 | 93 | ```js 94 | function foo (a) { 95 | ``` 96 | 97 | 98 | #### Units and layout - px, em, rem, vh, flexbox, grid: 99 | 100 | Pixels for borders. Rems for fonts and spacing. Flexbox and Grid for layout. 101 | 102 | 103 | 104 | [edgi-conduct]: https://github.com/edgi-govdata-archiving/overview/blob/main/CONDUCT.md 105 | [edgi-contributing]: https://github.com/edgi-govdata-archiving/overview/blob/main/CONTRIBUTING.md 106 | -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | Web-monitoring-ui is decoupled from the other [web monitoring](https://github.com/edgi-govdata-archiving/web-monitoring) components and interacts only with [web-monitoring-db](https://github.com/edgi-govdata-archiving/web-monitoring-db) through web-monitoring-db's JSON api. Anyone with a Heroku account and credentials to web-monitoring-db, can deploy web-monitoring-ui and have a fully functional instance of the application. You can still deploy without credentials but will not be able to update annotations or make other changes to web-monitoring-db. 4 | 5 | This deployment is simple and consists of: 6 | 7 | 1. A git clone of this repository 8 | 2. A free [Heroku](www.heroku.com) account and the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) setup 9 | 3. The script found at `/scripts/heroku-deploy.sh` 10 | 11 | ## Environment Variables 12 | 13 | In our deployment, environment variables are named: 14 | 15 | 1. `WEB_MONITORING_DB_URL` 16 | 2. `FORCE_SSL` 17 | 18 | ## Shell script 19 | 20 | The script takes 2 arguments that are optional. 21 | 22 | 1. The name of the heroku remote to deploy to. This is useful if you have multiple heroku remotes or have renamed them. It defaults to `heroku`. 23 | 2. The name of the local branch to be deployed. It defaults to `main`. Supplying this argument is useful if you want to deploy a branch other than main. 24 | 25 | The script assumes and pushes from a branch named `heroku-deploy`. It will create one or switch to it if it exists. You can't have a branch named `heroku-deploy` that you are working in if you plan on using the script. 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### Baseline image for development/test/build ### 2 | # We require a lot of extras for building (Python, GCC) because of Node-Zopfli. 3 | FROM node:18.20.4 as dev 4 | LABEL maintainer="enviroDGI@gmail.com" 5 | 6 | RUN mkdir -p /app 7 | WORKDIR /app 8 | 9 | # Copy dependencies only so they can be cached. 10 | COPY package.json package-lock.json ./ 11 | 12 | # Install deps. 13 | RUN npm ci 14 | 15 | # Finally, pull in the source. 16 | # TODO: can we mount so this can be used for live-reload dev? 17 | COPY . . 18 | 19 | CMD ["/bin/bash"] 20 | 21 | 22 | ### Build a production version of the app ### 23 | # Note this *creates* production artifacts. The docker image created here 24 | # should never actually be distributed; it's just an intermediate. 25 | FROM dev as build 26 | ENV NODE_ENV=production 27 | RUN npm run build-production 28 | 29 | 30 | ### Release Image ### 31 | # It might feel ridiculous to build up all the same things again, but the 32 | # resulting image is less than half the size! 33 | FROM node:18.20.4-slim as release 34 | LABEL maintainer="enviroDGI@gmail.com" 35 | 36 | RUN apt-get update && apt-get install -y --no-install-recommends dumb-init 37 | 38 | ENV NODE_ENV=production 39 | 40 | RUN mkdir -p /app 41 | WORKDIR /app 42 | 43 | # Copy dependencies only so they can be cached. 44 | COPY package.json package-lock.json ./ 45 | EXPOSE 3001 46 | 47 | # Install deps. 48 | RUN npm ci --only=production 49 | 50 | # Now copy all source. 51 | COPY . . 52 | COPY --from=build /app/dist ./dist 53 | 54 | # Run server. Use dumb-init because Node does not handle Docker's stop signal. 55 | ENTRYPOINT ["dumb-init", "--"] 56 | CMD ["npm", "run", "start"] 57 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server/app.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code of Conduct](https://img.shields.io/badge/%E2%9D%A4-code%20of%20conduct-blue.svg?style=flat)](https://github.com/edgi-govdata-archiving/overview/blob/main/CONDUCT.md)  [![Project Status Board](https://img.shields.io/badge/✔-Project%20Status%20Board-green.svg?style=flat)](https://github.com/orgs/edgi-govdata-archiving/projects/32) 2 | 3 | 4 | # web-monitoring-ui 5 | 6 | This repository is part of the EDGI [Web Monitoring Project](https://github.com/edgi-govdata-archiving/web-monitoring). 7 | 8 | This component works with [web-monitoring-db](https://github.com/edgi-govdata-archiving/web-monitoring-db) and [web-monitoring-processing](https://github.com/edgi-govdata-archiving/web-monitoring-processing) to support the next web monitoring workflow. 9 | 10 | It’s a React.js-based browser application with a Node.js backend with the following capabilities: 11 | * Consume subset of data from web-monitoring-db as proof of concept, read/write annotations 12 | * [DEMO](https://monitoring-staging.envirodatagov.org) 13 | * LIST VIEW shows first page of records from [web-monitor-db](https://api.monitoring-staging.envirdatagov.org/api/v0/pages) JSON endpoint 14 | * PAGE VIEW shows basic info about the latest version of that page: site, URLs and links to Wayback Machine calendar view and page versions 15 | * updates annotations 16 | 17 | 18 | ## Installation 19 | 20 | 1. Install Node 18.20.4. 21 | - We recommend using [Nodenv][nodenv], which will automatically select the correct version of Node.js to run for you. 22 | - If you don’t yet have the right version of Node.js installed, enter the root directory for this project and then run `nodenv install`. 23 | - Alternatively, you can use [NVM][nvm] or a variety of [alternatives][nodenv-alternatives] for managing multiple versions of Node.js. 24 | 25 | 2. Install node dependencies with `npm` 26 | 27 | ```sh 28 | npm install 29 | ``` 30 | 31 | 3. Copy `.env.example` to `.env` and supply any local configuration info you need (all fields are optional) 32 | 33 | 4. Start the web server! 34 | 35 | ```sh 36 | npm start 37 | ``` 38 | 39 | …and point your browser to http://localhost:3001 to view the app. If you haven't changed `WEB_MONITORING_DB_URL` in your `.env` file (step 3), you can log in with the public user credentials: 40 | 41 | - Username: `public.access@envirodatagov.org` 42 | - Password: `PUBLIC_ACCESS` 43 | 44 | [nodenv]: https://github.com/nodenv/nodenv 45 | [nodenv-alternatives]: https://github.com/nodenv/nodenv/wiki/Alternatives 46 | [nvm]: https://github.com/creationix/nvm 47 | 48 | 49 | ## Running tests 50 | 51 | To run all tests once 52 | 53 | ```sh 54 | npm test 55 | ``` 56 | 57 | while to start the test runner in watch mode 58 | 59 | ```sh 60 | npm run test-watch 61 | ``` 62 | 63 | 64 | ## Manual view 65 | Access the main view at `http://localhost:3001` 66 | 67 | Screenshot: 68 | ![screenshot](screenshot.png) 69 | 70 | ## Code of Conduct 71 | 72 | This repository falls under EDGI's [Code of Conduct](https://github.com/edgi-govdata-archiving/overview/blob/main/CONDUCT.md). 73 | 74 | 75 | ## Getting Involved 76 | 77 | We need your help! Please read through the [Web Monitoring Project](https://github.com/edgi-govdata-archiving/web-monitoring) project document and see what you can help with and check [EDGI’s contribution guidelines](https://github.com/edgi-govdata-archiving/overview/blob/main/CONTRIBUTING.md) for information on how to propose issues or changes. 78 | 79 | 80 | ## Docker 81 | 82 | You can also run this project via Docker. To build and run (on port 3001, as in the instructions for running directly above): 83 | 84 | ``` 85 | docker build -t envirodgi/ui . 86 | docker run -p 3001:3001 -e envirodgi/ui 87 | ``` 88 | 89 | Point your browser to ``http://localhost:3001``. 90 | 91 | To run tests via Docker: 92 | 93 | ``` 94 | docker build -t envirodgi/ui:dev --target dev . 95 | docker run envirodgi/ui:dev npm run test 96 | ``` 97 | 98 | 99 | ## Releases 100 | 101 | New releases of the app are published automatically as Docker images by CircleCI when someone pushes to the `release` branch. They are availble at https://hub.docker.com/r/envirodgi/ui. See [web-monitoring-ops](https://github.com/edgi-govdata-archiving/web-monitoring-ops) for how we deploy releases to actual web servers. 102 | 103 | Images are tagged with the SHA-1 of the git commit they were built from. For example, the image `envirodgi/ui:6fa54911bede5b135e890391198fbba68cd20853` was built from [commit `3802e0392fb6fe398a93f355083ba51052e83102`](https://github.com/edgi-govdata-archiving/web-monitoring-ui/commit/3802e0392fb6fe398a93f355083ba51052e83102). 104 | 105 | We usually create *merge commits* on the `release` branch that note the PRs included in the release or any other relevant notes (e.g. [`Release #395`](https://github.com/edgi-govdata-archiving/web-monitoring-ui/commit/3802e0392fb6fe398a93f355083ba51052e83102)). 106 | 107 | 108 | ## Contributors 109 | 110 | This project wouldn’t exist without a lot of amazing people’s help. Thanks to the following for all their contributions! 111 | 112 | 113 | | Contributions | Name | 114 | | ----: | :---- | 115 | | [📖](# "Documentation") [📋](# "Organizer") [💬](# "Answering Questions") [👀](# "Reviewer") | [Dan Allan](https://github.com/danielballan) | 116 | | [💻](# "Code") | [Jatin Arora](https://github.com/jatinAroraGit) | 117 | | [💡](# "Examples") | [@allanpichardo](https://github.com/allanpichardo) | 118 | | [💡](# "Examples") | [@ArcTanSusan](https://github.com/ArcTanSusan) | 119 | | [💡](# "Examples") | [@AutumnColeman](https://github.com/AutumnColeman) | 120 | | [📋](# "Organizer") [🔍](# "Funding/Grant Finder") | [Andrew Bergman](https://github.com/ambergman) | 121 | | [💻](# "Code") [📖](# "Documentation") [💬](# "Answering Questions") [👀](# "Reviewer") | [Rob Brackett](https://github.com/Mr0grog) | 122 | | [📖](# "Documentation") | [Patrick Connolly](https://github.com/patcon) | 123 | | [📖](# "Documentation") | [Manaswini Das](https://github.com/manaswinidas) | 124 | | [💻](# "Code") [⚠️](# "Tests") | [Nick Echols](https://github.com/steryereo) | 125 | | [💻](# "Code") [⚠️](# "Tests") | [Katie Jones](https://github.com/katjone) | 126 | | [💡](# "Examples") | [@lh00000000](https://github.com/lh00000000) | 127 | | [💻](# "Code") [⚠️](# "Tests") | [Greg Merrill](https://github.com/g-merrill) | 128 | | [💻](# "Code") [🎨](# "Design") [📖](# "Documentation") [💬](# "Answering Questions") [👀](# "Reviewer") | [Kevin Nguyen](https://github.com/lightandluck) | 129 | | [💻](# "Code") [⚠️](# "Tests") | [Johnson Phan](https://github.com/johnsonphan95) | 130 | | [📖](# "Documentation") [📋](# "Organizer") [📢](# "Talks") | [Matt Price](https://github.com/titaniumbones) | 131 | | [📖](# "Documentation") | [@professionalzack](https://github.com/professionalzack) | 132 | | [📋](# "Organizer") [🔍](# "Funding/Grant Finder") | [Toly Rinberg](https://github.com/trinberg) | 133 | | [💻](# "Code") | [Ben Sheldon](https://github.com/bensheldon) | 134 | | [💡](# "Examples") | [@StephenAlanBuckley](https://github.com/StephenAlanBuckley) | 135 | | [💡](# "Examples") | [@stuartlynn](https://github.com/stuartlynn) | 136 | | [💻](# "Code") | [Michelle Truong](https://github.com/fendatr) | 137 | | [📖](# "Documentation") [📋](# "Organizer") | [Dawn Walker](https://github.com/dcwalk) | 138 | | [💻](# "Code") [📖](# "Documentation") [⚠️](# "Tests") [👀](# "Reviewer") | [Sarah Yu](https://github.com/SYU15) | 139 | | [💻](# "Code") [⚠️](# "Tests") | [Alberto Zaccagni](https://github.com/lazywithclass) | 140 | 141 | 142 | 143 | (For a key to the contribution emoji or more info on this format, check out [“All Contributors.”](https://github.com/kentcdodds/all-contributors)) 144 | 145 | 146 | ## License & Copyright 147 | 148 | Copyright (C) <2017> Environmental Data and Governance Initiative (EDGI) 149 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.0. 150 | 151 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 152 | 153 | See the [`LICENSE`](https://github.com/edgi-govdata-archiving/web-monitoring-ui/blob/main/LICENSE) file for details. 154 | -------------------------------------------------------------------------------- /dist/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edgi-govdata-archiving/web-monitoring-ui/1053ed3afcc40abb47c5ff688b94480a39106ece/dist/.keep -------------------------------------------------------------------------------- /docs/issue_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Expected Behavior 4 | 5 | 6 | 7 | ## Current Behavior 8 | 9 | 10 | 11 | ## Possible Solution 12 | 13 | 14 | 15 | ## Steps to Reproduce (for bugs) 16 | 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 22 | ## Context 23 | 24 | 25 | 26 | ## Your Environment 27 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-monitoring-ui", 3 | "version": "0.1.0", 4 | "description": "web-monitoring-ui", 5 | "main": "views/main.html", 6 | "dependencies": { 7 | "@googleapis/sheets": "^4.0.1", 8 | "babel-plugin-react-css-modules": "^5.2.6", 9 | "body-parser": "^1.20.1", 10 | "ejs": "^3.1.10", 11 | "express": "^4.21.1", 12 | "normalize.css": "^8.0.1", 13 | "request": "^2.88.0" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.27.4", 17 | "@babel/preset-env": "^7.27.2", 18 | "@babel/preset-react": "^7.27.1", 19 | "@babel/runtime": "^7.27.6", 20 | "@gfx/zopfli": "^1.0.15", 21 | "autoprefixer": "^10.4.13", 22 | "babel-core": "^7.0.0-bridge.0", 23 | "babel-loader": "^9.2.1", 24 | "bundlewatch": "^0.4.1", 25 | "compression-webpack-plugin": "^10.0.0", 26 | "css-loader": "^3.6.0", 27 | "dotenv": "^16.0.3", 28 | "enzyme": "^3.11.0", 29 | "enzyme-adapter-react-16": "^1.15.7", 30 | "eslint": "^8.33.0", 31 | "eslint-plugin-react": "^7.32.2", 32 | "extract-loader": "^5.1.0", 33 | "fetch-mock": "^9.11.0", 34 | "file-loader": "^6.2.0", 35 | "husky": "^8.0.3", 36 | "jest": "^29.4.1", 37 | "jest-environment-jsdom": "^29.4.1", 38 | "moment": "^2.29.4", 39 | "moment-locales-webpack-plugin": "^1.2.0", 40 | "node-fetch": "^3.3.0", 41 | "postcss": "^8.4.31", 42 | "postcss-loader": "^7.2.4", 43 | "prop-types": "^15.8.1", 44 | "react": "^16.14.0", 45 | "react-aria-modal": "^4.0.2", 46 | "react-dates": "^21.8.0", 47 | "react-dom": "^16.14.0", 48 | "react-router-dom": "^5.3.0", 49 | "react-test-renderer": "^16.14.0", 50 | "react-tooltip": "^4.5.1", 51 | "react-with-direction": "^1.4.0", 52 | "style-loader": "^3.3.1", 53 | "webpack": "^5.94.0", 54 | "webpack-bundle-analyzer": "^4.7.0", 55 | "webpack-cli": "^5.0.1", 56 | "webpack-dev-middleware": "^6.1.2" 57 | }, 58 | "scripts": { 59 | "analyze": "npm run build-production -- --analyze", 60 | "build": "webpack", 61 | "build-production": "NODE_ENV=production webpack", 62 | "bundlewatch": "bundlewatch --config .bundlewatch.config.js", 63 | "lint": "eslint --ignore-path .gitignore './**/*.{js,jsx}'", 64 | "prepare": "if [ -d .git ]; then husky install; fi", 65 | "start": "node server/app.js", 66 | "test": "jest --colors --verbose", 67 | "test-watch": "jest --watch" 68 | }, 69 | "jest": { 70 | "moduleNameMapper": { 71 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/__mocks__/file-mock.js", 72 | "\\.css$": "/src/__mocks__/identity-object.js" 73 | }, 74 | "setupFilesAfterEnv": [ 75 | "/src/test-setup.js" 76 | ], 77 | "testEnvironment": "jsdom", 78 | "watchPathIgnorePatterns": [ 79 | "/node_modules/", 80 | "/\\..+/" 81 | ] 82 | }, 83 | "author": "", 84 | "license": "GPL-3.0", 85 | "engines": { 86 | "node": "18.20.4" 87 | }, 88 | "browserslist": [ 89 | "last 3 versions", 90 | "not < 1%", 91 | "not ie < 1000", 92 | "not op_mini all", 93 | "not android < 1000", 94 | "not dead" 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edgi-govdata-archiving/web-monitoring-ui/1053ed3afcc40abb47c5ff688b94480a39106ece/screenshot.png -------------------------------------------------------------------------------- /scripts/heroku-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # @param {string} remote [heroku] - heroku remote name 4 | # @param {string} deployFrom [main] - local branch to deploy 5 | 6 | # check and set defaults 7 | remote=${1:-heroku} 8 | deployFrom=${2:-main} 9 | deployTo="heroku-deploy" 10 | currentBranch=`git rev-parse --abbrev-ref HEAD` 11 | 12 | hasChanges=`git diff-index HEAD --` 13 | if [ -n "$hasChanges" ]; then 14 | git stash 15 | fi 16 | 17 | # create new branch if necessary 18 | exists=`git show-ref refs/heads/${deployTo}` 19 | if [ -n "$exists" ]; then 20 | git checkout ${deployTo} 21 | git reset --hard ${deployFrom} 22 | else 23 | git checkout -b ${deployTo} ${deployFrom} 24 | fi 25 | 26 | npm run build-production 27 | git add -f dist/bundle.* dist/css/* dist/img/* dist/sourceMaps/* 28 | git commit -m "Deploy heroku app" 29 | git push -f ${remote} ${deployTo}:main 30 | git checkout ${currentBranch} 31 | 32 | if [ -n "$hasChanges" ]; then 33 | git stash apply 34 | fi 35 | 36 | echo "Finished deployment" 37 | -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "rules": { 7 | "no-console": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const bodyParser = require('body-parser'); 5 | const makeRequest = require('request'); 6 | const app = express(); 7 | const path = require('path'); 8 | const sheetData = require('./sheet-data'); 9 | const config = require('./configuration'); 10 | 11 | const serverPort = process.env.PORT || 3001; 12 | 13 | /** 14 | * Create a generic error handler for a response. This (or something similar) 15 | * should always conclude a promise chain in an HTTP request handler. If you 16 | * have an error you expect, however (e.g. some kind of specialized "not found" 17 | * error), you should handle it directly so you can send a useful message. 18 | * 19 | * @param {Express.Response} response 20 | */ 21 | function createErrorHandler (response) { 22 | return error => { 23 | let errorData = error; 24 | if (error instanceof Error) { 25 | if (config.baseConfiguration().NODE_ENV !== 'production') { 26 | errorData = { error: error.message, stack: error.stack }; 27 | } 28 | else { 29 | errorData = { error: 'An unknown error ocurred.' }; 30 | } 31 | } 32 | response.status(error.status || 500).json(errorData); 33 | }; 34 | } 35 | 36 | // If FORCE_SSL is true, redirect any non-SSL requests to https. 37 | if (process.env.FORCE_SSL && process.env.FORCE_SSL.toLowerCase() === 'true') { 38 | // The /healthcheck route is exempted, to allow liveness/readiness probes 39 | // to make requests internal to a deployment (inside SSL termination). 40 | const exemptPaths = /^\/healthcheck\/?$/; 41 | 42 | app.use((request, response, next) => { 43 | if (request.secure || request.headers['x-forwarded-proto'] === 'https' || exemptPaths.test(request.path)) { 44 | return next(); 45 | } 46 | response.redirect( 47 | 301, 48 | `https://${request.headers.host}${request.originalUrl}` 49 | ); 50 | }); 51 | } 52 | 53 | // Serve assets (live from Webpack in dev mode) 54 | if (config.baseConfiguration().NODE_ENV === 'development') { 55 | const webpack = require('webpack'); 56 | const webpackDevMiddleware = require('webpack-dev-middleware'); 57 | const webpackConfig = require('../webpack.config.js'); 58 | app.use(webpackDevMiddleware(webpack(webpackConfig))); 59 | } 60 | else { 61 | app.use(express.static('dist', { 62 | setHeaders (response, filePath, stat) { 63 | if (filePath.endsWith('.gz')) { 64 | response.set('Content-Encoding', 'gzip'); 65 | 66 | const preExtension = (filePath.match(/\.([^/]+)\.gz$/i) || ['', ''])[1]; 67 | const contentType = { 68 | js: 'application/javascript', 69 | css: 'text/css', 70 | svg: 'image/svg+xml' 71 | }[preExtension]; 72 | 73 | if (contentType) { 74 | response.set('Content-Type', contentType); 75 | } 76 | } 77 | } 78 | })); 79 | } 80 | 81 | app.set('views', path.join(__dirname, '../views')); 82 | app.engine('html', require('ejs').renderFile); 83 | app.use(bodyParser.json()); 84 | 85 | app.get('/healthcheck', function (request, response) { 86 | response.json({}); 87 | }); 88 | 89 | function validateChangeBody (request, response, next) { 90 | const valid = request.body 91 | && request.body.page 92 | && request.body.from_version 93 | && request.body.to_version 94 | && request.body.annotation 95 | && request.body.user; 96 | if (!valid) { 97 | return response.status(400).json({ 98 | error: 'You must POST a JSON object with: {page: Object, from_version: Object, to_version: Object, annotation: Object, user: String}' 99 | }); 100 | } 101 | next(); 102 | } 103 | 104 | function authorizeRequest (request, response, next) { 105 | if (!request.headers.authorization) { 106 | return response.status(401).json({ error: 'You must include authorization headers' }); 107 | } 108 | 109 | let host = config.baseConfiguration().WEB_MONITORING_DB_URL; 110 | if (!host.endsWith('/')) { 111 | host += '/'; 112 | } 113 | 114 | makeRequest({ 115 | url: `${host}users/session`, 116 | headers: { Authorization: request.headers.authorization }, 117 | callback (error, authResponse, body) { 118 | if (error) { 119 | console.error(error); 120 | return response.status(500).json({ error: 'Authentication Error' }); 121 | } 122 | else if (authResponse.statusCode !== 200) { 123 | return response.status(authResponse.statusCode).end(body); 124 | } 125 | next(); 126 | } 127 | }); 128 | } 129 | 130 | app.post( 131 | '/api/importantchange', 132 | authorizeRequest, 133 | validateChangeBody, 134 | function (request, response) { 135 | sheetData.addChangeToImportant(request.body) 136 | .then(data => response.json(data)) 137 | .catch(createErrorHandler(response)); 138 | } 139 | ); 140 | 141 | app.post( 142 | '/api/dictionary', 143 | authorizeRequest, 144 | validateChangeBody, 145 | function (request, response) { 146 | sheetData.addChangeToDictionary(request.body) 147 | .then(data => response.json(data)) 148 | .catch(createErrorHandler(response)); 149 | } 150 | ); 151 | 152 | /** 153 | * Main view for manual entry 154 | */ 155 | app.get('*', function (request, response) { 156 | const useGzip = config.baseConfiguration().NODE_ENV === 'production' 157 | && request.acceptsEncodings('gzip'); 158 | 159 | response.render('main.html', { 160 | configuration: config.clientConfiguration(), 161 | useGzip 162 | }); 163 | }); 164 | 165 | app.listen(serverPort, function () { 166 | console.log(`Listening on port ${serverPort}`); 167 | }); 168 | -------------------------------------------------------------------------------- /server/configuration.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const defaultValues = { 4 | WEB_MONITORING_DB_URL: 'https://api.monitoring-staging.envirodatagov.org', 5 | ALLOW_PUBLIC_VIEW: false 6 | }; 7 | 8 | const clientFields = [ 9 | 'WEB_MONITORING_DB_URL', 10 | 'ALLOW_PUBLIC_VIEW' 11 | ]; 12 | 13 | const processEnvironment = Object.assign( 14 | {}, 15 | process.env, 16 | { NODE_ENV: (process.env.NODE_ENV || 'development').toLowerCase() } 17 | ); 18 | 19 | function parseBoolean (text, options = { default: false }) { 20 | if (text == null || text === '') return options.default; 21 | 22 | if (typeof text === 'string') { 23 | return /^t|true|1$/.test(text.trim().toLowerCase()); 24 | } 25 | else { 26 | return !!text; 27 | } 28 | } 29 | 30 | /** 31 | * Get the current configuration for the app. This consists of the process's 32 | * environment, then falls back to a local `.env` file (not used in production), 33 | * then some built-in defaults. When not in production, the local `.env` file 34 | * will be re-scanned on every call, so you get live-updated configuration. 35 | * 36 | * Note this configuration object is *not* suitable for sending to client code; 37 | * it may contain keys that must be kept secure. 38 | * @returns {Object} 39 | */ 40 | function baseConfiguration () { 41 | let localEnvironment = processEnvironment; 42 | 43 | if (processEnvironment.NODE_ENV !== 'production') { 44 | localEnvironment.NODE_ENV = 'development'; 45 | 46 | // dotenv.config() updates process.env, but only with properties it doesn't 47 | // already have. That means it won't update properties that were previously 48 | // specified, so we have to do it manually here. 49 | const fromFile = require('dotenv').config(); 50 | // If there is no .env file, don't throw and just use process.env 51 | if (fromFile.error && fromFile.error.code !== 'ENOENT') { 52 | throw fromFile.error; 53 | } 54 | else if (fromFile.parsed) { 55 | localEnvironment = Object.assign(fromFile.parsed, localEnvironment); 56 | } 57 | } 58 | 59 | // Special parsing 60 | localEnvironment.ALLOW_PUBLIC_VIEW = parseBoolean( 61 | localEnvironment.ALLOW_PUBLIC_VIEW, 62 | { default: null } 63 | ); 64 | if (localEnvironment.ALLOW_PUBLIC_VIEW == null) { 65 | delete localEnvironment.ALLOW_PUBLIC_VIEW; 66 | } 67 | 68 | return Object.assign({}, defaultValues, localEnvironment); 69 | } 70 | 71 | /** 72 | * Get a configuration object that is safe to pass to client code. 73 | * @returns {Object} 74 | */ 75 | function clientConfiguration () { 76 | const source = baseConfiguration(); 77 | 78 | return clientFields.reduce((result, field) => { 79 | result[field] = source[field]; 80 | return result; 81 | }, {}); 82 | } 83 | 84 | exports.baseConfiguration = baseConfiguration; 85 | exports.clientConfiguration = clientConfiguration; 86 | -------------------------------------------------------------------------------- /server/sheet-data.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const googleSheets = require('@googleapis/sheets'); 4 | const config = require('./configuration'); 5 | const sheets = googleSheets.sheets('v4'); 6 | const formatters = require('../src/scripts/formatters'); 7 | 8 | function addChangeToDictionary (data) { 9 | const versionista = data.to_version.source_type === 'versionista' 10 | && data.to_version.source_metadata; 11 | 12 | const row = [ 13 | // Index 14 | '', 15 | // UUID 16 | `${data.from_version.uuid}..${data.to_version.uuid}`, 17 | // Output Date/Time 18 | formatDate(), 19 | // Maintainers 20 | formatters.formatMaintainers(data.page.maintainers), 21 | // Sites 22 | formatters.formatSites(data.page.tags), 23 | // Page Name 24 | data.page.title, 25 | // URL 26 | data.page.url, 27 | // Page View URL 28 | `https://monitoring.envirodatagov.org/page/${data.page.uuid}`, 29 | // Last Two - Side by Side 30 | `https://monitoring.envirodatagov.org/page/${data.page.uuid}/..${data.to_version.uuid}`, 31 | // Latest to Base - Side by Side 32 | `https://monitoring.envirodatagov.org/page/${data.page.uuid}/^..${data.to_version.uuid}`, 33 | // Date Found - Latest 34 | formatDate(data.from_version.capture_time), 35 | // Date Found - Base 36 | '', // we don't have this information 37 | // Diff Hash 38 | versionista ? versionista.diff_hash : '', 39 | // Diff Length 40 | versionista ? versionista.diff_length : '', 41 | // Who Found This? 42 | data.user, 43 | // Classification 44 | '', 45 | // Description 46 | data.annotation && data.annotation.notes || '' 47 | ]; 48 | 49 | return appendRowToSheet( 50 | row, 51 | config.baseConfiguration().GOOGLE_DICTIONARY_SHEET_ID 52 | ) 53 | .then(() => ({ success: 'appended' })); 54 | } 55 | 56 | function addChangeToImportant (data) { 57 | const versionista = data.to_version.source_type === 'versionista' 58 | && data.to_version.source_metadata; 59 | const annotation = data.annotation || {}; 60 | 61 | const row = [ 62 | // Checked 63 | '', 64 | // Index 65 | '', 66 | // Unique ID 67 | `${data.from_version.uuid}..${data.to_version.uuid}`, 68 | // Output Date/Time 69 | formatDate(), 70 | // Maintainers 71 | formatters.formatMaintainers(data.page.maintainers), 72 | // Sites 73 | formatters.formatSites(data.page.tags), 74 | // Page Name 75 | data.page.title, 76 | // URL 77 | data.page.url, 78 | // Page View URL 79 | `https://monitoring.envirodatagov.org/page/${data.page.uuid}`, 80 | // Last Two - Side by Side 81 | `https://monitoring.envirodatagov.org/page/${data.page.uuid}/..${data.to_version.uuid}`, 82 | // Latest to Base - Side by Side 83 | `https://monitoring.envirodatagov.org/page/${data.page.uuid}/^..${data.to_version.uuid}`, 84 | // Date Found - Latest 85 | formatDate(data.from_version.capture_time), 86 | // Date Found - Base 87 | '', // we don't have this information 88 | // Diff Length 89 | versionista ? versionista.diff_length : '', 90 | // Diff Hash 91 | versionista ? versionista.diff_hash : '', 92 | // Text diff length 93 | versionista ? versionista.diff_text_length : '', 94 | // Text diff hash 95 | versionista ? versionista.diff_text_hash : '', 96 | // Who Found This? 97 | data.user, 98 | // 1 99 | annotation.indiv_1 ? 'y' : '', 100 | // 2 101 | annotation.indiv_2 ? 'y' : '', 102 | // 3 103 | annotation.indiv_3 ? 'y' : '', 104 | // 4 105 | annotation.indiv_4 ? 'y' : '', 106 | // 5 107 | annotation.indiv_5 ? 'y' : '', 108 | // 6 109 | annotation.indiv_6 ? 'y' : '', 110 | // 7 111 | annotation.repeat_7 ? 'y' : '', 112 | // 8 113 | annotation.repeat_8 ? 'y' : '', 114 | // 9 115 | annotation.repeat_9 ? 'y' : '', 116 | // 10 117 | annotation.repeat_10 ? 'y' : '', 118 | // 11 119 | annotation.repeat_11 ? 'y' : '', 120 | // 12 121 | annotation.repeat_12 ? 'y' : '', 122 | // 1 123 | annotation.sig_1 ? 'y' : '', 124 | // 2 125 | annotation.sig_2 ? 'y' : '', 126 | // 3 127 | annotation.sig_3 ? 'y' : '', 128 | // 4 129 | annotation.sig_4 ? 'y' : '', 130 | // 5 131 | annotation.sig_5 ? 'y' : '', 132 | // 6 133 | annotation.sig_6 ? 'y' : '', 134 | // Choose from drop down menu (2 columns) 135 | '', '', 136 | // Leave blank (used on Patterns sheet) 137 | '', 138 | // Further Notes 139 | annotation.notes || '' 140 | ]; 141 | 142 | return appendRowToSheet( 143 | row, 144 | config.baseConfiguration().GOOGLE_IMPORTANT_CHANGE_SHEET_ID, 145 | 'A6:AN6' 146 | ) 147 | .then(() => ({ success: 'appended' })); 148 | } 149 | 150 | /** 151 | * Append a row to a google sheet 152 | * 153 | * @param {string[]} values Column values of row to append 154 | * @param {string} spreadsheedId ID of Google sheet to append to 155 | * @param {string} [range] Range identifying the data to append to in sheet, 156 | * e.g. `A3:Z3`. This is NOT the range after which to append -- Google sheets 157 | * identifies a continuous series of rows that intersect with this range and 158 | * appends *after those rows.* 159 | * @returns {Promise} Response data from Google Sheets 160 | */ 161 | function appendRowToSheet (values, spreadsheetId, range = 'A3:ZZZ') { 162 | return addAuthentication({ 163 | spreadsheetId, 164 | // supply a cell where data exists, Google decides for itself where the data table ends and appends, using extreme range again to grab everything 165 | range, 166 | resource: { 167 | values: [values] 168 | }, 169 | valueInputOption: 'RAW', 170 | insertDataOption: 'INSERT_ROWS' 171 | }) 172 | .then(args => sheets.spreadsheets.values.append(args)); 173 | } 174 | 175 | 176 | // ------------------- PRIVATE UTILITIES ----------------------- 177 | 178 | let authTokens; 179 | let authClient; 180 | 181 | /** 182 | * Add authentication information to a request object for Google API calls. 183 | * If there is an authenticated client on hand, it will be used. Otherwise, 184 | * a new authenticated client will be created and logged in before returning 185 | * the object with authentication information. 186 | * @private 187 | * @param {Object} requestData 188 | * @returns {Promise} requestData modified with auth client 189 | */ 190 | function addAuthentication (requestData) { 191 | return new Promise((resolve, reject) => { 192 | // If tokens expire in 30 seconds or less, refresh them. 193 | const expirationLeeway = 30000; 194 | if (!authTokens || Date.now() > authTokens.expiry_date - expirationLeeway) { 195 | const configuration = config.baseConfiguration(); 196 | const clientEmail = configuration.GOOGLE_SERVICE_CLIENT_EMAIL; 197 | // Replace `\n` in ENV variable with actual line breaks. 198 | const privateKey = configuration.GOOGLE_SHEETS_PRIVATE_KEY.replace(/\\n/g, '\n'); 199 | 200 | authClient = new googleSheets.auth.JWT( 201 | clientEmail, 202 | null, 203 | privateKey, 204 | ['https://www.googleapis.com/auth/spreadsheets']); 205 | 206 | authClient.authorize((error, tokens) => { 207 | if (error) return reject(error); 208 | authTokens = tokens; 209 | resolve(Object.assign({ auth: authClient }, requestData)); 210 | }); 211 | } 212 | else { 213 | resolve(Object.assign({ auth: authClient }, requestData)); 214 | } 215 | }); 216 | } 217 | 218 | function formatDate (date) { 219 | if (!date) { 220 | date = new Date(); 221 | } 222 | else if (typeof date === 'string') { 223 | date = new Date(date); 224 | } 225 | 226 | return date 227 | .toISOString() 228 | .replace('T', ' ') 229 | .replace(/(\d\d)\.\d+/, '$1') 230 | .replace('Z', ' GMT'); 231 | } 232 | 233 | exports.addChangeToDictionary = addChangeToDictionary; 234 | exports.addChangeToImportant = addChangeToImportant; 235 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "react" 4 | ], 5 | "parserOptions": { 6 | "ecmaVersion": 2020, 7 | "ecmaFeatures": { 8 | "jsx": true 9 | } 10 | }, 11 | "env": { 12 | "browser": true, 13 | "node": false 14 | }, 15 | // TODO: Consider using recommended React rules once we've got the basics down 16 | // "extends": ["plugin:react/recommended"], 17 | "rules": { 18 | // Core JSX requirements 19 | "react/jsx-uses-react": "error", 20 | "react/jsx-uses-vars": "error" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/__mocks__/file-mock.js: -------------------------------------------------------------------------------- 1 | // Webpack builds our assets into exported paths, but in testing, we need to 2 | // mock that behavior (because Webpack is not there). This module stands in for 3 | // any static file that Webpack would otherwise build. 4 | export default 'test-file-fake-path'; 5 | -------------------------------------------------------------------------------- /src/__mocks__/identity-object.js: -------------------------------------------------------------------------------- 1 | /** 2 | * An object where any key's value is the name of the key. 3 | * @example 4 | * identityObject.a === 'a'; 5 | * identityObject.b === 'b'; 6 | */ 7 | export default new Proxy({}, { 8 | get (target, key) { 9 | // Support Babel/Webpack CommonJS compilation, which uses __esModule. 10 | if (key === '__esModule') { 11 | return false; 12 | } 13 | return key; 14 | } 15 | }); -------------------------------------------------------------------------------- /src/__mocks__/simple-page.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "9420d91c-2fd8-411a-a756-5bf976574d10", 3 | "url": "http://www.ncei.noaa.gov/news/earth-science-conference-convenes", 4 | "title": "Earth Science Conference Convenes | National Centers for Environmental Information (NCEI)", 5 | "active": true, 6 | "created_at": "2017-05-02T23:55:57.021Z", 7 | "updated_at": "2018-02-01T05:20:52.203Z", 8 | "versions": [ 9 | { 10 | "uuid": "ed4aed23-424e-4326-ac75-c3ad72797bad", 11 | "page_uuid": "9420d91c-2fd8-411a-a756-5bf976574d10", 12 | "url": "http://www.ncei.noaa.gov/news/earth-science-conference-convenes", 13 | "capture_time": "2018-02-01T04:37:15.000Z", 14 | "body_url": "https://edgi-wm-versionista.s3.amazonaws.com/versionista2/74303-6222064/version-14470996.html", 15 | "body_hash": "15e3e7f86e3f72acd360668b22250b9981f7b3d5f777b3d972557f00cd21153f", 16 | "media_type": "text/html", 17 | "media_type_parameters": "charset=UTF-8", 18 | "content_length": 31190, 19 | "headers": { 20 | "age": "0", 21 | "date": "Thu, 01 Feb 2018 05:18:47 GMT", 22 | "vary": "Accept-Encoding", 23 | "expires": "Thu, 01 Feb 2018 05:18:47 GMT", 24 | "x-cachee": "MISS", 25 | "connection": "close", 26 | "content-type": "text/html; charset=UTF-8", 27 | "accept-ranges": "bytes", 28 | "cache-control": "private, max-age=0", 29 | "transfer-encoding": "chunked" 30 | }, 31 | "source_type": "versionista", 32 | "source_metadata": { 33 | "url": "https://versionista.com/74303/6222064/14470996/", 34 | "length": 31190, 35 | "account": "versionista2", 36 | "page_id": "6222064", 37 | "site_id": "74303", 38 | "diff_hash": "28479a7929b2f7f59a8d5837286fd2ecf1a7e6fb267c762dbc0e2bcff352dc1d", 39 | "version_id": "14470996", 40 | "diff_length": 20614, 41 | "has_content": true, 42 | "content_type": "text/html; charset=UTF-8", 43 | "diff_text_hash": "bcf5c1b9236c7fa047d6cd709b66302819a1e5ca4e12b4e67e89dcf367fc9e9e", 44 | "diff_text_length": 462, 45 | "diff_with_first_url": "https://versionista.com/74303/6222064/14470996:9437401/", 46 | "diff_with_previous_url": "https://versionista.com/74303/6222064/14470996:14458658/" 47 | }, 48 | "created_at": "2018-02-01T05:20:52.198Z", 49 | "updated_at": "2018-02-01T05:20:52.198Z", 50 | "title": "Earth Science Conference Convenes | National Centers for Environmental Information (NCEI)" 51 | }, 52 | { 53 | "uuid": "70aa8b7b-d485-48dc-8295-7635ff04dc5b", 54 | "page_uuid": "9420d91c-2fd8-411a-a756-5bf976574d10", 55 | "url": "http://www.ncei.noaa.gov/news/earth-science-conference-convenes", 56 | "capture_time": "2017-01-17T13:46:48.000Z", 57 | "body_url": "https://edgi-wm-versionista.s3.amazonaws.com/versionista2/74303-6222064/version-9437401.html", 58 | "body_hash": "03942355429ebd4bf526199777879fdf069a23ec040a9517b556df8aa4d1a8d3", 59 | "media_type": null, 60 | "media_type_parameters": null, 61 | "content_length": 31190, 62 | "source_type": "versionista", 63 | "source_metadata": { 64 | "url": "https://versionista.com/74303/6222064/9437401/", 65 | "account": "versionista2", 66 | "page_id": "6222064", 67 | "site_id": "74303", 68 | "version_id": "9437401", 69 | "has_content": true 70 | }, 71 | "created_at": "2017-05-02T23:55:57.033Z", 72 | "updated_at": "2017-05-02T23:55:57.033Z", 73 | "title": "Earth Science Conference Convenes | National Centers for Environmental Information (NCEI)" 74 | }, 75 | { 76 | "uuid": "70aa8b7b-d485-48dc-8295-7635ff04dc5c", 77 | "page_uuid": "9420d91c-2fd8-411a-a756-5bf976574d10", 78 | "url": "http://www.ncei.noaa.gov/news/earth-science-conference-convenes", 79 | "capture_time": "2017-01-17T10:00:00.000Z", 80 | "body_url": "https://edgi-wm-versionista.s3.amazonaws.com/versionista2/74303-6222064/version-9437401.html", 81 | "body_hash": "03942355429ebd4bf526199777879fdf069a23ec040a9517b556df8aa4d1a8d3", 82 | "media_type": null, 83 | "media_type_parameters": null, 84 | "content_length": 31190, 85 | "source_type": "versionista", 86 | "source_metadata": { 87 | "url": "https://versionista.com/74303/6222064/9437400/", 88 | "account": "versionista2", 89 | "page_id": "6222064", 90 | "site_id": "74303", 91 | "version_id": "9437400", 92 | "has_content": true 93 | }, 94 | "created_at": "2017-05-02T23:55:57.033Z", 95 | "updated_at": "2017-05-02T23:55:57.033Z", 96 | "title": "Earth Science Conference Convenes | National Centers for Environmental Information (NCEI)" 97 | } 98 | ], 99 | "maintainers": [ 100 | { 101 | "uuid": "c73c89bc-6be8-4267-a3c7-c17a05c2d113", 102 | "name": "NOAA", 103 | "assigned_at": "2018-02-19T23:10:17.841Z", 104 | "parent_uuid": null 105 | }, 106 | { 107 | "uuid": "3kbc89bc-6be8-42b7-a337-cln305c2dte3", 108 | "name": "Unicorn Department", 109 | "assigned_at": "2018-01-19T23:10:17.841Z", 110 | "parent_uuid": null 111 | } 112 | ], 113 | "tags": [ 114 | { 115 | "uuid": "5954f070-c1b2-4a0d-9e97-42974b8875a0", 116 | "name": "site:NOAA - ncei.noaa.gov", 117 | "assigned_at": "2018-02-19T23:10:17.853Z" 118 | }, 119 | { 120 | "uuid": "92b5e03e-cef7-40b2-add2-c149754ffd50", 121 | "name": "site:EPA - www3.epa.gov", 122 | "assigned_at": "2018-02-03T19:29:06Z" 123 | }, 124 | { 125 | "uuid": "39mdkf070-c2b2-ba0d-le97-u3i74b887mc9", 126 | "name": "Human", 127 | "assigned_at": "2018-01-21T23:10:17.853Z" 128 | } 129 | ] 130 | } 131 | -------------------------------------------------------------------------------- /src/__mocks__/simple-pages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uuid": "9420d91c-2fd8-411a-a756-5bf976574d10", 4 | "url": "http://www.ncei.noaa.gov/news/earth-science-conference-convenes", 5 | "title": "Earth Science Conference Convenes | National Centers for Environmental Information (NCEI)", 6 | "active": true, 7 | "created_at": "2017-05-02T23:55:57.021Z", 8 | "updated_at": "2018-02-01T05:20:52.203Z", 9 | "latest": { 10 | "uuid": "a7c51f38-a953-467a-b1b5-ee463af53ded", 11 | "page_uuid": "553d0acd-8740-47b9-a52e-3e6c15d2846d", 12 | "url": "http://www.ncei.noaa.gov/news/earth-science-conference-convenes", 13 | "capture_time": "2018-03-04T16:33:33.000Z", 14 | "body_url": "https://edgi-wm-versionista.s3.amazonaws.com/versionista2/74300-6219628/version-14835175.xml", 15 | "body_hash": "e6f32277c22dece27b719310a65c46f4e21eb3f1c3d13fe82d9cead78bc52186", 16 | "media_type": "application/xml", 17 | "media_type_parameters": "charset=utf-8", 18 | "content_length": 8013, 19 | "headers": { 20 | "age": "0", 21 | "date": "Sun, 04 Mar 2018 17:17:30 GMT", 22 | "vary": "Accept-Encoding", 23 | "expires": "Sun, 04 Mar 2018 17:17:30 GMT", 24 | "x-cachee": "MISS", 25 | "connection": "close", 26 | "content-type": "application/xml; charset=utf-8", 27 | "accept-ranges": "bytes", 28 | "cache-control": "private, max-age=0", 29 | "transfer-encoding": "chunked" 30 | }, 31 | "source_type": "versionista", 32 | "source_metadata": { 33 | "url": "https://versionista.com/74300/6219628/14835175/", 34 | "length": 8013, 35 | "account": "versionista2", 36 | "page_id": "6219628", 37 | "site_id": "74300", 38 | "diff_hash": "8dccfc17403640523feb0339133460df0dd74da8247e115c3a3c27b9f1dd308e", 39 | "version_id": "14835175", 40 | "diff_length": 213, 41 | "has_content": true, 42 | "content_type": "application/xml; charset=utf-8", 43 | "diff_text_hash": "5de0596bca31b4fcee7e83fcd6bc1df9456a4196cdefb25e655fb1a49a4d53e0", 44 | "diff_text_length": 112, 45 | "diff_with_first_url": "https://versionista.com/74300/6219628/14835175:9439620/", 46 | "diff_with_previous_url": "https://versionista.com/74300/6219628/14835175:14823228/" 47 | } 48 | }, 49 | "maintainers": [ 50 | { 51 | "uuid": "c73c89bc-6be8-4267-a3c7-c17a05c2d113", 52 | "name": "NOAA", 53 | "assigned_at": "2018-02-19T23:10:17.841Z", 54 | "parent_uuid": null 55 | }, 56 | { 57 | "uuid": "3kbc89bc-6be8-42b7-a337-cln305c2dte3", 58 | "name": "Unicorn Department", 59 | "assigned_at": "2018-01-19T23:10:17.841Z", 60 | "parent_uuid": null 61 | } 62 | ], 63 | "tags": [ 64 | { 65 | "uuid": "5954f070-c1b2-4a0d-9e97-42974b8875a0", 66 | "name": "site:NOAA - ncei.noaa.gov", 67 | "assigned_at": "2018-03-19T23:10:17.853Z" 68 | }, 69 | { 70 | "uuid": "92b5e03e-cef7-40b2-add2-c149754ffd50", 71 | "name": "site:EPA - www3.epa.gov", 72 | "assigned_at": "2018-03-03T19:29:06Z" 73 | }, 74 | { 75 | "uuid": "39mdkf070-c2b2-ba0d-le97-u3i74b887mc9", 76 | "name": "Human", 77 | "assigned_at": "2018-02-21T23:10:17.853Z" 78 | } 79 | ] 80 | }, 81 | { 82 | "uuid": "fba5c998-4f11-4e69-a3ff-66c0a4e9e7d3", 83 | "url": "https://www3.epa.gov/climatechange/impacts/society.html", 84 | "title": "Page being updated | US EPA", 85 | "active": true, 86 | "agency": "EPA", 87 | "site": "EPA - www3.epa.gov", 88 | "created_at": "2017-11-09T02:50:37Z", 89 | "latest": { 90 | "uuid": "e759b7cb-4e3d-4d55-a94a-03a5649de6f1", 91 | "page_uuid": "93db6937-3d5d-47b2-bd7c-df49ba716896", 92 | "url": "https://www3.epa.gov/climatechange/impacts/society.html", 93 | "capture_time": "2017-06-19T11:59:22.000Z", 94 | "body_url": "https://edgi-wm-versionista.s3.amazonaws.com/versionista1/71555-6026676/version-11816086.html", 95 | "body_hash": "10c2283c9e8768980bcc343e7b76a6f51b4dae3632a7f35cd037b8add71b9d0e", 96 | "media_type": null, 97 | "media_type_parameters": null, 98 | "content_length": 31190, 99 | "source_type": "versionista", 100 | "source_metadata": { 101 | "url": "https://versionista.com/71555/6026676/11816086/", 102 | "account": "versionista1", 103 | "page_id": "6026676", 104 | "site_id": "71555", 105 | "diff_hash": "291d248aaa00565ba2d2ccdc74622a0a4a2dfcd63b92ce1ca3c52b22c99b9952", 106 | "version_id": "11816086", 107 | "diff_length": 2555, 108 | "has_content": true, 109 | "diff_with_first_url": "https://versionista.com/71555/6026676/11816086:8941741", 110 | "diff_with_previous_url": "https://versionista.com/71555/6026676/11816086:10271375" 111 | }, 112 | "created_at": "2017-11-09T02:50:23.332Z", 113 | "updated_at": "2017-11-09T02:50:23.332Z", 114 | "title": "Help for Employers - How to comply, go beyond compliance, and improve your bottom line | Occupational Safety and Health Administration" 115 | }, 116 | "updated_at": "2017-11-09T02:50:37Z", 117 | "maintainers": [ 118 | { 119 | "uuid": "35bb657a-a11d-4a04-8978-9c7c65bb1ae7", 120 | "name": "Unicorn Department", 121 | "parent_uuid": null, 122 | "assigned_at": "2018-03-03T22:55:51Z" 123 | }, 124 | { 125 | "uuid": "ec232801-936e-442d-9058-c97bbe9c733d", 126 | "name": "EPA", 127 | "parent_uuid": null, 128 | "assigned_at": "2018-03-03T19:29:06Z" 129 | } 130 | ], 131 | "tags": [ 132 | { 133 | "uuid": "709d30b0-3d05-42e9-b88f-32189fb1e89e", 134 | "name": "Human", 135 | "assigned_at": "2018-03-04T07:04:11Z" 136 | }, 137 | { 138 | "uuid": "92b5e03e-cef7-40b2-add2-c149754ffd50", 139 | "name": "site:EPA - www3.epa.gov", 140 | "assigned_at": "2018-03-03T19:29:06Z" 141 | }, 142 | { 143 | "uuid": "d5f0a138-c9c1-457b-8f5c-cf104583c624", 144 | "name": "site:unicorndepartment.com", 145 | "assigned_at": "2018-03-01T23:08:53Z" 146 | } 147 | ] 148 | } 149 | ] 150 | -------------------------------------------------------------------------------- /src/components/__tests__/diff-view.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import DiffView from '../diff-view'; 4 | import { shallow } from 'enzyme'; 5 | import simplePage from '../../__mocks__/simple-page.json'; 6 | import WebMonitoringDb from '../../services/web-monitoring-db'; 7 | 8 | describe('diff-view', () => { 9 | const mockApi = Object.assign(Object.create(WebMonitoringDb.prototype), { 10 | getDiff () { 11 | return new Promise(resolve => resolve({ 12 | change_count: 1, 13 | diff: [[0, 'Hi'], [1, '!']] 14 | })); 15 | } 16 | }); 17 | 18 | const waitForNextTurn = () => new Promise(resolve => setTimeout(resolve, 0)); 19 | 20 | it('can render', () => { 21 | const diffView = shallow( 22 | , 28 | { context: { api: mockApi } } 29 | ); 30 | 31 | expect(diffView.exists()).toEqual(true); 32 | // It should always have rendered *something.* 33 | expect(diffView.get(0)).toBeTruthy(); 34 | }); 35 | 36 | it('renders an alert if there are no changes in the diff', () => { 37 | mockApi.getDiff = jest.fn().mockReturnValue(Promise.resolve({ change_count: 0 })); 38 | 39 | const diffView = shallow( 40 | , 46 | { context: { api: mockApi } } 47 | ); 48 | 49 | // Wait for diff to load, state to change, and re-render to occur. 50 | return waitForNextTurn().then(() => { 51 | expect(diffView.find('.diff-view__alert').length).toBe(1); 52 | }); 53 | }); 54 | 55 | it('renders no alert if there are changes in the diff', () => { 56 | mockApi.getDiff = jest.fn().mockReturnValue(Promise.resolve({ change_count: 1 })); 57 | 58 | const diffView = shallow( 59 | , 65 | { context: { api: mockApi } } 66 | ); 67 | 68 | // Wait for diff to load, state to change, and re-render to occur. 69 | return waitForNextTurn().then(() => { 70 | expect(diffView.find('.diff-view__alert').length).toBe(0); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/components/__tests__/environment-banner.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import EnvironmentBanner from '../environment-banner/environment-banner'; 4 | import { mount } from 'enzyme'; 5 | 6 | describe('EnvironmentBanner', () => { 7 | it('renders nothing in the production environment', () => { 8 | const banner = mount(); 11 | 12 | expect(banner.children().exists()).toBe(false); 13 | }); 14 | 15 | it('displays the correct environment for staging', () => { 16 | const banner = mount(); 19 | 20 | expect(banner.children().exists()).toBe(true); 21 | expect(banner.text()).toMatch(/\bstaging\b/); 22 | }); 23 | 24 | it('displays the correct environment for the deprecated staging URL', () => { 25 | const banner = mount(); 28 | 29 | expect(banner.children().exists()).toBe(true); 30 | expect(banner.text()).toMatch(/\bstaging\b/); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/__tests__/login-form.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { shallow } from 'enzyme'; 4 | import LoginPanel from '../login-form/login-form'; 5 | import WebMonitoringDb from '../../services/web-monitoring-db'; 6 | 7 | describe('login-form', () => { 8 | const getMockedApi = (overrides = {}) => Object.assign( 9 | Object.create(WebMonitoringDb.prototype), 10 | overrides 11 | ); 12 | 13 | it('Renders a label and input for email', () => { 14 | const panel = shallow(); 15 | const label = panel.find('label').at(0); 16 | 17 | expect(label.text()).toMatch(/e-?mail/i); 18 | expect(label.find('input').props()).toMatchObject({ name: 'email', type: 'text' }); 19 | }); 20 | 21 | it('Renders a label and input for password', () => { 22 | const panel = shallow(); 23 | const label = panel.find('label').at(1); 24 | 25 | expect(label.text()).toMatch(/password/i); 26 | expect(label.find('input').props()).toMatchObject({ name: 'password', type: 'password' }); 27 | }); 28 | 29 | it('Renders a submit button for the form', () => { 30 | const panel = shallow(); 31 | expect(panel.find('form input[type="submit"]').length).toBe(1); 32 | }); 33 | 34 | it('Renders a cancel button', () => { 35 | const panel = shallow(); 36 | expect(panel.findWhere(el => el.type() === 'button' && (/cancel/i).test(el.text())).length).toBe(1); 37 | }); 38 | 39 | it('Calls props.cancelLogin when the cancel button is clicked', () => { 40 | const cancelLogin = jest.fn(); 41 | const panel = shallow(); 42 | const cancelButton = panel.findWhere(el => el.type() === 'button' && (/cancel/i).test(el.text())); 43 | 44 | cancelButton.simulate('click', document.createEvent('UIEvents')); 45 | expect(cancelLogin).toHaveBeenCalled(); 46 | }); 47 | 48 | describe('when the form is submitted', () => { 49 | it('Calls "logIn" on the api service if email and password are present', () => { 50 | const api = getMockedApi({ logIn: jest.fn().mockResolvedValue({}) }); 51 | const panel = shallow(, { context: { api } }); 52 | 53 | panel.find('input[name="email"]') 54 | .simulate('change', { currentTarget: { value: 'aaa@aaa.aaa' } }); 55 | panel.find('input[name="password"]') 56 | .simulate('change', { currentTarget: { value: 'password' } }); 57 | 58 | panel.find('form').simulate('submit', document.createEvent('UIEvents')); 59 | 60 | expect(api.logIn).toHaveBeenCalledWith('aaa@aaa.aaa', 'password'); 61 | }); 62 | 63 | it('Calls props.onLogin if the api call is successful', async () => { 64 | const onLogin = jest.fn(); 65 | const api = getMockedApi({ logIn: jest.fn().mockResolvedValue({ id: 5 }) }); 66 | 67 | const panel = shallow(, { context: { api } }); 68 | 69 | panel.find('input[name="email"]') 70 | .simulate('change', { currentTarget: { value: 'aaa@aaa.aaa' } }); 71 | panel.find('input[name="password"]') 72 | .simulate('change', { currentTarget: { value: 'password' } }); 73 | 74 | panel.find('form').simulate('submit', document.createEvent('UIEvents')); 75 | 76 | // we have asserted that api.logIn gets called in the previous test. 77 | // this waits for it to resolve before testing that onLogin gets called 78 | await api.logIn.mock.results[0].value; 79 | expect(onLogin).toHaveBeenCalledWith({ id: 5 }); 80 | }); 81 | 82 | it('Displays an error if the api call is unsuccessful', async () => { 83 | const onLogin = jest.fn(); 84 | const api = getMockedApi({ logIn: jest.fn().mockRejectedValue(new Error('Login unsuccessful')) }); 85 | const panel = shallow(, { context: { api } }); 86 | 87 | panel.find('input[name="email"]') 88 | .simulate('change', { currentTarget: { value: 'aaa@aaa.aaa' } }); 89 | panel.find('input[name="password"]') 90 | .simulate('change', { currentTarget: { value: 'password' } }); 91 | 92 | panel.find('form').simulate('submit', document.createEvent('UIEvents')); 93 | 94 | await expect(api.logIn.mock.results[0].value).rejects.toThrow(); 95 | expect(panel.find('[className*="danger"]').text()).toBe('Login unsuccessful'); 96 | }); 97 | 98 | it('Does not call "logIn" if email and password are not both present', () => { 99 | const api = getMockedApi({ logIn: jest.fn().mockResolvedValue({}) }); 100 | const panel = shallow(, { context: { api } }); 101 | 102 | panel.find('input[name="email"]') 103 | .simulate('change', { currentTarget: { value: 'aaa@aaa.aaa' } }); 104 | 105 | panel.find('form').simulate('submit', document.createEvent('UIEvents')); 106 | 107 | expect(api.logIn).not.toHaveBeenCalled(); 108 | expect(panel.find('[className*="danger"]').text()).toBeTruthy(); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/components/__tests__/nav-bar.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { shallow } from 'enzyme'; 4 | import NavBar from '../nav-bar/nav-bar'; 5 | 6 | test('navbar holds title and username', () => { 7 | const navbar = shallow(); 8 | expect(navbar.find('Link').getElement().props.children).toEqual('ohhai'); 9 | expect(navbar.find('ul > li').last().text()).toEqual('me (Log out)'); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/__tests__/page-details.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import timers from 'node:timers/promises'; 3 | import PageDetails from '../page-details/page-details'; 4 | import { shallow } from 'enzyme'; 5 | import simplePage from '../../__mocks__/simple-page.json'; 6 | import WebMonitoringDb from '../../services/web-monitoring-db'; 7 | 8 | describe('page-details', () => { 9 | // Change string values to date objects so they're parsed correctly 10 | simplePage.versions.forEach(version => { 11 | version.capture_time = new Date(version.capture_time); 12 | }); 13 | const match = { params: { pageId: simplePage.uuid } }; 14 | const createMockApi = () => { 15 | let samples = simplePage.versions.reduce((samples, version) => { 16 | const key = version.capture_time.toISOString().slice(0, 10); 17 | if (key in samples) { 18 | samples[key].version_count += 1; 19 | } 20 | else { 21 | samples[key] = { 22 | time: key, 23 | version_count: 1, 24 | version 25 | }; 26 | } 27 | return samples; 28 | }, {}); 29 | samples = Object.values(samples).sort((a, b) => a.time < b.time ? 1 : -1); 30 | 31 | return Object.assign(Object.create(WebMonitoringDb.prototype), { 32 | getPage: jest.fn().mockResolvedValue({ ...simplePage }), 33 | getVersion: jest.fn(id => Promise.resolve( 34 | simplePage.versions.find(v => v.uuid === id) 35 | )), 36 | getVersions: jest.fn().mockResolvedValue(simplePage.versions), 37 | sampleVersions: jest.fn().mockResolvedValue(samples) 38 | }); 39 | }; 40 | 41 | it('can render', () => { 42 | const mockApi = createMockApi(); 43 | const pageDetails = shallow( 44 | , 47 | { context: { api: mockApi } } 48 | ); 49 | 50 | expect(pageDetails.exists()).toEqual(true); 51 | // It should always have rendered *something.* 52 | expect(pageDetails.get(0)).toBeTruthy(); 53 | }); 54 | 55 | it('shows correct title', async () => { 56 | const mockApi = createMockApi(); 57 | const pageDetails = shallow( 58 | , 61 | { context: { api: mockApi } } 62 | ); 63 | 64 | await timers.setTimeout(10); 65 | expect(mockApi.getPage).toHaveBeenCalled(); 66 | expect(mockApi.sampleVersions).toHaveBeenCalled(); 67 | 68 | expect(document.title).toBe('Scanner | http://www.ncei.noaa.gov/news/earth-science-conference-convenes'); 69 | 70 | pageDetails.unmount(); 71 | expect(document.title).toBe('Scanner'); 72 | }); 73 | 74 | it('gets versions missing from the sample', async () => { 75 | const allVersions = simplePage.versions; 76 | const mockApi = createMockApi(); 77 | const pageDetails = shallow( 78 | , 87 | { context: { api: mockApi } } 88 | ); 89 | 90 | await timers.setTimeout(10); 91 | expect(mockApi.getPage).toHaveBeenCalled(); 92 | expect(mockApi.sampleVersions).toHaveBeenCalled(); 93 | expect(mockApi.getVersion).toHaveBeenCalled(); 94 | 95 | // expect(document.title).toBe('Scanner | http://www.ncei.noaa.gov/news/earth-science-conference-convenes'); 96 | expect(pageDetails.exists()).toEqual(true); 97 | // It should always have rendered *something.* 98 | expect(pageDetails.get(0)).toBeTruthy(); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/components/__tests__/page-list.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import PageList from '../page-list/page-list'; 4 | import SearchBar from '../search-bar/search-bar'; 5 | import Loading from '../loading'; 6 | import { shallow } from 'enzyme'; 7 | import simplePages from '../../__mocks__/simple-pages.json'; 8 | 9 | describe('page-list', () => { 10 | let globalOpen; 11 | 12 | /* eslint-disable no-undef */ 13 | beforeEach(() => { 14 | globalOpen = global.open; 15 | }); 16 | 17 | afterEach(() => { 18 | global.open = globalOpen; 19 | }); 20 | 21 | // Change string values to date objects so they're parsed correctly 22 | simplePages.forEach(record => { 23 | record.latest.capture_time = new Date(record.latest.capture_time); 24 | }); 25 | 26 | it('can render', () => { 27 | const pageList = shallow( 28 | 29 | ); 30 | 31 | expect(pageList.exists()).toEqual(true); 32 | // It should always have rendered *something.* 33 | expect(pageList.get(0)).toBeTruthy(); 34 | }); 35 | 36 | it('shows domain without www prefix', () => { 37 | const pageList = shallow( 38 | 39 | ); 40 | expect(pageList.find('tbody tr').first().childAt(0).text()) 41 | .toBe('ncei.noaa.gov'); 42 | }); 43 | 44 | it('shows non-URL related tags', () => { 45 | const pageList = shallow(); 46 | const tagsCell = pageList.find('tbody tr').first().childAt(3); 47 | 48 | expect(tagsCell.children().every('PageTag')).toBe(true); 49 | expect(tagsCell.children().length).toBe(1); 50 | expect(tagsCell.children().first().props()) 51 | .toHaveProperty('tag.name', 'Human'); 52 | }); 53 | 54 | it('displays SearchBar component', () => { 55 | const pageList = shallow( 56 | 57 | ); 58 | expect(pageList.find(SearchBar).length).toBe(1); 59 | }); 60 | 61 | it('displays Loading component when there are no pages', () => { 62 | const pageList = shallow( 63 | 64 | ); 65 | expect(pageList.find(Loading).length).toBe(1); 66 | }); 67 | 68 | it('does not display Loading component when there are pages', () => { 69 | const pageList = shallow( 70 | 71 | ); 72 | expect(pageList.find(Loading).length).toBe(0); 73 | }); 74 | 75 | it('opens a new window when a user control clicks on a page row', () => { 76 | global.open = jest.fn(); 77 | const pageList = shallow( 78 | 79 | ); 80 | 81 | pageList.find('tr[data-name="info-row"]').first().simulate('click', { ctrlKey : true }); 82 | 83 | expect(global.open.mock.calls[0][0]).toBe('/page/9420d91c-2fd8-411a-a756-5bf976574d10'); 84 | expect(global.open.mock.calls[0][1]).toBe('_blank'); 85 | }); 86 | 87 | it('opens a new window when a user command clicks on a page row', () => { 88 | global.open = jest.fn(); 89 | const pageList = shallow( 90 | 91 | ); 92 | 93 | pageList.find('tr[data-name="info-row"]').first().simulate('click', { metaKey : true }); 94 | 95 | expect(global.open.mock.calls.length).toBe(1); 96 | }); 97 | /* eslint-enable no-undef */ 98 | }); 99 | -------------------------------------------------------------------------------- /src/components/__tests__/page-url-details.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import PageUrlDetails from '../page-url-details/page-url-details'; 3 | import { mount } from 'enzyme'; 4 | import simplePage from '../../__mocks__/simple-page.json'; 5 | 6 | describe('PageUrlDetails Component', () => { 7 | // Change string values to date objects so they're parsed correctly 8 | simplePage.versions.forEach(version => { 9 | version.capture_time = new Date(version.capture_time); 10 | }); 11 | 12 | it('shows nothing if version URLs and redirects match the page URL', () => { 13 | const view = mount( 14 | 19 | ); 20 | 21 | expect(view.children()).toHaveLength(0); 22 | }); 23 | 24 | it("shows the versions' URL if it differs from the page's", () => { 25 | const version1 = { 26 | ...simplePage.versions[1], 27 | url: `${simplePage.url}/something` 28 | }; 29 | const version2 = { 30 | ...simplePage.versions[0], 31 | url: `${simplePage.url}/something` 32 | }; 33 | const view = mount( 34 | 39 | ); 40 | 41 | expect(view.debug()).toMatchSnapshot(); 42 | }); 43 | 44 | it("shows the versions' redirects", () => { 45 | const version1 = { 46 | ...simplePage.versions[1], 47 | source_metadata: { 48 | ...simplePage.versions[1].source_metadata, 49 | redirects: [ 50 | simplePage.url, 51 | `${simplePage.url}/something` 52 | ] 53 | } 54 | }; 55 | const version2 = { 56 | ...simplePage.versions[0], 57 | source_metadata: { 58 | ...simplePage.versions[0].source_metadata, 59 | redirects: [ 60 | simplePage.url, 61 | `${simplePage.url}/something` 62 | ] 63 | } 64 | }; 65 | const view = mount( 66 | 71 | ); 72 | 73 | expect(view.debug()).toMatchSnapshot(); 74 | }); 75 | 76 | it('shows separate URL histories for each version if they differ', () => { 77 | const version1 = { 78 | ...simplePage.versions[1], 79 | source_metadata: { 80 | ...simplePage.versions[1].source_metadata, 81 | redirects: [ 82 | simplePage.url, 83 | `${simplePage.url}/something` 84 | ] 85 | } 86 | }; 87 | const version2 = { 88 | ...simplePage.versions[0], 89 | source_metadata: { 90 | ...simplePage.versions[0].source_metadata, 91 | redirects: [ 92 | simplePage.url, 93 | `${simplePage.url}/something/else` 94 | ] 95 | } 96 | }; 97 | const view = mount( 98 | 103 | ); 104 | 105 | expect(view.debug()).toMatchSnapshot(); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/components/__tests__/sandboxed-html.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { mount } from 'enzyme'; 4 | import SandboxedHtml from '../sandboxed-html'; 5 | 6 | describe('sandboxed-html', () => { 7 | it('renders an iframe with a sandbox attribute', () => { 8 | const sandbox = mount(); 9 | const frame = sandbox.find('iframe').first(); 10 | expect(frame).toBeDefined(); 11 | expect(frame.props().sandbox).toBeTruthy(); 12 | }); 13 | 14 | it('sets a element for `baseUrl`', () => { 15 | const source = ` 16 | 17 | Whatever 18 | Hello! 19 | `; 20 | 21 | const sandbox = mount(); 22 | const frame = sandbox.find('iframe').first().getDOMNode(); 23 | expect(frame.getAttribute('srcdoc')).toMatch(/ { 27 | const source = ` 28 | 29 | Whatever 30 | Hello! 31 | `; 32 | 33 | const transform = document => { 34 | document.body.appendChild(document.createTextNode('Transformed!')); 35 | return document; 36 | }; 37 | 38 | const sandbox = mount(); 39 | const frame = sandbox.find('iframe').first().getDOMNode(); 40 | expect(frame.getAttribute('srcdoc')).toMatch(/Hello![\n\s]*Transformed!/ig); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/__tests__/search-bar.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { shallow, mount } from 'enzyme'; 4 | import SearchBar from '../search-bar/search-bar'; 5 | import moment from 'moment'; 6 | 7 | describe('search-bar', () => { 8 | // Need to use a fake timer because the _urlSearch function is debounced. 9 | jest.useFakeTimers(); 10 | 11 | it('Renders the search-bar', () => { 12 | const searchBar = shallow(); 13 | const searchBarInput = searchBar.find('input'); 14 | expect(searchBarInput.prop('placeholder')).toBe('Search for a URL...'); 15 | }); 16 | 17 | it('Handles search queries with a protocol correctly', () => { 18 | const onSearch = jest.fn(); 19 | const searchBar = shallow(); 20 | const searchBarInput = searchBar.find('input'); 21 | searchBarInput.simulate('change', { target: { value: 'http://epa' } }); 22 | 23 | jest.runAllTimers(); 24 | expect(onSearch).toHaveBeenCalledWith({ 25 | url: 'http://epa*', 26 | startDate: null, 27 | endDate: null 28 | }); 29 | }); 30 | 31 | it('Handles search queries without a protocol correctly', () => { 32 | const onSearch = jest.fn(); 33 | const searchBar = shallow(); 34 | const searchBarInput = searchBar.find('input'); 35 | searchBarInput.simulate('change', { target: { value: 'epa' } }); 36 | 37 | jest.runAllTimers(); 38 | expect(onSearch).toHaveBeenCalledWith({ 39 | url: '*//epa*', 40 | startDate: null, 41 | endDate: null 42 | }); 43 | }); 44 | 45 | it('Handles date range search queries for startDate', () => { 46 | const onSearch = jest.fn(); 47 | const searchBar = mount(); 48 | searchBar.find('input#startDate1').simulate('focus'); 49 | 50 | searchBar.find('.CalendarDay.CalendarDay_1').first().simulate('click'); 51 | expect(onSearch).toHaveBeenCalledWith({ 52 | url: null, 53 | startDate: expect.any(moment), 54 | endDate: null 55 | }); 56 | }); 57 | 58 | it('Handles date range search queries for endDate', () => { 59 | const onSearch = jest.fn(); 60 | const searchBar = mount(); 61 | 62 | searchBar.find('input#endDate1').simulate('focus'); 63 | searchBar.find('.CalendarDay.CalendarDay_1').first().simulate('click'); 64 | expect(onSearch).toHaveBeenCalledWith({ 65 | url: null, 66 | startDate: null, 67 | endDate: expect.any(moment) 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/components/__tests__/source-info.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { shallow } from 'enzyme'; 4 | import SourceInfo from '../source-info/source-info'; 5 | 6 | describe('source-info', () => { 7 | const noViewUrl = { 8 | source_type: 'versionista', 9 | source_metadata: { 10 | url: 'https://versionista.com/1111/2222/3333/', 11 | account: 'versionista1', 12 | page_id: '1234567', 13 | site_id: '8888888', 14 | version_id: '123456778', 15 | has_content: true 16 | } 17 | }; 18 | 19 | const withViewUrl1 = { 20 | source_type: 'internet_archive', 21 | source_metadata: { 22 | encoding: 'ISO-8859-1', 23 | view_url: 'http://web.archive.org/web/1111111/https://19january2017snapshot.epa.gov/test-url', 24 | mime_type: 'text/html', 25 | status_code: 200 26 | } 27 | }; 28 | 29 | const withViewUrl2 = { 30 | source_type: 'internet_archive', 31 | source_metadata: { 32 | encoding: 'ISO-8859-1', 33 | view_url: 'http://web.archive.org/web/22222/https://19january2017snapshot.epa.gov/test-url', 34 | mime_type: 'text/html', 35 | status_code: 200 36 | } 37 | }; 38 | 39 | const pageUrl = 'https://19january2017snapshot.epa.gov/test-url'; 40 | 41 | it('Renders only the Wayback calendar link if neither of the page versions have a view_url', () => { 42 | const sInfo = shallow(); 43 | expect(sInfo.text()).toBe('Wayback Machine calendar view'); 44 | const anchorTag = sInfo.find('a'); 45 | expect(anchorTag.length).toBe(1); 46 | const { props: { href } } = anchorTag.get(0); 47 | expect(href).toBe('https://web.archive.org/web/*/https://19january2017snapshot.epa.gov/test-url'); 48 | }); 49 | 50 | it('Renders the Wayback calendar link and previous/next versions with Wayback links if they are sourced from Wayback', () => { 51 | const sInfo = shallow(); 52 | expect(sInfo.text()).toBe('Wayback Machine calendar view | Wayback Machine previous page version | Wayback Machine next page version'); 53 | const anchorTags = sInfo.find('a'); 54 | expect(anchorTags.length).toBe(3); 55 | const { props: { href : href1 } } = anchorTags.get(1); 56 | const { props: { href: href2 } } = anchorTags.get(2); 57 | expect(href1).toBe('http://web.archive.org/web/1111111/https://19january2017snapshot.epa.gov/test-url'); 58 | expect(href2).toBe('http://web.archive.org/web/22222/https://19january2017snapshot.epa.gov/test-url'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/annotation-form.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import StandardTooltip from './standard-tooltip'; 3 | 4 | /** 5 | * @typedef {Object} AnnotationFormProps 6 | * @property {Object} annotation 7 | * @property {boolean} [collapsed=true] 8 | * @property {Function} [onChange] Callback for changes to the annotation. It 9 | * should be of the signature `(annotation) => void` 10 | */ 11 | 12 | /** 13 | * Form layout for marking/viewing simple annotations of changes. 14 | * 15 | * @class AnnotationForm 16 | * @extends {Component} 17 | * @param {AnnotationFormProps} props 18 | */ 19 | export default class AnnotationForm extends Component { 20 | constructor (props) { 21 | super(props); 22 | this._onFieldChange = this._onFieldChange.bind(this); 23 | this._onNotesChange = this._onNotesChange.bind(this); 24 | } 25 | 26 | render () { 27 | const annotation = this.props.annotation || {}; 28 | const common = { 29 | formValues: annotation, 30 | onChange: this._onFieldChange, 31 | collapsed: this.props.collapsed 32 | }; 33 | 34 | const classes = ['annotation-form']; 35 | if (this.props.collapsed) { 36 | classes.push('annotation-form--collapsed'); 37 | } 38 | 39 | return ( 40 |
41 |
42 |
43 | Individual Page Changes 44 |
    45 | Date and time change only 46 | Text or numeric content removal or change 47 | Image content removal or change 48 | Hyperlink removal or change 49 | Text-box, entry field, or interactive component removal or change 50 | Page removal (whether it has happened in the past or is currently removed) 51 |
52 |
53 |
54 | Repeated Changes 55 |
    56 | Header menu removal or change 57 | Template text, page format, or comment field removal or change 58 | Footer or site map removal or change 59 | Sidebar removal or change 60 | Banner/advertisement removal or change 61 | Scrolling news/reports 62 |
63 |
64 |
65 | Significance 66 |
    67 | Change related to energy, environment, or climate 68 | Language is significantly altered 69 | Content is removed 70 | Page is removed 71 | Insignificant 72 | Repeated Insignificant 73 |
74 |
75 |
76 | 77 |