Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b07ed2ab5 | |||
| 97878c3881 | |||
| 14c1e14f51 | |||
| 1621e0e16a | |||
| 8d78f41ed6 | |||
| 42b3d0a9d8 | |||
| 3105371954 | |||
| 18427f9fc6 | |||
| f9b3d5dbd7 | |||
| c6020a225f | |||
| e01b6d424e | |||
| 94d7f3d3ce | |||
| 8186e1cf87 | |||
| 461d8aa7ac | |||
| 110543a537 | |||
| aa10ecf18c | |||
| 280412e2db | |||
| dd8c405482 | |||
| ead8927b02 | |||
| d46ce46b93 | |||
| 07cd0a4799 | |||
| 0473c27e39 | |||
| 26e0776f1d | |||
| c8068762b5 | |||
| f63e9b2a47 | |||
| e449eade9d | |||
| 84fd88f1d0 | |||
| f11c41b05e | |||
| 0d512a1a4d | |||
| a1281543e2 | |||
| 53def33627 | |||
| 791142005e | |||
| 4d31079ca2 | |||
| e29b40990c | |||
| 092fd39da6 | |||
| ace907779f | |||
| 383197a4c1 | |||
| ced6c42c3d | |||
| 9f15287fcc | |||
| 5d700f47aa | |||
| 58dc36b0c6 | |||
| d813fdf742 | |||
| babab418c7 | |||
| 143a18807e | |||
| 4fd02d661d | |||
| 728a7fbb14 | |||
| 2f7c67c953 | |||
| b5cde63e4e | |||
| 274fdf038d | |||
| 51c21881cc | |||
| 59ebe433b6 | |||
| 5a782b439e | |||
| b3b7480358 | |||
| b7393b3a4b | |||
| a13f26286d | |||
| 8830fad923 | |||
| 47ee62d387 | |||
| ee915c39dc | |||
| f4e7bc0e76 | |||
| c1716570a8 | |||
| 3920c716e4 | |||
| 91ab1dbd05 | |||
| 67ca83ff16 | |||
| 97509773af | |||
| cdb77255a7 | |||
| b47d03cb31 | |||
| 3fcf31bc48 | |||
| 9d7da52ee3 | |||
| 4e27c4ef87 | |||
| 4f1ad26635 | |||
| 6c603b7958 | |||
| f71d707e23 | |||
| 67d970c837 | |||
| 35efa7cdb0 | |||
| fe714f2363 | |||
| a118519c3a | |||
| f16a51abcb | |||
| 1ee3055f37 | |||
| e70705be5f | |||
| 754868b9d5 | |||
| d4aa207f75 | |||
| b299d32fc3 | |||
| 21c392752e | |||
| 3378c68aed | |||
| 57dbea8c40 | |||
| fcf9a354f4 | |||
| f9b34ebd94 | |||
| ef8b3a4bde | |||
| d57a4c1147 | |||
| 9e0cf951d2 | |||
| 86552860f6 | |||
| 1bb01ab8eb | |||
| 2c8b779793 | |||
| 83de8ebcbc | |||
| f776d19404 | |||
| cfd7e1ab02 | |||
| d41dad14a1 | |||
| a23e72d633 | |||
| 67771e07b0 | |||
| f0233ee030 | |||
| c7ea0659f4 | |||
| 325f01f034 | |||
| d48b48bffc | |||
| c4a74ad244 | |||
| 3f692fc20b | |||
| bbdc5a3b12 | |||
| 8c312a450c | |||
| e77763d0b0 | |||
| 9200c1a8e3 | |||
| 46c956e3da | |||
| 4b7f55382f | |||
| eab20afe1c | |||
| 7261b0b526 | |||
| bf44bce954 | |||
| 60449249d0 | |||
| c14aa078b1 | |||
| f1a2dfdd34 | |||
| 3eb0689ff3 | |||
| 5de72ffb97 | |||
| c3116bf5b6 | |||
| 152cf7f7b3 | |||
| 89bac8953b | |||
| 42a1788c52 | |||
| f41518bd92 | |||
| 13cfcca85e | |||
| 2d754227b1 | |||
| d8ceec1c07 | |||
| abe7879b08 | |||
| 8187865f1f | |||
| 1336862bce | |||
| 024eb57603 | |||
| d589329883 | |||
| 786cd06cd9 | |||
| 1f5da0c690 | |||
| 9a4f669c2d | |||
| a4450b34b3 | |||
| 0606325d6d | |||
| 50200ae72f | |||
| befcac2087 | |||
| ea8e4fba08 | |||
| b774babfb9 | |||
| cf0ec81730 | |||
| 297bb650ac | |||
| 70b489f2a6 | |||
| aca30c8df3 | |||
| 1e4e5d82d1 | |||
| f14e018518 | |||
| 2a77792165 | |||
| ba8461c281 | |||
| 02f6f2d252 | |||
| 8426eaa433 | |||
| 3b11fcb0b6 | |||
| bf35f26d5b | |||
| 618b21bb93 | |||
| e1c4cb9068 | |||
| 7d7916baf0 | |||
| 837b8d77df | |||
| 8a3471b916 | |||
| 6cff032efb | |||
| c26bf26bd1 | |||
| a5babf8965 | |||
| 1655a7898e | |||
| 533475e613 | |||
| a7ebcd92bf | |||
| 3ce3a886ed | |||
| fb4a130b3c | |||
| c572501d51 | |||
| 6f391264be | |||
| 6fd1035cf6 | |||
| 9519afa75a | |||
| 819d4be1a5 | |||
| bad4b4be73 | |||
| 49f3c16c2c | |||
| 6fc3062146 | |||
| 10bd2c7e36 | |||
| 02f33c6ae2 | |||
| 767433149e | |||
| 4b1d5591c4 | |||
| 59e5d12882 | |||
| 123be01b1f | |||
| 881bd299f6 | |||
| 4136e95e13 | |||
| 3682d4ce81 | |||
| 539a907076 | |||
| 09a62efa7d | |||
| 5c314662e4 | |||
| ae3d8b7e18 | |||
| 186152bb3c | |||
| 6638994f83 | |||
| 9cbd923c1f |
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true,
|
||||
"jest/globals": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:jest/recommended"
|
||||
],
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"jest"
|
||||
],
|
||||
"rules": {
|
||||
"arrow-parens": [
|
||||
"error",
|
||||
"as-needed"
|
||||
],
|
||||
"arrow-spacing": [
|
||||
"error",
|
||||
{
|
||||
"before": true,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"objects": "never",
|
||||
"arrays": "never",
|
||||
"imports": "never",
|
||||
"exports": "never",
|
||||
"functions": "never"
|
||||
}
|
||||
],
|
||||
"indent": [
|
||||
"error",
|
||||
2
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"max-len": [
|
||||
"warn",
|
||||
{
|
||||
"code": 80
|
||||
}
|
||||
],
|
||||
"no-var": "error",
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"space-before-function-paren": [
|
||||
"error",
|
||||
{
|
||||
"named": "never",
|
||||
"anonymous": "never",
|
||||
"asyncArrow": "always"
|
||||
}
|
||||
],
|
||||
"template-curly-spacing": [
|
||||
"error",
|
||||
"always"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "16.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "regexper"
|
||||
},
|
||||
"targets": {
|
||||
"regexper": {
|
||||
"hosting": {
|
||||
"production": [
|
||||
"regexper"
|
||||
],
|
||||
"preview": [
|
||||
"regexper-preview"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -18,12 +18,13 @@ node_modules/
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# Build output
|
||||
build/
|
||||
script/__build__/
|
||||
# Gatsby build files
|
||||
.cache/
|
||||
public/
|
||||
|
||||
# Coverage reports
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Favicon cache
|
||||
.wwp-cache/
|
||||
# Firebase
|
||||
.firebase/
|
||||
firebase-debug.log
|
||||
|
||||
+24
-39
@@ -1,68 +1,55 @@
|
||||
image: node:latest
|
||||
|
||||
stages:
|
||||
- setup
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
.shared_runner: &shared_runner
|
||||
tags:
|
||||
- shared
|
||||
|
||||
.cache_consumer: &cache_consumer
|
||||
cache:
|
||||
policy: pull
|
||||
paths:
|
||||
- node_modules
|
||||
|
||||
.preview_job: &preview_job
|
||||
only:
|
||||
- react # TODO: Change to master once merged
|
||||
- gatsby # TODO: Change to master once merged
|
||||
|
||||
.production_job: &production_job
|
||||
only:
|
||||
- /^release-.*$/
|
||||
|
||||
.build_template: &build_template
|
||||
<<: *shared_runner
|
||||
<<: *cache_consumer
|
||||
stage: build
|
||||
dependencies: []
|
||||
artifacts:
|
||||
paths:
|
||||
- build/
|
||||
- public/
|
||||
script:
|
||||
- yarn build
|
||||
|
||||
.deploy_template: &deploy_template
|
||||
<<: *shared_runner
|
||||
<<: *cache_consumer
|
||||
stage: deploy
|
||||
script:
|
||||
- yarn deploy
|
||||
- yarn firebase use --token $FIREBASE_DEPLOY_KEY default
|
||||
- yarn firebase deploy --only hosting:$DEPLOY_ENV -m "Pipeline $CI_PIPELINE_ID, Build $CI_BUILD_ID" --non-interactive --token $FIREBASE_DEPLOY_KEY
|
||||
|
||||
setup:
|
||||
<<: *shared_runner
|
||||
stage: setup
|
||||
cache:
|
||||
cache:
|
||||
paths:
|
||||
- node_modules
|
||||
script:
|
||||
- node_modules/
|
||||
|
||||
before_script:
|
||||
- yarn install
|
||||
|
||||
test:
|
||||
<<: *shared_runner
|
||||
<<: *cache_consumer
|
||||
test-lint:
|
||||
stage: test
|
||||
script:
|
||||
- yarn test:lint
|
||||
|
||||
test-unit:
|
||||
stage: test
|
||||
coverage: '/^Statements\s*:\s*([^%]+)/'
|
||||
script:
|
||||
- yarn test:unit
|
||||
artifacts:
|
||||
paths:
|
||||
- coverage/
|
||||
script:
|
||||
- yarn test
|
||||
|
||||
build_preview:
|
||||
build-preview:
|
||||
<<: *build_template
|
||||
<<: *preview_job
|
||||
variables:
|
||||
@@ -70,33 +57,31 @@ build_preview:
|
||||
DEPLOY_ENV: preview
|
||||
GA_PROPERTY: $PREVIEW_GA_PROPERTY
|
||||
|
||||
build_production:
|
||||
build-production:
|
||||
<<: *build_template
|
||||
<<: *production_job
|
||||
variables:
|
||||
DEPLOY_ENV: production
|
||||
GA_PROPERTY: $PROD_GA_PROPERTY
|
||||
|
||||
deploy_preview:
|
||||
deploy-preview:
|
||||
<<: *deploy_template
|
||||
<<: *preview_job
|
||||
dependencies:
|
||||
- build_preview
|
||||
- build-preview
|
||||
environment:
|
||||
name: preview
|
||||
url: https://preview.regexper.com
|
||||
variables:
|
||||
CLOUD_FRONT_ID: $PREVIEW_CLOUDFRONT_ID
|
||||
DEPLOY_BUCKET: $PREVIEW_DEPLOY_BUCKET
|
||||
DEPLOY_ENV: preview
|
||||
|
||||
deploy_production:
|
||||
deploy-production:
|
||||
<<: *deploy_template
|
||||
<<: *production_job
|
||||
dependencies:
|
||||
- build_production
|
||||
- build-production
|
||||
environment:
|
||||
name: production
|
||||
url: https://regexper.com
|
||||
variables:
|
||||
CLOUD_FRONT_ID: $PROD_CLOUDFRONT_ID
|
||||
DEPLOY_BUCKET: $PROD_DEPLOY_BUCKET
|
||||
DEPLOY_ENV: production
|
||||
|
||||
@@ -20,43 +20,6 @@ To start a development server, run:
|
||||
|
||||
$ yarn start
|
||||
|
||||
### Translating
|
||||
|
||||
A helper tool is available to support maintaining translations. Running `yarn i18n:scrub` will update locale data files under `src/locales` by normalizing YAML syntax across all the files, maintaining a `missing.yaml` file under each locale, and indicating any existing translations that appear to be no longer used. For this, the `src/locales/en/translation.yaml` file is used as a source of truth.
|
||||
|
||||
To add a new locale, first create a directory for the locale under `src/locales` (no files need to be added at this point), then run `yarn i18n:scrub`. This will create a `src/locales/<locale name>/missing.yaml` file that contains required translations. Add a `src/locales/<locale name>/translation.yaml` file and at a minimum add a translation for the `/displayName` value. This should be the translated name of the locale your are adding.
|
||||
|
||||
If you are looking to translation missing text for a locale that is already present in the project, start by running `yarn i18n:scrub`. Check the `src/locales/<locale name>/missing.yaml` file for translations that are needed. Add any updated translations to `src/locales/<locale name>/translation.yaml`.
|
||||
|
||||
Before committing any updated translations, run `yarn i18n:scrub` again to maintain syntax in the YAML files and remove any translated entries from `missing.yaml`. Commit any changes to the `translation.yaml` and `missing.yaml` files.
|
||||
|
||||
### Available scripts
|
||||
|
||||
* `yarn start` - Start a development server on port 8080
|
||||
* `yarn start:prod` - Run a build and start a web server on port 8080. This will not automatically rebuild.
|
||||
* `yarn build` - Run a production build (used for deployments and for rebuilding when running `yarn start:prod`)
|
||||
* `yarn deploy` - Deploy application to AWS S3 bucket
|
||||
* `yarn test` - Run lint and unit tests
|
||||
* `yarn test:lint` - Run eslint
|
||||
* `yarn test:unit` - Run jest unit tests
|
||||
* `yarn test:watch` - Run jest in watch mode
|
||||
* `yarn test:bundle-analyzer` - Generate webpack-bundle-analyzer report
|
||||
* `yarn i18n:scrub` - Scrubs i18n locale configs. Adds missing keys and normalizes YAML formatting
|
||||
|
||||
## Configuration
|
||||
|
||||
Several environment variables are used to configure the application at build-time. None of these values are required during testing.
|
||||
|
||||
* `NODE_ENV` - Effects build-time optimizations. Set to `"production"` for builds and unit tests in package.json. Setting to anything else will show a banner in the application's header.
|
||||
* `GA_PROPERTY` - Google Analytics property ID.
|
||||
* `SENTRY_KEY` - Sentry.io DSN key for error reporting.
|
||||
* `CI_COMMIT_REF_SLUG`, `CI_COMMIT_SHA` - GitLab CI values used to generate build ID. Displayed in application footer and used in Sentry.io error reports.
|
||||
* `CLOUD_FRONT_ID` - AWS CloudFront distribution ID to invalidating when running `yarn deploy`
|
||||
* `DEPLOY_BUCKET` - AWS S3 bucket to deploy application to when running `yarn deploy`.
|
||||
* `DEPLOY_ENV` - Environment the application will be deployed to. Used to report environment in Sentry.io error reports. Typically set to either "preview" or "production".
|
||||
* `BANNER` - Text to display in header banner. Generally generated from `NODE_ENV`
|
||||
* `BUILD_ID` - Application build ID. Generated from `CI_COMMIT_REF_SLUG` and `CI_COMMIT_SHA` if not set.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE.txt](/LICENSE.txt) file for licensing details.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
s3Bucket: process.env.DEPLOY_BUCKET,
|
||||
cloudFrontId: process.env.CLOUD_FRONT_ID,
|
||||
deployFrom: path.resolve(__dirname, 'build'),
|
||||
paths: [
|
||||
{
|
||||
match: /^service-worker.js/,
|
||||
CacheControl: 'max-age=0'
|
||||
},
|
||||
{
|
||||
match: /^(js|css|icons-\w{8})/,
|
||||
CacheControl: 'public, max-age=31536000'
|
||||
},
|
||||
{ // Default config. MUST BE LAST
|
||||
match: /./,
|
||||
CacheControl: 'public, max-age=96400'
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hosting": [
|
||||
{
|
||||
"target": "production",
|
||||
"public": "public"
|
||||
},
|
||||
{
|
||||
"target": "preview",
|
||||
"public": "public"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
|
||||
import i18n from 'i18n';
|
||||
import Layout from 'components/Layout';
|
||||
|
||||
import 'site.css';
|
||||
import style from 'globals.module.css';
|
||||
|
||||
Modal.setAppElement('#___gatsby');
|
||||
|
||||
Modal.defaultProps = {
|
||||
...Modal.defaultProps,
|
||||
className: style.modal,
|
||||
overlayClassName: style.modalOverlay
|
||||
};
|
||||
|
||||
export const onClientEntry = () => {
|
||||
Sentry.getCurrentHub().getClient().getOptions().enabled =
|
||||
(navigator.doNotTrack !== '1' && window.doNotTrack !== '1');
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
export const wrapPageElement = ({ element }) => {
|
||||
return <Layout>{ element }</Layout>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
export const wrapRootElement = ({ element }) => {
|
||||
return <I18nextProvider i18n={ i18n }>{ element }</I18nextProvider>;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
const pkg = require('./package.json');
|
||||
|
||||
const buildId = [
|
||||
process.env.CI_COMMIT_REF_SLUG || 'prerelese',
|
||||
(process.env.CI_COMMIT_SHA || 'gitsha').slice(0, 7)
|
||||
].join('-');
|
||||
const banner = process.env.BANNER || (process.env.NODE_ENV === 'production'
|
||||
? false
|
||||
: (process.env.NODE_ENV || 'development'));
|
||||
|
||||
module.exports = {
|
||||
siteMetadata: {
|
||||
description: pkg.description,
|
||||
buildId,
|
||||
banner,
|
||||
defaultSyntax: 'js',
|
||||
syntaxList: [
|
||||
{ id: 'js', label: 'JavaScript' },
|
||||
{ id: 'pcre', label: 'PCRE' }
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
'gatsby-plugin-react-helmet',
|
||||
'gatsby-plugin-postcss',
|
||||
{
|
||||
resolve: 'gatsby-plugin-google-analytics',
|
||||
options: {
|
||||
trackingId: process.env.GA_PROPERTY,
|
||||
anonymize: true,
|
||||
respectDNT: true,
|
||||
storeGac: false,
|
||||
cookieExpires: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
resolve: 'gatsby-plugin-sentry',
|
||||
options: {
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.DEPLOY_ENV || process.env.NODE_ENV,
|
||||
debug: (process.env.NODE_ENV !== 'production'),
|
||||
release: buildId
|
||||
}
|
||||
},
|
||||
{
|
||||
resolve: 'gatsby-plugin-manifest',
|
||||
options: {
|
||||
name: 'Regexper',
|
||||
short_name: 'Regexper',
|
||||
start_url: '/',
|
||||
background_color: '#6b6659',
|
||||
theme_color: '#bada55',
|
||||
display: 'standalone',
|
||||
icon: 'src/icon.svg'
|
||||
}
|
||||
},
|
||||
'gatsby-plugin-offline'
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
const path = require('path');
|
||||
|
||||
exports.onCreateWebpackConfig = ({ actions }) => {
|
||||
actions.setWebpackConfig({
|
||||
resolve: {
|
||||
modules: [path.resolve(__dirname, 'src'), 'node_modules']
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
|
||||
import i18n from 'i18n';
|
||||
import Layout from 'components/Layout';
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
export const wrapPageElement = ({ element }) => {
|
||||
return <Layout>{ element }</Layout>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
export const wrapRootElement = ({ element }) => {
|
||||
return <I18nextProvider i18n={ i18n }>{ element }</I18nextProvider>;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
const babelOptions = {
|
||||
presets: ['babel-preset-gatsby'],
|
||||
plugins: ['dynamic-import-node']
|
||||
};
|
||||
|
||||
module.exports = require('babel-jest').createTransformer(babelOptions);
|
||||
@@ -0,0 +1,5 @@
|
||||
global.___loader = {
|
||||
enqueue: jest.fn()
|
||||
};
|
||||
|
||||
global.Element.prototype.getBBox = jest.fn();
|
||||
@@ -0,0 +1,7 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
process(src, filename) {
|
||||
return `module.exports = ${ JSON.stringify(path.basename(filename)) };`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
module.exports = {
|
||||
process(src) {
|
||||
return `module.exports = ${ JSON.stringify(yaml.safeLoad(src)) };`;
|
||||
}
|
||||
};
|
||||
+73
-144
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "regexper",
|
||||
"version": "1.0.0",
|
||||
"description": "Regular expression visualization tool using railroad diagrams",
|
||||
"description": "Regular expression visualization tool",
|
||||
"homepage": "http://regexper.com",
|
||||
"author": {
|
||||
"name": "Jeffrey Avallone",
|
||||
@@ -10,39 +10,21 @@
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server --config webpack.dev.js",
|
||||
"start:prod": "run-s build start:http-server",
|
||||
"start:http-server": "http-server -c0 ./build",
|
||||
"build": "cross-env NODE_ENV=production run-s build:webpack:web build:webpack:prerender build:prerender",
|
||||
"build:webpack:web": "webpack --config webpack.prod-web.js",
|
||||
"build:webpack:prerender": "webpack --config webpack.prod-prerender.js",
|
||||
"build:prerender": "node ./script/__build__/prerender.js",
|
||||
"deploy": "node ./script/s3-upload.js deploy.config.js",
|
||||
"test": "run-s test:lint 'test:unit --coverage'",
|
||||
"test:unit": "cross-env NODE_ENV=production jest",
|
||||
"start": "gatsby develop",
|
||||
"build": "gatsby build",
|
||||
"test:lint": "eslint --ignore-path .gitignore .",
|
||||
"test:watch": "yarn test:unit --watch",
|
||||
"test:bundle-analyzer": "cross-env NODE_ENV=production webpack --config webpack.bundle-analyzer.js",
|
||||
"i18n:scrub": "node ./script/i18n-scrub.js",
|
||||
"precommit": "run-s test:lint"
|
||||
"test:unit": "jest --coverage",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "yarn test:lint"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
">1%",
|
||||
"not ie < 11"
|
||||
],
|
||||
"babel": {
|
||||
"presets": [
|
||||
"env",
|
||||
"react"
|
||||
],
|
||||
"plugins": [
|
||||
"transform-runtime",
|
||||
"transform-class-properties",
|
||||
"transform-object-rest-spread",
|
||||
"transform-decorators-legacy",
|
||||
"syntax-dynamic-import"
|
||||
]
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"postcss-import": {},
|
||||
@@ -55,132 +37,79 @@
|
||||
},
|
||||
"jest": {
|
||||
"clearMocks": true,
|
||||
"setupTestFrameworkScriptFile": "<rootDir>/src/setup/jest.js",
|
||||
"snapshotSerializers": [
|
||||
"enzyme-to-json/serializer"
|
||||
],
|
||||
"modulePaths": [
|
||||
"src",
|
||||
"node_modules"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"\\.svg$": "__mocks__/svgMock.js",
|
||||
"\\.css$": "identity-obj-proxy"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.js",
|
||||
"!src/i18n.js",
|
||||
"!src/prerender.js",
|
||||
"!src/setup/service-worker.js",
|
||||
"!src/setup/jest.js",
|
||||
"!src/pages/**/config.js",
|
||||
"!src/pages/**/browser.js"
|
||||
"!src/i18n.js"
|
||||
],
|
||||
"coverageReporters": [
|
||||
"text-summary",
|
||||
"html"
|
||||
],
|
||||
"globals": {
|
||||
"__PATH_PREFIX__": ""
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"\\.css$": "identity-obj-proxy"
|
||||
},
|
||||
"modulePaths": [
|
||||
"src",
|
||||
"node_modules"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"react-testing-library/cleanup-after-each",
|
||||
"<rootDir>/jest/setup.js"
|
||||
],
|
||||
"testPathIgnorePatterns": [
|
||||
"node_modules",
|
||||
".cache"
|
||||
],
|
||||
"transform": {
|
||||
"\\.yaml$": "<rootDir>/jest/yaml.js",
|
||||
"\\.js$": "<rootDir>/jest/preprocess.js",
|
||||
"\\.svg$": "<rootDir>/jest/static-file-transform.js"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(gatsby)/)"
|
||||
],
|
||||
"watchPathIgnorePatterns": [
|
||||
"<rootDir>/coverage",
|
||||
"<rootDir>/public"
|
||||
]
|
||||
},
|
||||
"eslintConfig": {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true,
|
||||
"jest/globals": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"jest"
|
||||
],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
2
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@alienfast/i18next-loader": "^1.0.14",
|
||||
"aws-sdk": "^2.247.1",
|
||||
"babel-core": "^6.26.0",
|
||||
"babel-eslint": "^8.2.1",
|
||||
"babel-jest": "^23.0.1",
|
||||
"babel-loader": "^7.1.2",
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.4",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-preset-env": "^1.6.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"babel-register": "^6.26.0",
|
||||
"cheerio": "^1.0.0-rc.2",
|
||||
"colors": "^1.1.2",
|
||||
"copy-webpack-plugin": "^4.4.1",
|
||||
"cross-env": "^5.1.3",
|
||||
"css-loader": "^0.28.9",
|
||||
"enzyme": "^3.3.0",
|
||||
"enzyme-adapter-react-16": "^1.1.1",
|
||||
"enzyme-to-json": "^3.3.1",
|
||||
"eslint": "^4.17.0",
|
||||
"eslint-plugin-jest": "^21.8.0",
|
||||
"eslint-plugin-react": "^7.6.1",
|
||||
"extract-text-webpack-plugin": "^4.0.0-beta.0",
|
||||
"feather-icons": "^4.5.0",
|
||||
"html-webpack-plugin": "^3.0.0",
|
||||
"i18next": "^11.3.2",
|
||||
"i18next-browser-languagedetector": "^2.1.0",
|
||||
"@babel/core": "^7.2.2",
|
||||
"@ungap/url-search-params": "^0.1.2",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-jest": "^24.5.0",
|
||||
"babel-plugin-dynamic-import-node": "^2.2.0",
|
||||
"babel-preset-gatsby": "^0.1.6",
|
||||
"eslint": "^5.11.1",
|
||||
"eslint-plugin-jest": "^22.1.2",
|
||||
"eslint-plugin-react": "^7.12.1",
|
||||
"firebase-tools": "^6.3.0",
|
||||
"gatsby": "^2.0.81",
|
||||
"gatsby-plugin-google-analytics": "^2.0.8",
|
||||
"gatsby-plugin-manifest": "^2.0.13",
|
||||
"gatsby-plugin-offline": "^2.0.21",
|
||||
"gatsby-plugin-postcss": "^2.0.2",
|
||||
"gatsby-plugin-react-helmet": "^3.0.5",
|
||||
"gatsby-plugin-sentry": "^1.0.0",
|
||||
"husky": "^1.3.1",
|
||||
"i18next": "^15.0.7",
|
||||
"i18next-browser-languagedetector": "^3.0.1",
|
||||
"i18next-xhr-backend": "^2.0.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"immutable": "^3.8.2",
|
||||
"jest": "^23.1.0",
|
||||
"mime-types": "^2.1.18",
|
||||
"npm-run-all": "^4.1.2",
|
||||
"jest": "^24.5.0",
|
||||
"js-yaml": "^3.13.0",
|
||||
"postcss-cssnext": "^3.1.0",
|
||||
"postcss-import": "^11.1.0",
|
||||
"postcss-loader": "^2.1.0",
|
||||
"raven-js": "^3.22.2",
|
||||
"react": "^16.3.0",
|
||||
"react-dom": "^16.3.0",
|
||||
"react-ga": "^2.4.1",
|
||||
"react-i18next": "^7.3.6",
|
||||
"react-test-renderer": "^16.3.0",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
"style-loader": "^0.21.0",
|
||||
"svg-react-loader": "^0.4.5",
|
||||
"uglifyjs-webpack-plugin": "^1.1.8",
|
||||
"url-search-params": "^0.10.0",
|
||||
"webapp-webpack-plugin": "^2.1.0",
|
||||
"webpack": "^4.1.1",
|
||||
"webpack-cli": "^3.0.1",
|
||||
"webpack-merge": "^4.1.1",
|
||||
"webpack-node-externals": "^1.6.0",
|
||||
"workbox-webpack-plugin": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"http-server": "^0.11.1",
|
||||
"husky": "^0.14.3",
|
||||
"js-yaml": "^3.10.0",
|
||||
"webpack-bundle-analyzer": "^2.11.1",
|
||||
"webpack-dev-server": "^3.1.1"
|
||||
"postcss-import": "^12.0.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^16.7.0",
|
||||
"react-dom": "^16.7.0",
|
||||
"react-feather": "^1.1.5",
|
||||
"react-helmet": "^5.2.0",
|
||||
"react-i18next": "^10.5.3",
|
||||
"react-modal": "^3.8.1",
|
||||
"react-testing-library": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# humanstxt.org/
|
||||
# The humans responsible & technology colophon
|
||||
|
||||
# TEAM
|
||||
|
||||
Creator: Jeff Avallone
|
||||
Site: http://gitlab.com/javallone
|
||||
Twitter: @javallone
|
||||
|
||||
# THANKS
|
||||
|
||||
strfriend.com for the idea, whatever happened to you?
|
||||
|
||||
# TECHNOLOGY COLOPHON
|
||||
|
||||
HTML5, CSS3, SVG, React, Feather Icons
|
||||
@@ -1,3 +0,0 @@
|
||||
# robotstxt.org/
|
||||
|
||||
User-agent: *
|
||||
@@ -1,102 +0,0 @@
|
||||
const util = require('util');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const colors = require('colors/safe');
|
||||
|
||||
const readdir = util.promisify(fs.readdir);
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
const writeFile = util.promisify(fs.writeFile);
|
||||
|
||||
const localesDir = path.resolve(__dirname, '../src/locales');
|
||||
|
||||
const loadLocales = async () => {
|
||||
const languages = (await readdir(localesDir)).filter(name => name !== 'index.js');
|
||||
|
||||
let localeData = {};
|
||||
|
||||
await Promise.all(languages.map(async lang => {
|
||||
const langDir = path.resolve(localesDir, lang);
|
||||
const namespaces = (await readdir(langDir))
|
||||
.filter(file => /\.yaml$/.test(file));
|
||||
|
||||
localeData[lang] = {};
|
||||
|
||||
await Promise.all(namespaces.map(async ns => {
|
||||
const nsFile = path.resolve(langDir, ns);
|
||||
localeData[lang][ns.replace('.yaml', '')] = yaml.safeLoad(await readFile(nsFile));
|
||||
}));
|
||||
}));
|
||||
|
||||
return localeData;
|
||||
};
|
||||
|
||||
const saveLocales = async locales => {
|
||||
await Promise.all(Object.keys(locales).map(async langName => {
|
||||
const lang = locales[langName];
|
||||
|
||||
await Promise.all(Object.keys(lang).map(async nsName => {
|
||||
const nsFile = path.resolve(localesDir, langName, `${ nsName }.yaml`);
|
||||
const yamlDump = yaml.safeDump(lang[nsName], {
|
||||
sortKeys: true
|
||||
});
|
||||
await writeFile(nsFile, yamlDump);
|
||||
}));
|
||||
}));
|
||||
};
|
||||
|
||||
loadLocales()
|
||||
.then(async locales => {
|
||||
const sourceLocale = locales.en.translation;
|
||||
const requiredKeys = Object.keys(sourceLocale);
|
||||
const languages = Object.keys(locales).filter(lang => lang !== 'en');
|
||||
|
||||
languages.forEach(langName => {
|
||||
const lang = locales[langName];
|
||||
const presentKeys = Object.keys(lang).filter(nsName => nsName !== 'missing').reduce((list, nsName) => {
|
||||
return list.concat(Object.keys(lang[nsName]));
|
||||
}, []);
|
||||
const missingKeys = requiredKeys.filter(key => !presentKeys.includes(key));
|
||||
const extraKeys = presentKeys.filter(key => !requiredKeys.includes(key));
|
||||
|
||||
if (!lang.translation) {
|
||||
lang.translation = {};
|
||||
}
|
||||
|
||||
if (!lang.missing) {
|
||||
lang.missing = {};
|
||||
}
|
||||
|
||||
missingKeys.forEach(key => {
|
||||
if (lang.missing[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(colors.yellow.bold('MISSING:'), `${ langName } needs value for "${ colors.bold(key) }".`); //eslint-disable-line no-console
|
||||
lang.missing[key] = sourceLocale[key];
|
||||
});
|
||||
|
||||
presentKeys.forEach(key => {
|
||||
if (!lang.missing[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(colors.yellow.bold('DEFINED:'), `Removing "${ colors.bold(key) }" from ${ langName}.missing. It is defined elsewhere.`); // eslint-disable-line no-console
|
||||
delete lang.missing[key];
|
||||
});
|
||||
|
||||
extraKeys.forEach(key => {
|
||||
console.log(colors.yellow.bold('EXTRA:'), `${ langName } has extra key for "${ colors.bold(key) }". It should be removed.`); // eslint-disable-line no-console
|
||||
});
|
||||
});
|
||||
|
||||
return locales;
|
||||
})
|
||||
.then(saveLocales)
|
||||
.then(() => {
|
||||
console.log('Done updating locales'); // eslint-disable-line no-console
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(colors.red.bold('FAILED:'), e); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
// NOTE: This script *MUST* be built with webpack since it requires React
|
||||
// components. The script is built and run as part of `yarn build`
|
||||
|
||||
import React from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import fs from 'fs';
|
||||
import util from 'util';
|
||||
import cheerio from 'cheerio';
|
||||
import colors from 'colors/safe';
|
||||
|
||||
import 'i18n';
|
||||
|
||||
const readdir = util.promisify(fs.readdir);
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
const writeFile = util.promisify(fs.writeFile);
|
||||
|
||||
readdir('./src/pages')
|
||||
.then(pages => (
|
||||
Promise.all(pages.map(async page => {
|
||||
const Component = (await import(`pages/${ page }/Component`)).default;
|
||||
const pagePath = `./build/${ page }.html`;
|
||||
|
||||
const markup = cheerio.load(await readFile(pagePath));
|
||||
|
||||
markup('#root').html(renderToString(<Component/>));
|
||||
await writeFile(pagePath, markup.html());
|
||||
|
||||
console.log(colors.green.bold('PRERENDERED:'), `${ page }.html`); // eslint-disable-line no-console
|
||||
}))
|
||||
))
|
||||
.then(() => console.log('Done prerendering')) // eslint-disable-line no-console
|
||||
.catch(e => {
|
||||
console.error(colors.red.bold('FAIL:'), e); // eslint-disable-line no-console
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const colors = require('colors/safe');
|
||||
const readdir = require('recursive-readdir');
|
||||
const AWS = require('aws-sdk');
|
||||
const mime = require('mime-types');
|
||||
|
||||
const s3 = new AWS.S3();
|
||||
const cloudFront = new AWS.CloudFront();
|
||||
const config = require(path.resolve(process.argv[2]));
|
||||
|
||||
const configFor = path => {
|
||||
const { match, ...conf } = config.paths.find(conf => conf.match.test(path)); // eslint-disable-line no-unused-vars
|
||||
return {
|
||||
ContentType: mime.lookup(path) || 'application/octet-stream',
|
||||
...conf
|
||||
};
|
||||
};
|
||||
|
||||
const bucketContents = s3.listObjectsV2({
|
||||
Bucket: config.s3Bucket
|
||||
}).promise()
|
||||
.then(result => {
|
||||
return result.Contents.map(item => item.Key);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(colors.red.bold('Failed to fetch bucket contents:'), err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const uploadDetails = readdir(config.deployFrom)
|
||||
.then(paths => paths.map(p => {
|
||||
const key = path.relative(config.deployFrom, p);
|
||||
return {
|
||||
Key: key,
|
||||
Body: fs.createReadStream(p),
|
||||
...configFor(key)
|
||||
};
|
||||
}))
|
||||
.catch(err => {
|
||||
console.error(colors.red.bold('Error:'), err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Promise.all([bucketContents, uploadDetails]).then(([bucket, upload]) => {
|
||||
const deleteKeys = bucket.filter(key => !upload.find(conf => key === conf.Key));
|
||||
|
||||
const uploadPromises = upload.map(params => {
|
||||
console.log(`Starting upload for ${ params.Key }`);
|
||||
return s3.upload({
|
||||
Bucket: config.s3Bucket,
|
||||
...params
|
||||
}).promise()
|
||||
.then(() => console.log(colors.green(`${ params.Key } successful`)))
|
||||
.catch(err => {
|
||||
console.error(colors.red.bold(`${ params.Key } failed`));
|
||||
return Promise.reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(uploadPromises)
|
||||
.then(() => {
|
||||
if (deleteKeys.length === 0) {
|
||||
console.log('No files to delete');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
console.log(`Deleting ${ deleteKeys.length } stale files`);
|
||||
return s3.deleteObjects({
|
||||
Bucket: config.s3Bucket,
|
||||
Delete: {
|
||||
Objects: deleteKeys.map(key => ({ Key: key }))
|
||||
}
|
||||
}).promise()
|
||||
.then(() => console.log(colors.green('Delete successful')))
|
||||
.catch(err => {
|
||||
console.error(colors.red.bold('Delete failed'));
|
||||
return Promise.reject(err);
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return cloudFront.createInvalidation({
|
||||
DistributionId: config.cloudFrontId,
|
||||
InvalidationBatch: {
|
||||
CallerReference: `deploy-${ process.env.CI_COMMIT_REF_SLUG }-${ process.env.CI_COMMIT_SHA }`,
|
||||
Paths: {
|
||||
Quantity: 1,
|
||||
Items: [
|
||||
'/*'
|
||||
]
|
||||
}
|
||||
}
|
||||
}).promise();
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(colors.red.bold('Error:'), err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import reflowable from 'components/SVG/reflowable';
|
||||
|
||||
@reflowable
|
||||
class SVGElement extends React.PureComponent {
|
||||
reflow() {
|
||||
return this.setBBox(this.props.bbox);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <text>Mock content</text>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SVGElement;
|
||||
@@ -0,0 +1,19 @@
|
||||
const React = require('react');
|
||||
|
||||
const buildMock = component => {
|
||||
const componentName = component.displayName || component.name || 'Component';
|
||||
const Mock = ({ children, ...props }) => (
|
||||
<span
|
||||
data-component={ componentName }
|
||||
data-props={ JSON.stringify(props, null, ' ') }>{ children }</span>
|
||||
);
|
||||
Mock.propTypes = component.propTypes;
|
||||
return Mock;
|
||||
};
|
||||
|
||||
module.exports = path => {
|
||||
const actual = jest.requireActual(path);
|
||||
return buildMock(actual.default || actual);
|
||||
};
|
||||
|
||||
module.exports.buildMock = buildMock;
|
||||
@@ -0,0 +1,14 @@
|
||||
const React = require('react');
|
||||
const gatsby = jest.requireActual('gatsby');
|
||||
|
||||
module.exports = {
|
||||
...gatsby,
|
||||
graphql: jest.fn().mockImplementation(([query]) => query),
|
||||
Link: jest.fn().mockImplementation(({ to, ...rest }) =>
|
||||
React.createElement('a', {
|
||||
...rest,
|
||||
href: to
|
||||
})
|
||||
),
|
||||
StaticQuery: jest.fn()
|
||||
};
|
||||
+13
-18
@@ -1,20 +1,15 @@
|
||||
import React from 'react';
|
||||
import i18n from 'i18next';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
const i18n = jest.requireActual('i18n');
|
||||
|
||||
const translate = txt => `translate(${ txt })`;
|
||||
// Load empty resource bundle to reduce logging output
|
||||
i18n.default.addResourceBundle('dev', 'translation', {});
|
||||
i18n.default.addResourceBundle('en', 'translation', {});
|
||||
i18n.default.addResourceBundle('other', 'translation', {});
|
||||
|
||||
i18n.init({
|
||||
fallbackLng: 'en',
|
||||
fallbackNS: 'missing',
|
||||
debug: false,
|
||||
resources: {}
|
||||
});
|
||||
|
||||
const I18nWrapper = ({ children }) => ( // eslint-disable-line react/prop-types
|
||||
<I18nextProvider i18n={ i18n }>
|
||||
{ React.cloneElement(React.Children.only(children), { t: translate }) }
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
export { translate, i18n, I18nWrapper };
|
||||
module.exports = {
|
||||
...i18n,
|
||||
locales: [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'other', name: 'Other' }
|
||||
],
|
||||
mockT: str => `TRANSLATE(${ str })`
|
||||
};
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
const reactI18next = jest.requireActual('react-i18next');
|
||||
const i18n = require('i18n');
|
||||
|
||||
module.exports = {
|
||||
...reactI18next,
|
||||
Trans: require('__mocks__/component-mock').buildMock(reactI18next.Trans),
|
||||
useTranslation: () => ({ i18n, t: i18n.mockT })
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const SvgMock = () => <svg></svg>;
|
||||
|
||||
export default SvgMock;
|
||||
@@ -0,0 +1,87 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`layout running against a node with a basic bounding box 1`] = `
|
||||
Object {
|
||||
"box": Object {
|
||||
"axisX1": 0,
|
||||
"axisX2": 20,
|
||||
"axisY": 5,
|
||||
"height": 10,
|
||||
"width": 20,
|
||||
},
|
||||
"props": Object {},
|
||||
"type": "Example",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`layout running against a node with a complete bounding box 1`] = `
|
||||
Object {
|
||||
"box": Object {
|
||||
"axisX1": 5,
|
||||
"axisX2": 15,
|
||||
"axisY": 2,
|
||||
"height": 10,
|
||||
"width": 20,
|
||||
},
|
||||
"props": Object {},
|
||||
"type": "Example",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`layout running against a node with props 1`] = `
|
||||
Object {
|
||||
"box": Object {
|
||||
"axisX1": 0,
|
||||
"axisX2": 0,
|
||||
"axisY": 0,
|
||||
"height": 0,
|
||||
"width": 0,
|
||||
},
|
||||
"props": Object {
|
||||
"property": "example",
|
||||
},
|
||||
"type": "Example",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`layout running against a simple node 1`] = `
|
||||
Object {
|
||||
"box": Object {
|
||||
"axisX1": 0,
|
||||
"axisX2": 0,
|
||||
"axisY": 0,
|
||||
"height": 0,
|
||||
"width": 0,
|
||||
},
|
||||
"props": Object {},
|
||||
"type": "Example",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`layout running layout on children 1`] = `
|
||||
Object {
|
||||
"box": Object {
|
||||
"axisX1": 0,
|
||||
"axisX2": 0,
|
||||
"axisY": 0,
|
||||
"height": 0,
|
||||
"width": 0,
|
||||
},
|
||||
"children": Array [
|
||||
Object {
|
||||
"box": Object {
|
||||
"axisX1": 0,
|
||||
"axisX2": 20,
|
||||
"axisY": 5,
|
||||
"height": 10,
|
||||
"width": 20,
|
||||
},
|
||||
"props": Object {},
|
||||
"type": "Other",
|
||||
},
|
||||
"string example",
|
||||
],
|
||||
"props": Object {},
|
||||
"type": "Example",
|
||||
}
|
||||
`;
|
||||
@@ -1,17 +1,255 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`App rendering 1`] = `
|
||||
<React.Fragment>
|
||||
<Translate(Form)
|
||||
downloadUrls={Array []}
|
||||
key="expr=undefined&syntax=undefined"
|
||||
onSubmit={[Function]}
|
||||
syntaxes={
|
||||
Object {
|
||||
"js": "JavaScript",
|
||||
"pcre": "PCRE",
|
||||
}
|
||||
exports[`App removing rendered expression 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"js\\",
|
||||
\\"expr\\": \\"test expression\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="FormActions"
|
||||
data-props="{}"
|
||||
/>
|
||||
</React.Fragment>
|
||||
</span>
|
||||
<span
|
||||
data-component="Render"
|
||||
data-props="{
|
||||
\\"data\\": \\"LAYOUT(PARSED(test expression))\\"
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App removing rendered expression 2`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"js\\",
|
||||
\\"expr\\": \\"\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App rendering 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"js\\",
|
||||
\\"expr\\": \\"\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App rendering an expression 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"js\\",
|
||||
\\"expr\\": \\"\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App rendering an expression 2`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"js\\",
|
||||
\\"expr\\": \\"test expression\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
<span
|
||||
data-component="Loader"
|
||||
data-props="{}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App rendering an expression 3`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"js\\",
|
||||
\\"expr\\": \\"test expression\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="FormActions"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
data-component="Render"
|
||||
data-props="{
|
||||
\\"data\\": \\"LAYOUT(PARSED(test expression))\\"
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App rendering with an invalid syntax 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"invalid\\",
|
||||
\\"expr\\": \\"\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App rendering with an invalid syntax 2`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"invalid\\",
|
||||
\\"expr\\": \\"test expression\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
<span
|
||||
data-component="Loader"
|
||||
data-props="{}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`App rendering with an invalid syntax 3`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Form"
|
||||
data-props="{
|
||||
\\"syntax\\": \\"invalid\\",
|
||||
\\"expr\\": \\"test expression\\",
|
||||
\\"syntaxList\\": [
|
||||
{
|
||||
\\"id\\": \\"testJS\\",
|
||||
\\"label\\": \\"Testing JS\\"
|
||||
},
|
||||
{
|
||||
\\"id\\": \\"other\\",
|
||||
\\"label\\": \\"Other\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
<span
|
||||
data-component="Message"
|
||||
data-props="{
|
||||
\\"type\\": \\"error\\",
|
||||
\\"heading\\": \\"TRANSLATE(Render Failure)\\"
|
||||
}"
|
||||
>
|
||||
<p>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
An error occurred while rendering the regular expression.
|
||||
</span>
|
||||
</p>
|
||||
<a
|
||||
href="#retry"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Retry
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
+115
-181
@@ -1,231 +1,165 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { translate } from 'react-i18next';
|
||||
import URLSearchParams from 'url-search-params';
|
||||
import Raven from 'raven-js';
|
||||
|
||||
import LoaderIcon from 'feather-icons/dist/icons/loader.svg';
|
||||
|
||||
import style from './style.css';
|
||||
import { withTranslation, Trans } from 'react-i18next';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import URLSearchParams from '@ungap/url-search-params';
|
||||
|
||||
import Form from 'components/Form';
|
||||
import FormActions from 'components/FormActions';
|
||||
import Loader from 'components/Loader';
|
||||
import Message from 'components/Message';
|
||||
import InstallPrompt from 'components/InstallPrompt';
|
||||
import { demoImage } from 'devel';
|
||||
|
||||
const syntaxes = {
|
||||
js: 'JavaScript',
|
||||
pcre: 'PCRE'
|
||||
};
|
||||
|
||||
const toUrl = params => new URLSearchParams(params).toString();
|
||||
|
||||
class App extends React.PureComponent {
|
||||
state = {}
|
||||
static propTypes = {
|
||||
syntax: PropTypes.string.isRequired,
|
||||
expr: PropTypes.string.isRequired,
|
||||
permalinkUrl: PropTypes.string,
|
||||
syntaxList: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string
|
||||
})),
|
||||
t: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
image = React.createRef()
|
||||
state = {
|
||||
loading: false,
|
||||
loadingError: null,
|
||||
render: {}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('hashchange', this.handleHashChange);
|
||||
window.addEventListener('beforeinstallprompt', this.handleInstallPrompt);
|
||||
this.handleHashChange();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('hashchange', this.handleHashChange);
|
||||
window.removeEventListener('beforeinstallprompt', this.handleInstallPrompt);
|
||||
}
|
||||
|
||||
async setSvgUrl() {
|
||||
try {
|
||||
const type = 'image/svg+xml';
|
||||
const blob = await this.image.current.svgUrl(type);
|
||||
|
||||
this.setState({
|
||||
svgUrl: {
|
||||
url: URL.createObjectURL(blob),
|
||||
label: 'Download SVG',
|
||||
filename: 'image.svg',
|
||||
type
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
if (this.props.expr) {
|
||||
this.handleRender();
|
||||
}
|
||||
}
|
||||
|
||||
async setPngUrl() {
|
||||
try {
|
||||
const type = 'image/png';
|
||||
const blob = await this.image.current.pngUrl(type);
|
||||
componentDidUpdate(prevProps) {
|
||||
const { syntax, expr } = this.props;
|
||||
|
||||
this.setState({
|
||||
pngUrl: {
|
||||
url: URL.createObjectURL(blob),
|
||||
label: 'Download PNG',
|
||||
filename: 'image.png',
|
||||
type
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
if (syntax !== prevProps.syntax || expr !== prevProps.expr) {
|
||||
this.handleRender();
|
||||
}
|
||||
}
|
||||
|
||||
async loadSVGComponent() {
|
||||
if (this.state.SVG) {
|
||||
return;
|
||||
handleSubmit = ({ syntax, expr }) => {
|
||||
if (expr) {
|
||||
document.location.hash = new URLSearchParams({
|
||||
syntax,
|
||||
expr
|
||||
}).toString();
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
loadingFailed: false
|
||||
});
|
||||
handleRender = async () => {
|
||||
const { syntax, expr } = this.props;
|
||||
|
||||
try {
|
||||
const SVG = await import(/* webpackChunkName: "render" */ 'components/SVG');
|
||||
|
||||
this.setState({
|
||||
SVG: SVG.default,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
Raven.captureException(e);
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadingFailed: e
|
||||
loadingError: null,
|
||||
render: {}
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
handleInstallPrompt = event => {
|
||||
event.preventDefault();
|
||||
|
||||
this.setState({
|
||||
installPrompt: event
|
||||
});
|
||||
}
|
||||
|
||||
handleSubmit = ({expr, syntax}) => {
|
||||
if (expr) {
|
||||
document.location.hash = toUrl({ syntax, expr });
|
||||
}
|
||||
}
|
||||
|
||||
handleHashChange = async () => {
|
||||
const query = document.location.hash.slice(1);
|
||||
const params = new URLSearchParams(query);
|
||||
const { expr, syntax } = (() => {
|
||||
if (params.get('syntax')) {
|
||||
return {
|
||||
syntax: params.get('syntax'),
|
||||
expr: params.get('expr')
|
||||
};
|
||||
} else {
|
||||
// Assuming old-style URL
|
||||
return {
|
||||
syntax: 'js',
|
||||
expr: query
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
if (!expr) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadSVGComponent();
|
||||
console.log(syntax, expr); // eslint-disable-line no-console
|
||||
this.setState({
|
||||
image: demoImage,
|
||||
permalinkUrl: document.location.toString(),
|
||||
loading: true
|
||||
});
|
||||
|
||||
try {
|
||||
const syntaxModule = await import(
|
||||
/* webpackChunkName: "render-[index]" */
|
||||
`syntax/${ syntax }`
|
||||
);
|
||||
|
||||
const exprData = syntaxModule.layout(syntaxModule.parse(expr));
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
render: {
|
||||
syntax,
|
||||
expr
|
||||
}, async () => {
|
||||
await this.image.current.doReflow();
|
||||
this.setSvgUrl();
|
||||
this.setPngUrl();
|
||||
exprData,
|
||||
Component: syntaxModule.Render
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
Sentry.withScope(scope => {
|
||||
scope.setExtra('syntax', syntax);
|
||||
Sentry.captureException(e);
|
||||
});
|
||||
this.setState({
|
||||
loading: false,
|
||||
loadingError: e
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = async event => {
|
||||
handleRetry = event => {
|
||||
event.preventDefault();
|
||||
this.handleHashChange();
|
||||
this.handleRender();
|
||||
}
|
||||
|
||||
handleInstallReject = () => {
|
||||
this.setState({ installPrompt: null });
|
||||
}
|
||||
|
||||
handleInstallAccept = async () => {
|
||||
const { installPrompt } = this.state;
|
||||
|
||||
this.setState({ installPrompt: null });
|
||||
installPrompt.prompt();
|
||||
}
|
||||
handleSvg = imageDetails => this.setState({ imageDetails });
|
||||
|
||||
render() {
|
||||
const {
|
||||
SVG,
|
||||
loading,
|
||||
loadingFailed,
|
||||
svgUrl,
|
||||
pngUrl,
|
||||
permalinkUrl,
|
||||
syntax,
|
||||
expr,
|
||||
image,
|
||||
installPrompt
|
||||
permalinkUrl,
|
||||
syntaxList,
|
||||
t
|
||||
} = this.props;
|
||||
const {
|
||||
loading,
|
||||
loadingError,
|
||||
imageDetails,
|
||||
render: {
|
||||
syntax: renderSyntax,
|
||||
exprData,
|
||||
Component
|
||||
}
|
||||
} = this.state;
|
||||
const downloadUrls = [
|
||||
svgUrl,
|
||||
pngUrl
|
||||
].filter(Boolean);
|
||||
|
||||
return <React.Fragment>
|
||||
<Form
|
||||
key={ toUrl({ expr, syntax }) }
|
||||
syntaxes={ syntaxes }
|
||||
downloadUrls={ downloadUrls }
|
||||
permalinkUrl={ permalinkUrl }
|
||||
syntax={ syntax }
|
||||
expr={ expr }
|
||||
onSubmit={ this.handleSubmit }/>
|
||||
{
|
||||
loading && <div className={ style.loader }>
|
||||
<LoaderIcon />
|
||||
<div className={ style.message }>Loading...</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
loadingFailed && <Message type="error" heading="Render Failure">
|
||||
An error occurred while rendering the regular expression. <a href="#retry" onClick={ this.handleRetry }>Retry</a>
|
||||
</Message>
|
||||
}
|
||||
{
|
||||
image && <div className={ style.render }>
|
||||
<SVG data={ image } ref={ this.image }/>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
installPrompt && <InstallPrompt onAccept={ this.handleInstallAccept } onReject={ this.handleInstallReject } />
|
||||
}
|
||||
</React.Fragment>;
|
||||
|
||||
const formProps = {
|
||||
onSubmit: this.handleSubmit,
|
||||
syntax,
|
||||
expr,
|
||||
syntaxList
|
||||
};
|
||||
const actionProps = {
|
||||
imageDetails,
|
||||
permalinkUrl
|
||||
};
|
||||
const renderProps = {
|
||||
onRender: this.handleSvg,
|
||||
data: exprData
|
||||
};
|
||||
|
||||
const doRender = renderSyntax === syntax;
|
||||
|
||||
return <>
|
||||
<Form { ...formProps }>
|
||||
{ doRender && <FormActions { ...actionProps } /> }
|
||||
</Form>
|
||||
|
||||
{ loading && <Loader /> }
|
||||
|
||||
{ loadingError && <Message type="error" heading={ t('Render Failure') }>
|
||||
<p><Trans>
|
||||
An error occurred while rendering the regular expression.
|
||||
</Trans></p>
|
||||
<a href="#retry" onClick={ this.handleRetry }><Trans>Retry</Trans></a>
|
||||
</Message> }
|
||||
|
||||
{ doRender && <Component { ...renderProps } /> }
|
||||
</>;
|
||||
}
|
||||
}
|
||||
|
||||
App.propTypes = {
|
||||
t: PropTypes.func
|
||||
};
|
||||
|
||||
export default translate()(App);
|
||||
export { App };
|
||||
export default withTranslation()(App);
|
||||
|
||||
@@ -1,16 +1,90 @@
|
||||
jest.mock('components/SVG');
|
||||
jest.mock('components/Form', () =>
|
||||
require('__mocks__/component-mock')('components/Form'));
|
||||
jest.mock('components/FormActions', () =>
|
||||
require('__mocks__/component-mock')('components/FormActions'));
|
||||
jest.mock('components/Loader', () =>
|
||||
require('__mocks__/component-mock')('components/Loader'));
|
||||
jest.mock('components/Message', () =>
|
||||
require('__mocks__/component-mock')('components/Message'));
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import { mockT } from 'i18n';
|
||||
import { App } from 'components/App';
|
||||
import { translate } from '__mocks__/i18n';
|
||||
|
||||
jest.mock('syntax/js', () => ({
|
||||
parse: expr => `PARSED(${ expr })`,
|
||||
layout: parsed => `LAYOUT(${ parsed })`,
|
||||
Render: require('__mocks__/component-mock').buildMock(function Render() {})
|
||||
}));
|
||||
|
||||
const syntaxList = [
|
||||
{ id: 'testJS', label: 'Testing JS' },
|
||||
{ id: 'other', label: 'Other' }
|
||||
];
|
||||
const commonProps = { syntaxList, t: mockT };
|
||||
|
||||
describe('App', () => {
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<App t={ translate }/>
|
||||
const { asFragment } = render(
|
||||
<App expr="" syntax="js" { ...commonProps } />
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering an expression', async () => {
|
||||
const { asFragment, rerender } = render(
|
||||
<App expr="" syntax="js" { ...commonProps } />
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
rerender(
|
||||
<App expr="test expression" syntax="js" { ...commonProps } />
|
||||
);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
// Give a beat for module to load
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with an invalid syntax', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { asFragment, rerender } = render(
|
||||
<App expr="" syntax="invalid" { ...commonProps } />
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
rerender(
|
||||
<App expr="test expression" syntax="invalid" { ...commonProps } />
|
||||
);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
// Give a beat for module to load
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('removing rendered expression', async () => {
|
||||
const { asFragment, rerender } = render(
|
||||
<App expr="test expression" syntax="js" { ...commonProps } />
|
||||
);
|
||||
|
||||
// Give a beat for module to load
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
rerender(
|
||||
<App expr="" syntax="js" { ...commonProps } />
|
||||
);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Footer rendering 1`] = `
|
||||
<footer>
|
||||
<ul>
|
||||
<DocumentFragment>
|
||||
<footer
|
||||
class="footer"
|
||||
>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<Trans>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Created by
|
||||
<a
|
||||
href="mailto:jeff.avallone@gmail.com"
|
||||
>
|
||||
Jeff Avallone
|
||||
</a>
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<Trans
|
||||
i18nKey="Generated images licensed"
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Generated images licensed:
|
||||
<a
|
||||
@@ -24,17 +33,18 @@ exports[`Footer rendering 1`] = `
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Creative Commons CC-BY-3.0 License"
|
||||
src="https://licensebuttons.net/l/by/3.0/80x15.png"
|
||||
alt="TRANSLATE(Creative Commons CC-BY-3.0 License)"
|
||||
src="cc-by.svg"
|
||||
/>
|
||||
</a>
|
||||
</Trans>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
className="buildId"
|
||||
class="buildId"
|
||||
>
|
||||
example build id
|
||||
abc-123
|
||||
</div>
|
||||
</footer>
|
||||
</footer>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://web.resource.org/cc/"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="80"
|
||||
height="15"
|
||||
id="svg2279"
|
||||
sodipodi:version="0.32"
|
||||
inkscape:version="0.45+devel"
|
||||
version="1.0"
|
||||
sodipodi:docname="by.svg"
|
||||
inkscape:output_extension="org.inkscape.output.svg.inkscape">
|
||||
<defs
|
||||
id="defs2281">
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath3442">
|
||||
<rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.92243534;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect3444"
|
||||
width="20.614058"
|
||||
height="12.483703"
|
||||
x="171.99832"
|
||||
y="239.1203" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#999999"
|
||||
borderopacity="1"
|
||||
gridtolerance="10000"
|
||||
guidetolerance="10"
|
||||
objecttolerance="10"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="10.5125"
|
||||
inkscape:cx="40"
|
||||
inkscape:cy="7.5"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
width="80px"
|
||||
height="15px"
|
||||
showborder="true"
|
||||
inkscape:showpageshadow="false"
|
||||
inkscape:window-width="935"
|
||||
inkscape:window-height="624"
|
||||
inkscape:window-x="50"
|
||||
inkscape:window-y="160" />
|
||||
<metadata
|
||||
id="metadata2284">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<g
|
||||
id="BY"
|
||||
transform="matrix(0.9875019,0,0,0.9333518,-323.90064,-271.87688)">
|
||||
<g
|
||||
transform="translate(158,54)"
|
||||
id="g3693">
|
||||
<rect
|
||||
y="237.86218"
|
||||
x="170.5"
|
||||
height="15"
|
||||
width="80"
|
||||
id="rect3695"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.04161763;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.92243534;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect3697"
|
||||
width="77"
|
||||
height="12"
|
||||
x="172"
|
||||
y="239.36218" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccscc"
|
||||
id="path3699"
|
||||
d="M 171.99996,239.37505 L 171.99996,251.37505 L 192.33474,251.37505 C 193.64339,249.62474 194.52652,247.59057 194.52652,245.37505 C 194.52652,243.17431 193.65859,241.1179 192.36599,239.37505 L 171.99996,239.37505 z"
|
||||
style="fill:#abb1aa;fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.46913578" />
|
||||
<g
|
||||
clip-path="url(#clipPath3442)"
|
||||
transform="matrix(0.9612533,0,0,0.9612533,6.8341566,9.5069994)"
|
||||
id="g3701">
|
||||
<path
|
||||
style="opacity:1;fill:#ffffff"
|
||||
d="M 190.06417,245.36206 C 190.06667,249.25405 186.91326,252.41072 183.02153,252.41323 C 179.12979,252.41572 175.97262,249.26256 175.97036,245.3706 C 175.97036,245.36783 175.97036,245.36507 175.97036,245.36206 C 175.9681,241.47007 179.12126,238.3134 183.013,238.31113 C 186.90524,238.30864 190.06191,241.46181 190.06417,245.3538 C 190.06417,245.35628 190.06417,245.35929 190.06417,245.36206 z"
|
||||
rx="22.939548"
|
||||
type="arc"
|
||||
cy="264.3577"
|
||||
ry="22.939548"
|
||||
cx="296.35416"
|
||||
id="path3703" />
|
||||
<path
|
||||
style="opacity:1"
|
||||
id="path3705"
|
||||
d="M 188.74576,239.62226 C 190.30843,241.18492 191.08988,243.09869 191.08988,245.36206 C 191.08988,247.62592 190.32197,249.51913 188.78615,251.04165 C 187.15627,252.64521 185.22995,253.44672 183.00722,253.44672 C 180.81132,253.44672 178.91837,252.65172 177.32887,251.06174 C 175.73912,249.47198 174.94436,247.57226 174.94436,245.36206 C 174.94436,243.15235 175.73912,241.23908 177.32887,239.62226 C 178.87799,238.0591 180.77094,237.27764 183.00722,237.27764 C 185.2706,237.27764 187.18312,238.05909 188.74576,239.62226 z M 178.38093,240.67355 C 177.05978,242.008 176.39945,243.57116 176.39945,245.36429 C 176.39945,247.15694 177.05326,248.70682 178.36062,250.01393 C 179.66822,251.32153 181.22487,251.97509 183.03105,251.97509 C 184.83724,251.97509 186.40716,251.31502 187.74161,249.99412 C 189.0086,248.76725 189.64234,247.22467 189.64234,245.36429 C 189.64234,243.51799 188.99831,241.95084 187.71101,240.66354 C 186.42396,239.37649 184.86406,238.7327 183.03105,238.7327 C 181.19804,238.73271 179.64767,239.37975 178.38093,240.67355 z M 181.85761,244.57559 C 181.65573,244.13545 181.35354,243.91525 180.95051,243.91525 C 180.23802,243.91525 179.8819,244.39501 179.8819,245.35404 C 179.8819,246.31328 180.23802,246.79255 180.95051,246.79255 C 181.421,246.79255 181.75705,246.55908 181.95869,246.09111 L 182.94629,246.61701 C 182.47555,247.45339 181.76934,247.87168 180.82763,247.87168 C 180.10136,247.87168 179.51953,247.64899 179.08265,247.20409 C 178.64502,246.7587 178.42684,246.14477 178.42684,245.36206 C 178.42684,244.59313 178.65204,243.98271 179.10271,243.53056 C 179.55338,243.07838 180.11463,242.8524 180.7875,242.8524 C 181.78288,242.8524 182.49561,243.24465 182.92647,244.02835 L 181.85761,244.57559 z M 186.50398,244.57559 C 186.30184,244.13545 186.00567,243.91525 185.61517,243.91525 C 184.88839,243.91525 184.52474,244.39501 184.52474,245.35404 C 184.52474,246.31328 184.88839,246.79255 185.61517,246.79255 C 186.08642,246.79255 186.41644,246.55908 186.6048,246.09111 L 187.61447,246.61701 C 187.14448,247.45339 186.43926,247.87168 185.49931,247.87168 C 184.77403,247.87168 184.19346,247.64899 183.75683,247.20409 C 183.32096,246.7587 183.10254,246.14477 183.10254,245.36206 C 183.10254,244.59313 183.32422,243.98271 183.76737,243.53056 C 184.21026,243.07838 184.77404,242.8524 185.4592,242.8524 C 186.45282,242.8524 187.16455,243.24465 187.5939,244.02835 L 186.50398,244.57559 z" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
id="text3707"
|
||||
d="M 357.4197,298.68502 C 357.66518,298.68503 357.85131,298.63145 357.9781,298.52427 C 358.10488,298.41711 358.16827,298.25904 358.16828,298.05007 C 358.16827,297.84377 358.10488,297.68704 357.9781,297.57987 C 357.85131,297.47003 357.66518,297.41511 357.4197,297.4151 L 356.55784,297.4151 L 356.55784,298.68502 L 357.4197,298.68502 M 357.4723,301.30928 C 357.78522,301.30928 358.0199,301.24363 358.17637,301.11235 C 358.33552,300.98108 358.4151,300.78282 358.4151,300.51758 C 358.4151,300.2577 358.33686,300.06346 358.18041,299.93486 C 358.02396,299.80358 357.78792,299.73795 357.4723,299.73794 L 356.55784,299.73794 L 356.55784,301.30928 L 357.4723,301.30928 M 358.92089,299.15121 C 359.25538,299.24766 359.51434,299.42582 359.69779,299.6857 C 359.88121,299.94558 359.97293,300.26439 359.97294,300.64216 C 359.97293,301.22086 359.776,301.6522 359.38217,301.9362 C 358.98833,302.22019 358.38947,302.36218 357.5856,302.36218 L 355.00001,302.36218 L 355.00001,296.36218 L 357.33878,296.36218 C 358.17771,296.36219 358.78466,296.48811 359.15962,296.73995 C 359.53727,296.9918 359.7261,297.39501 359.7261,297.94959 C 359.7261,298.24163 359.65732,298.49079 359.51975,298.69708 C 359.38217,298.9007 359.18255,299.05208 358.92089,299.15121 M 359.83746,296.36218 L 361.54096,296.36218 L 362.91671,298.50016 L 364.29245,296.36218 L 366,296.36218 L 363.69764,299.83439 L 363.69764,302.36218 L 362.13982,302.36218 L 362.13982,299.83439 L 359.83746,296.36218"
|
||||
style="font-size:8.25858784px;font-style:normal;font-weight:bold;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:'Bitstream Vera Sans'" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -1,27 +1,38 @@
|
||||
import React from 'react';
|
||||
import { translate, Trans } from 'react-i18next';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import style from './style.css';
|
||||
import ccLogo from './cc-by.svg';
|
||||
|
||||
const Footer = () => (
|
||||
<footer>
|
||||
<ul>
|
||||
import style from './style.module.css';
|
||||
|
||||
export const Footer = ({ buildId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <footer className={ style.footer }>
|
||||
<ul className={ style.list }>
|
||||
<li>
|
||||
<Trans>Created by <a href="mailto:jeff.avallone@gmail.com">Jeff Avallone</a></Trans>
|
||||
<Trans>Created by <a
|
||||
href="mailto:jeff.avallone@gmail.com">Jeff Avallone</a></Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans i18nKey="Generated images licensed">
|
||||
Generated images licensed: <a rel="license external noopener noreferrer" target="_blank" href="http://creativecommons.org/licenses/by/3.0/">
|
||||
<img
|
||||
alt="Creative Commons CC-BY-3.0 License"
|
||||
src="https://licensebuttons.net/l/by/3.0/80x15.png" />
|
||||
</a>
|
||||
</Trans>
|
||||
<Trans>Generated images licensed: <a
|
||||
href="http://creativecommons.org/licenses/by/3.0/"
|
||||
rel="license external noopener noreferrer"
|
||||
target="_blank">
|
||||
<img src={ ccLogo }
|
||||
alt={ t('Creative Commons CC-BY-3.0 License') } />
|
||||
</a></Trans>
|
||||
</li>
|
||||
</ul>
|
||||
<div className={ style.buildId }>{ process.env.BUILD_ID }</div>
|
||||
</footer>
|
||||
);
|
||||
<div className={ style.buildId }>
|
||||
{ buildId }
|
||||
</div>
|
||||
</footer>;
|
||||
};
|
||||
|
||||
export default translate()(Footer);
|
||||
export { Footer };
|
||||
Footer.propTypes = {
|
||||
buildId: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import url('../../globals.css');
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
footer {
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin: var(--spacing-margin) 0;
|
||||
@@ -9,19 +9,18 @@ footer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& ul {
|
||||
@apply --inline-list;
|
||||
@apply --with-separator-left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
& img {
|
||||
vertical-align: text-top;
|
||||
width: 80px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
& .buildId {
|
||||
color: color(var(--color-brown) blend(var(--color-tan) 25%));
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
composes: inline-list with-separator-left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.buildId {
|
||||
color: color(var(--color-brown) blend(var(--color-tan) 25%));
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import { Footer } from 'components/Footer';
|
||||
import Footer from 'components/Footer';
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
process.env.BUILD_ID = 'example build id';
|
||||
});
|
||||
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<Footer/>
|
||||
const { asFragment } = render(
|
||||
<Footer buildId="abc-123" />
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,194 +1,54 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Form rendering 1`] = `
|
||||
<div
|
||||
className="form"
|
||||
>
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="form"
|
||||
data-requires-js="true"
|
||||
>
|
||||
<form
|
||||
onSubmit={[Function]}
|
||||
data-testid="form"
|
||||
>
|
||||
<textarea
|
||||
autoFocus={true}
|
||||
data-testid="expr-input"
|
||||
name="expr"
|
||||
onChange={[Function]}
|
||||
onKeyPress={[Function]}
|
||||
placeholder="translate(Enter regular expression to display)"
|
||||
placeholder="TRANSLATE(Enter regular expression to display)"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
>
|
||||
<Trans>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Display
|
||||
</Trans>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className="select"
|
||||
class="select"
|
||||
>
|
||||
<select
|
||||
data-testid="syntax-select"
|
||||
name="syntax"
|
||||
onChange={[Function]}
|
||||
value="js"
|
||||
>
|
||||
<option
|
||||
key="js"
|
||||
value="js"
|
||||
value="testJS"
|
||||
>
|
||||
Javascript
|
||||
TRANSLATE(Testing JS)
|
||||
</option>
|
||||
<option
|
||||
key="pcre"
|
||||
value="pcre"
|
||||
value="other"
|
||||
>
|
||||
PCRE
|
||||
TRANSLATE(Other)
|
||||
</option>
|
||||
</select>
|
||||
<SvgMock />
|
||||
</div>
|
||||
<ul
|
||||
className="actions"
|
||||
<span
|
||||
data-component="ChevronsDown"
|
||||
data-props="{}"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Form rendering with download URLs 1`] = `
|
||||
<div
|
||||
className="form"
|
||||
>
|
||||
<form
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<textarea
|
||||
autoFocus={true}
|
||||
name="expr"
|
||||
onChange={[Function]}
|
||||
onKeyPress={[Function]}
|
||||
placeholder="translate(Enter regular expression to display)"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
>
|
||||
<Trans>
|
||||
Display
|
||||
</Trans>
|
||||
</button>
|
||||
<div
|
||||
className="select"
|
||||
>
|
||||
<select
|
||||
name="syntax"
|
||||
onChange={[Function]}
|
||||
value="js"
|
||||
>
|
||||
<option
|
||||
key="js"
|
||||
value="js"
|
||||
>
|
||||
Javascript
|
||||
</option>
|
||||
<option
|
||||
key="pcre"
|
||||
value="pcre"
|
||||
>
|
||||
PCRE
|
||||
</option>
|
||||
</select>
|
||||
<SvgMock />
|
||||
</div>
|
||||
<ul
|
||||
className="actions"
|
||||
>
|
||||
<li
|
||||
key="0"
|
||||
>
|
||||
<a
|
||||
download="image.svg"
|
||||
href="#svg"
|
||||
type="image/svg+xml"
|
||||
>
|
||||
<SvgMock />
|
||||
<Trans>
|
||||
Download SVG
|
||||
</Trans>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
key="1"
|
||||
>
|
||||
<a
|
||||
download="image.png"
|
||||
href="#png"
|
||||
type="image/png"
|
||||
>
|
||||
<SvgMock />
|
||||
<Trans>
|
||||
Download PNG
|
||||
</Trans>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
Actions
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Form rendering with permalink URL 1`] = `
|
||||
<div
|
||||
className="form"
|
||||
>
|
||||
<form
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<textarea
|
||||
autoFocus={true}
|
||||
name="expr"
|
||||
onChange={[Function]}
|
||||
onKeyPress={[Function]}
|
||||
placeholder="translate(Enter regular expression to display)"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
>
|
||||
<Trans>
|
||||
Display
|
||||
</Trans>
|
||||
</button>
|
||||
<div
|
||||
className="select"
|
||||
>
|
||||
<select
|
||||
name="syntax"
|
||||
onChange={[Function]}
|
||||
value="js"
|
||||
>
|
||||
<option
|
||||
key="js"
|
||||
value="js"
|
||||
>
|
||||
Javascript
|
||||
</option>
|
||||
<option
|
||||
key="pcre"
|
||||
value="pcre"
|
||||
>
|
||||
PCRE
|
||||
</option>
|
||||
</select>
|
||||
<SvgMock />
|
||||
</div>
|
||||
<ul
|
||||
className="actions"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="#permalink"
|
||||
>
|
||||
<SvgMock />
|
||||
<Trans>
|
||||
Permalink
|
||||
</Trans>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -1,105 +1,73 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { translate, Trans } from 'react-i18next';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import DownloadIcon from 'feather-icons/dist/icons/download.svg';
|
||||
import LinkIcon from 'feather-icons/dist/icons/link.svg';
|
||||
import ExpandIcon from 'feather-icons/dist/icons/chevrons-down.svg';
|
||||
import ExpandIcon from 'react-feather/dist/icons/chevrons-down';
|
||||
|
||||
import style from './style.css';
|
||||
import style from './style.module.css';
|
||||
|
||||
class Form extends React.PureComponent {
|
||||
state = {
|
||||
expr: this.props.expr,
|
||||
syntax: this.props.syntax || Object.keys(this.props.syntaxes)[0]
|
||||
}
|
||||
const Form = ({ syntaxList, children, onSubmit, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const [ expr, exprUpdate ] = useState(props.expr);
|
||||
const [ syntax, syntaxUpdate ] = useState(props.syntax);
|
||||
|
||||
handleSubmit = event => {
|
||||
const handleExprChange = useCallback(event => {
|
||||
exprUpdate(event.target.value);
|
||||
}, [exprUpdate]);
|
||||
const handleSyntaxChange = useCallback(event => {
|
||||
syntaxUpdate(event.target.value);
|
||||
}, [syntaxUpdate]);
|
||||
const handleSubmit = useCallback(event => {
|
||||
event.preventDefault();
|
||||
this.props.onSubmit.call(this, {
|
||||
expr: this.state.expr,
|
||||
syntax: this.state.syntax
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyPress = event => {
|
||||
onSubmit({ expr, syntax });
|
||||
}, [expr, syntax, onSubmit]);
|
||||
const handleKeyPress = useCallback(event => {
|
||||
if (event.charCode === 13 && event.shiftKey) {
|
||||
this.handleSubmit(event);
|
||||
}
|
||||
handleSubmit(event);
|
||||
}
|
||||
}, [handleSubmit]);
|
||||
|
||||
handleChange = event => this.setState({ [event.target.name]: event.target.value })
|
||||
|
||||
permalinkAction() {
|
||||
const { permalinkUrl } = this.props;
|
||||
|
||||
if (!permalinkUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <li>
|
||||
<a href={ permalinkUrl }><LinkIcon/><Trans>Permalink</Trans></a>
|
||||
</li>;
|
||||
}
|
||||
|
||||
downloadActions() {
|
||||
const { downloadUrls } = this.props;
|
||||
|
||||
if (!downloadUrls) {
|
||||
return;
|
||||
}
|
||||
|
||||
return downloadUrls.map(({ url, filename, type, label }, i) => <li key={ i }>
|
||||
<a href={ url } download={ filename } type={ type }>
|
||||
<DownloadIcon/><Trans>{ label }</Trans>
|
||||
</a>
|
||||
</li>);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { syntaxes, t } = this.props;
|
||||
const { expr, syntax } = this.state;
|
||||
|
||||
return <div className={ style.form }>
|
||||
<form onSubmit={ this.handleSubmit }>
|
||||
return <div className={ style.form } data-requires-js>
|
||||
<form data-testid="form" onSubmit={ handleSubmit }>
|
||||
<textarea
|
||||
data-testid="expr-input"
|
||||
name="expr"
|
||||
value={ expr }
|
||||
onKeyPress={ this.handleKeyPress }
|
||||
onChange={ this.handleChange }
|
||||
onKeyPress={ handleKeyPress }
|
||||
onChange={ handleExprChange }
|
||||
autoFocus
|
||||
placeholder={ t('Enter regular expression to display') }></textarea>
|
||||
<button type="submit"><Trans>Display</Trans></button>
|
||||
<div className={ style.select }>
|
||||
<select
|
||||
data-testid="syntax-select"
|
||||
name="syntax"
|
||||
value={ syntax }
|
||||
onChange={ this.handleChange }>
|
||||
{ Object.keys(syntaxes).map(id => (
|
||||
<option value={ id } key={ id }>{ syntaxes[id] }</option>
|
||||
onChange={ handleSyntaxChange } >
|
||||
{ syntaxList.map(({ id, label }) => (
|
||||
<option value={ id } key={ id }>{ t(label) }</option>
|
||||
)) }
|
||||
</select>
|
||||
<ExpandIcon/>
|
||||
<ExpandIcon />
|
||||
</div>
|
||||
<ul className={ style.actions }>
|
||||
{ this.downloadActions() }
|
||||
{ this.permalinkAction() }
|
||||
</ul>
|
||||
{ children }
|
||||
</form>
|
||||
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Form.propTypes = {
|
||||
expr: PropTypes.string,
|
||||
syntax: PropTypes.string,
|
||||
syntaxes: PropTypes.object,
|
||||
onSubmit: PropTypes.func,
|
||||
permalinkUrl: PropTypes.string,
|
||||
downloadUrls: PropTypes.array,
|
||||
t: PropTypes.func
|
||||
syntaxList: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string
|
||||
})),
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
])
|
||||
};
|
||||
|
||||
export default translate()(Form);
|
||||
export { Form };
|
||||
export default Form;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import url('../../globals.css');
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
:root {
|
||||
--control-gradient: var(--color-green) var(--gradient-green);
|
||||
@@ -23,7 +23,7 @@
|
||||
width: 100% !important; /* "!important" to prevent user changing width */
|
||||
height: calc(3 * var(--entry-line-height));
|
||||
box-sizing: border-box;
|
||||
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
|
||||
font-family: Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
& textarea::placeholder {
|
||||
@@ -44,24 +44,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
@apply --list-reset;
|
||||
margin-top: var(--spacing-margin);
|
||||
|
||||
@media (min-width: 700px) {
|
||||
@apply --inline-list;
|
||||
@apply --with-separator-left;
|
||||
float: right;
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
@apply --fancy-select;
|
||||
composes: fancy-select;
|
||||
}
|
||||
+44
-57
@@ -1,93 +1,80 @@
|
||||
jest.mock('react-feather/dist/icons/chevrons-down', () =>
|
||||
require('__mocks__/component-mock')(
|
||||
'react-feather/dist/icons/chevrons-down'));
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { render, fireEvent } from 'react-testing-library';
|
||||
|
||||
import { Form } from 'components/Form';
|
||||
import { translate } from '__mocks__/i18n';
|
||||
import Form from 'components/Form';
|
||||
|
||||
const syntaxes = {
|
||||
js: 'Javascript',
|
||||
pcre: 'PCRE'
|
||||
};
|
||||
const syntaxList = [
|
||||
{ id: 'testJS', label: 'Testing JS' },
|
||||
{ id: 'other', label: 'Other' }
|
||||
];
|
||||
const commonProps = { syntaxList };
|
||||
|
||||
describe('Form', () => {
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<Form t={ translate } syntaxes={ syntaxes }/>
|
||||
const { asFragment } = render(
|
||||
<Form onSubmit={ jest.fn() } { ...commonProps }>
|
||||
Actions
|
||||
</Form>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with download URLs', () => {
|
||||
const downloadUrls = [
|
||||
{ url: '#svg', filename: 'image.svg', type: 'image/svg+xml', label: 'Download SVG' },
|
||||
{ url: '#png', filename: 'image.png', type: 'image/png', label: 'Download PNG' }
|
||||
];
|
||||
const component = shallow(
|
||||
<Form t={ translate } syntaxes={ syntaxes } downloadUrls={ downloadUrls }/>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with permalink URL', () => {
|
||||
const permalinkUrl = '#permalink';
|
||||
const component = shallow(
|
||||
<Form t={ translate } syntaxes={ syntaxes } permalinkUrl={ permalinkUrl }/>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('submitting expression', () => {
|
||||
test('submitting form', () => {
|
||||
const onSubmit = jest.fn();
|
||||
const component = shallow(
|
||||
<Form t={ translate } syntaxes={ syntaxes } onSubmit={ onSubmit }/>
|
||||
const { getByTestId } = render(
|
||||
<Form onSubmit={ onSubmit } { ...commonProps } />
|
||||
);
|
||||
|
||||
const exprInput = component.find('[name="expr"]');
|
||||
const syntaxInput = component.find('[name="syntax"]');
|
||||
exprInput.simulate('change', { target: { name: 'expr', value: 'Test expression' } });
|
||||
syntaxInput.simulate('change', { target: { name: 'syntax', value: 'test' } });
|
||||
fireEvent.change(getByTestId('expr-input'), {
|
||||
target: { value: 'Test expression' }
|
||||
});
|
||||
fireEvent.change(getByTestId('syntax-select'), {
|
||||
target: { value: 'other' }
|
||||
});
|
||||
|
||||
const eventObj = { preventDefault: jest.fn() };
|
||||
component.find('form').simulate('submit', eventObj);
|
||||
const event = new Event('submit');
|
||||
jest.spyOn(event, 'preventDefault');
|
||||
|
||||
expect(eventObj.preventDefault).toHaveBeenCalled();
|
||||
fireEvent(getByTestId('form'), event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
expr: 'Test expression',
|
||||
syntax: 'test'
|
||||
syntax: 'other'
|
||||
});
|
||||
});
|
||||
|
||||
test('submitting form with Shift+Enter', () => {
|
||||
const component = shallow(
|
||||
<Form t={ translate } syntaxes={ syntaxes } onSubmit={ Function.prototype }/>
|
||||
const onSubmit = jest.fn();
|
||||
const { getByTestId } = render(
|
||||
<Form onSubmit={ onSubmit } { ...commonProps } />
|
||||
);
|
||||
const form = component.instance();
|
||||
const eventObj = {
|
||||
preventDefault: Function.prototype,
|
||||
|
||||
fireEvent.keyPress(getByTestId('expr-input'), {
|
||||
charCode: 13,
|
||||
shiftKey: true
|
||||
};
|
||||
jest.spyOn(form, 'handleSubmit');
|
||||
component.find('textarea').simulate('keypress', eventObj);
|
||||
});
|
||||
|
||||
expect(form.handleSubmit).toHaveBeenCalled();
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('not submitting with just Enter', () => {
|
||||
const component = shallow(
|
||||
<Form t={ translate } syntaxes={ syntaxes } onSubmit={ Function.protoytpe }/>
|
||||
const onSubmit = jest.fn();
|
||||
const { getByTestId } = render(
|
||||
<Form onSubmit={ onSubmit } { ...commonProps } />
|
||||
);
|
||||
const form = component.instance();
|
||||
const eventObj = {
|
||||
preventDefault: Function.prototype,
|
||||
|
||||
fireEvent.keyPress(getByTestId('expr-input'), {
|
||||
charCode: 13,
|
||||
shiftKey: false
|
||||
};
|
||||
jest.spyOn(form, 'handleSubmit');
|
||||
component.find('textarea').simulate('keypress', eventObj);
|
||||
});
|
||||
|
||||
expect(form.handleSubmit).not.toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FormActions rendering 1`] = `
|
||||
<DocumentFragment>
|
||||
<ul
|
||||
class="actions"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`FormActions rendering with a permalink 1`] = `
|
||||
<DocumentFragment>
|
||||
<ul
|
||||
class="actions"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="http://example.com"
|
||||
>
|
||||
<span
|
||||
data-component="Link"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Permalink
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import DownloadIcon from 'react-feather/dist/icons/download';
|
||||
import LinkIcon from 'react-feather/dist/icons/link';
|
||||
|
||||
import style from './style.module.css';
|
||||
|
||||
import { createPngLink, createSvgLink } from './links';
|
||||
|
||||
const downloadLink = (link, t) => {
|
||||
const { url, filename, type, label } = link;
|
||||
return <li>
|
||||
<a href={ url } download={ filename } type={ type }>
|
||||
<DownloadIcon />{ t(label) }
|
||||
</a>
|
||||
</li>;
|
||||
};
|
||||
|
||||
const FormActions = ({
|
||||
permalinkUrl,
|
||||
imageDetails
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [svgLink, setSvgLink] = useState(null);
|
||||
const [pngLink, setPngLink] = useState(null);
|
||||
|
||||
const generateDownloadLinks = useCallback(async () => {
|
||||
const { svg, width, height } = imageDetails;
|
||||
|
||||
setSvgLink(await createSvgLink({ svg }));
|
||||
setPngLink(await createPngLink({ svg, width, height }));
|
||||
}, [setSvgLink, setPngLink, imageDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (imageDetails && imageDetails.svg) {
|
||||
generateDownloadLinks();
|
||||
}
|
||||
}, [imageDetails]);
|
||||
|
||||
return <ul className={ style.actions }>
|
||||
{ pngLink && downloadLink(pngLink, t) }
|
||||
{ svgLink && downloadLink(svgLink, t) }
|
||||
{ permalinkUrl && <li>
|
||||
<a href={ permalinkUrl }><LinkIcon /><Trans>Permalink</Trans></a>
|
||||
</li> }
|
||||
</ul>;
|
||||
};
|
||||
|
||||
FormActions.propTypes = {
|
||||
permalinkUrl: PropTypes.string,
|
||||
imageDetails: PropTypes.shape({
|
||||
svg: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number
|
||||
})
|
||||
};
|
||||
|
||||
export default FormActions;
|
||||
@@ -0,0 +1,49 @@
|
||||
const createSvgLink = async ({ svg }) => {
|
||||
try {
|
||||
const type = 'image/svg+xml';
|
||||
const blob = new Blob([svg], { type });
|
||||
|
||||
return {
|
||||
url: URL.createObjectURL(blob),
|
||||
label: 'Download SVG',
|
||||
filename: 'image.svg',
|
||||
type
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
|
||||
const createPngLink = async ({ svg, width, height }) => {
|
||||
try {
|
||||
const type = 'image/png';
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
const loader = new Image();
|
||||
|
||||
loader.width = canvas.width = width * 2;
|
||||
loader.height = canvas.height = height * 2;
|
||||
|
||||
await new Promise(resolve => {
|
||||
loader.onload = resolve;
|
||||
loader.src = 'data:image/svg+xml,' + encodeURIComponent(svg);
|
||||
});
|
||||
|
||||
context.drawImage(loader, 0, 0, loader.width, loader.height);
|
||||
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, type));
|
||||
|
||||
return {
|
||||
url: URL.createObjectURL(blob),
|
||||
label: 'Download PNG',
|
||||
filename: 'image.png',
|
||||
type
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
|
||||
export { createSvgLink, createPngLink };
|
||||
@@ -0,0 +1,19 @@
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
.actions {
|
||||
composes: inline-list with-separator-left;
|
||||
float: right;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
& li {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
jest.mock('./links');
|
||||
jest.mock('react-feather/dist/icons/download', () =>
|
||||
require('__mocks__/component-mock')(
|
||||
'react-feather/dist/icons/download'));
|
||||
jest.mock('react-feather/dist/icons/link', () =>
|
||||
require('__mocks__/component-mock')(
|
||||
'react-feather/dist/icons/link'));
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import FormActions from 'components/FormActions';
|
||||
import { createPngLink, createSvgLink } from './links';
|
||||
|
||||
createPngLink.mockResolvedValue({
|
||||
url: 'http://example.com/image.png',
|
||||
filename: 'image.png',
|
||||
type: 'image/png',
|
||||
label: 'Example PNG Link'
|
||||
});
|
||||
createSvgLink.mockResolvedValue({
|
||||
url: 'http://example.com/image.svg',
|
||||
filename: 'image.svg',
|
||||
type: 'image/svg+xml',
|
||||
label: 'Example SVG Link'
|
||||
});
|
||||
|
||||
describe('FormActions', () => {
|
||||
test('rendering', () => {
|
||||
const { asFragment } = render(
|
||||
<FormActions/>
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with a permalink', () => {
|
||||
const { asFragment } = render(
|
||||
<FormActions permalinkUrl="http://example.com" />
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,21 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header rendering 1`] = `
|
||||
<header
|
||||
className="header"
|
||||
data-banner="testing"
|
||||
>
|
||||
exports[`Header opening the Privacy Policy modal 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Modal"
|
||||
data-props="{
|
||||
\\"isOpen\\": true
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="PrivacyPolicy"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
@@ -12,31 +23,512 @@ exports[`Header rendering 1`] = `
|
||||
Regexper
|
||||
</a>
|
||||
</h1>
|
||||
<ul>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<SvgMock />
|
||||
<Trans>
|
||||
<span
|
||||
data-component="Gitlab"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Source on GitLab
|
||||
</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/privacy.html"
|
||||
data-testid="privacy-link"
|
||||
href="/privacy"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
<Trans>
|
||||
Privacy Policy
|
||||
</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Translate(LocaleSwitcher) />
|
||||
<span
|
||||
data-component="InstallPrompt"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
data-requires-js="true"
|
||||
>
|
||||
<span
|
||||
data-component="LocaleSwitcher"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Header opening the Privacy Policy modal while holding alt key 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Modal"
|
||||
data-props="{
|
||||
\\"isOpen\\": false
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="PrivacyPolicy"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
>
|
||||
Regexper
|
||||
</a>
|
||||
</h1>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
data-component="Gitlab"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Source on GitLab
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-testid="privacy-link"
|
||||
href="/privacy"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Privacy Policy
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
data-component="InstallPrompt"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
data-requires-js="true"
|
||||
>
|
||||
<span
|
||||
data-component="LocaleSwitcher"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Header opening the Privacy Policy modal while holding ctrl key 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Modal"
|
||||
data-props="{
|
||||
\\"isOpen\\": false
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="PrivacyPolicy"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
>
|
||||
Regexper
|
||||
</a>
|
||||
</h1>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
data-component="Gitlab"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Source on GitLab
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-testid="privacy-link"
|
||||
href="/privacy"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Privacy Policy
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
data-component="InstallPrompt"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
data-requires-js="true"
|
||||
>
|
||||
<span
|
||||
data-component="LocaleSwitcher"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Header opening the Privacy Policy modal while holding meta key 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Modal"
|
||||
data-props="{
|
||||
\\"isOpen\\": false
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="PrivacyPolicy"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
>
|
||||
Regexper
|
||||
</a>
|
||||
</h1>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
data-component="Gitlab"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Source on GitLab
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-testid="privacy-link"
|
||||
href="/privacy"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Privacy Policy
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
data-component="InstallPrompt"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
data-requires-js="true"
|
||||
>
|
||||
<span
|
||||
data-component="LocaleSwitcher"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Header opening the Privacy Policy modal while holding shift key 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Modal"
|
||||
data-props="{
|
||||
\\"isOpen\\": false
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="PrivacyPolicy"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
>
|
||||
Regexper
|
||||
</a>
|
||||
</h1>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
data-component="Gitlab"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Source on GitLab
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-testid="privacy-link"
|
||||
href="/privacy"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Privacy Policy
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
data-component="InstallPrompt"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
data-requires-js="true"
|
||||
>
|
||||
<span
|
||||
data-component="LocaleSwitcher"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Header rendering 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Modal"
|
||||
data-props="{
|
||||
\\"isOpen\\": false
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="PrivacyPolicy"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<header
|
||||
class="header"
|
||||
data-banner="testing"
|
||||
>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
>
|
||||
Regexper
|
||||
</a>
|
||||
</h1>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
data-component="Gitlab"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Source on GitLab
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-testid="privacy-link"
|
||||
href="/privacy"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Privacy Policy
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
data-component="InstallPrompt"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
data-requires-js="true"
|
||||
>
|
||||
<span
|
||||
data-component="LocaleSwitcher"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Header rendering with no banner 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Modal"
|
||||
data-props="{
|
||||
\\"isOpen\\": false
|
||||
}"
|
||||
>
|
||||
<span
|
||||
data-component="PrivacyPolicy"
|
||||
data-props="{}"
|
||||
/>
|
||||
</span>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<h1>
|
||||
<a
|
||||
href="/"
|
||||
>
|
||||
Regexper
|
||||
</a>
|
||||
</h1>
|
||||
<ul
|
||||
class="list"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
data-component="Gitlab"
|
||||
data-props="{}"
|
||||
/>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Source on GitLab
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-testid="privacy-link"
|
||||
href="/privacy"
|
||||
>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Privacy Policy
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
data-component="InstallPrompt"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
data-requires-js="true"
|
||||
>
|
||||
<span
|
||||
data-component="LocaleSwitcher"
|
||||
data-props="{}"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -1,28 +1,77 @@
|
||||
import React from 'react';
|
||||
import { translate, Trans } from 'react-i18next';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from 'react-modal';
|
||||
import { Link } from 'gatsby';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import style from './style.css';
|
||||
import GitlabIcon from 'feather-icons/dist/icons/gitlab.svg';
|
||||
import GitlabIcon from 'react-feather/dist/icons/gitlab';
|
||||
|
||||
import LocaleSwitcher from 'components/LocaleSwitcher';
|
||||
import InstallPrompt from 'components/InstallPrompt';
|
||||
import PrivacyPolicy from 'components/PrivacyPolicy';
|
||||
|
||||
const Header = () => (
|
||||
<header className={ style.header } data-banner={ process.env.BANNER }>
|
||||
import style from './style.module.css';
|
||||
|
||||
const Header = ({ banner }) => {
|
||||
const [ showModal, updateShowModal] = useState(false);
|
||||
const handleClose = useCallback(() => {
|
||||
updateShowModal(false);
|
||||
}, [updateShowModal]);
|
||||
const handleOpen = useCallback(event => {
|
||||
if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
updateShowModal(true);
|
||||
}, [updateShowModal]);
|
||||
|
||||
return <>
|
||||
<Modal
|
||||
isOpen={ showModal }
|
||||
onRequestClose={ handleClose }>
|
||||
<PrivacyPolicy onClose={ handleClose } />
|
||||
</Modal>
|
||||
<header
|
||||
className={ style.header }
|
||||
data-banner={ banner || null }>
|
||||
<h1>
|
||||
<a href="/">Regexper</a>
|
||||
<Link to="/">Regexper</Link>
|
||||
</h1>
|
||||
|
||||
<ul>
|
||||
<li><a href="https://gitlab.com/javallone/regexper-static" rel="external noopener noreferrer" target="_blank">
|
||||
<GitlabIcon/><Trans>Source on GitLab</Trans>
|
||||
</a></li>
|
||||
<ul className={ style.list }>
|
||||
<li>
|
||||
<a href="/privacy.html"><Trans>Privacy Policy</Trans></a>
|
||||
<a href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank">
|
||||
<GitlabIcon />
|
||||
<Trans>Source on GitLab</Trans>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/privacy"
|
||||
data-testid="privacy-link"
|
||||
onClick={ handleOpen }
|
||||
>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<InstallPrompt />
|
||||
</li>
|
||||
<li data-requires-js>
|
||||
<LocaleSwitcher />
|
||||
</li>
|
||||
<li><LocaleSwitcher /></li>
|
||||
</ul>
|
||||
</header>
|
||||
);
|
||||
</>;
|
||||
};
|
||||
|
||||
export default translate()(Header);
|
||||
export { Header };
|
||||
Header.propTypes = {
|
||||
banner: PropTypes.oneOfType([
|
||||
PropTypes.bool,
|
||||
PropTypes.string
|
||||
]).isRequired
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import url('../../globals.css');
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -9,6 +9,7 @@
|
||||
margin: 0 calc(-1 * var(--content-margin)) var(--spacing-margin) calc(-1 * var(--content-margin));
|
||||
position: relative;
|
||||
color: var(--color-black);
|
||||
min-width: 320px;
|
||||
|
||||
&:after {
|
||||
content: attr(data-banner);
|
||||
@@ -41,13 +42,12 @@
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
& ul {
|
||||
@apply --inline-list;
|
||||
@apply --with-separator-right;
|
||||
.list {
|
||||
composes: inline-list with-separator-right;
|
||||
text-align: right;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
& li {
|
||||
line-height: 2.4rem;
|
||||
@@ -1,17 +1,62 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
jest.mock('react-modal', () =>
|
||||
require('__mocks__/component-mock')('react-modal'));
|
||||
jest.mock('react-feather/dist/icons/gitlab', () =>
|
||||
require('__mocks__/component-mock')('react-feather/dist/icons/gitlab'));
|
||||
jest.mock('components/LocaleSwitcher', () =>
|
||||
require('__mocks__/component-mock')('components/LocaleSwitcher'));
|
||||
jest.mock('components/InstallPrompt', () =>
|
||||
require('__mocks__/component-mock')('components/InstallPrompt'));
|
||||
jest.mock('components/PrivacyPolicy', () =>
|
||||
require('__mocks__/component-mock')('components/PrivacyPolicy'));
|
||||
|
||||
import { Header } from 'components/Header';
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from 'react-testing-library';
|
||||
|
||||
import Header from 'components/Header';
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
process.env.BANNER = 'testing';
|
||||
test('rendering', () => {
|
||||
const { asFragment } = render(
|
||||
<Header banner="testing" />
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<Header/>
|
||||
test('rendering with no banner', () => {
|
||||
const { asFragment } = render(
|
||||
<Header banner={ false } />
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('opening the Privacy Policy modal', () => {
|
||||
const { asFragment, getByTestId } = render(
|
||||
<Header banner={ false } />
|
||||
);
|
||||
const event = new MouseEvent('click', { bubbles: true });
|
||||
jest.spyOn(event, 'preventDefault');
|
||||
|
||||
fireEvent(getByTestId('privacy-link'), event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
['shift', 'ctrl', 'alt', 'meta'].forEach(key => {
|
||||
test(`opening the Privacy Policy modal while holding ${ key } key`, () => {
|
||||
const { asFragment, getByTestId } = render(
|
||||
<Header banner={ false } />
|
||||
);
|
||||
const event = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
[key + 'Key']: true
|
||||
});
|
||||
jest.spyOn(event, 'preventDefault');
|
||||
|
||||
fireEvent(getByTestId('privacy-link'), event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`InstallPrompt rendering 1`] = `
|
||||
<div
|
||||
className="install"
|
||||
>
|
||||
<p
|
||||
className="cta"
|
||||
exports[`InstallPrompt rendering 1`] = `<DocumentFragment />`;
|
||||
|
||||
exports[`InstallPrompt rendering after an install prompt has been requested 1`] = `<DocumentFragment />`;
|
||||
|
||||
exports[`InstallPrompt rendering after an install prompt has been requested 2`] = `
|
||||
<DocumentFragment>
|
||||
<a
|
||||
data-testid="install"
|
||||
href="#install"
|
||||
>
|
||||
<Trans>
|
||||
Add Regexper to your home screen?
|
||||
</Trans>
|
||||
</p>
|
||||
<div
|
||||
className="actions"
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
<button
|
||||
className="primary"
|
||||
>
|
||||
<Trans>
|
||||
Add It
|
||||
</Trans>
|
||||
</button>
|
||||
<button>
|
||||
<Trans>
|
||||
No Thanks
|
||||
</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Add to Home Screen
|
||||
</span>
|
||||
</a>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { translate, Trans } from 'react-i18next';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import style from './style.css';
|
||||
const InstallPrompt = () => {
|
||||
const [ installPrompt, updateInstallPrompt ] = useState(null);
|
||||
|
||||
const InstallPrompt = ({ onAccept, onReject }) => (
|
||||
<div className={ style.install }>
|
||||
<p className={ style.cta }><Trans>Add Regexper to your home screen?</Trans></p>
|
||||
<div className={ style.actions }>
|
||||
<button className={ style.primary } onClick={ onAccept }><Trans>Add It</Trans></button>
|
||||
<button onClick={ onReject }><Trans>No Thanks</Trans></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const handleInstall = useCallback(async event => {
|
||||
event.preventDefault();
|
||||
|
||||
InstallPrompt.propTypes = {
|
||||
onAccept: PropTypes.func.isRequired,
|
||||
onReject: PropTypes.func.isRequired
|
||||
try {
|
||||
installPrompt.prompt();
|
||||
await installPrompt.userChoice;
|
||||
}
|
||||
catch {
|
||||
// User cancelled install
|
||||
}
|
||||
|
||||
updateInstallPrompt(null);
|
||||
}, [installPrompt, updateInstallPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeinstallprompt', updateInstallPrompt);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', updateInstallPrompt);
|
||||
};
|
||||
});
|
||||
|
||||
if (!installPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <a href="#install"
|
||||
data-testid="install"
|
||||
onClick={ handleInstall }
|
||||
>
|
||||
<Trans>Add to Home Screen</Trans>
|
||||
</a>;
|
||||
};
|
||||
|
||||
export default translate()(InstallPrompt);
|
||||
export { InstallPrompt };
|
||||
export default InstallPrompt;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
@import url('../../globals.css');
|
||||
|
||||
.install {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-tan);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.cta {
|
||||
margin: 0;
|
||||
padding: var(--spacing-margin);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-evenly;
|
||||
padding: var(--spacing-margin);
|
||||
|
||||
& button {
|
||||
font-size: inherit;
|
||||
line-height: 2.8rem;
|
||||
border: 0 none;
|
||||
background: var(--color-green) var(--gradient-green);
|
||||
color: var(--color-black);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 40vw;
|
||||
|
||||
&.primary {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,42 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { render, fireEvent } from 'react-testing-library';
|
||||
|
||||
import { InstallPrompt } from 'components/InstallPrompt';
|
||||
import { translate } from '__mocks__/i18n';
|
||||
import InstallPrompt from 'components/InstallPrompt';
|
||||
|
||||
describe('InstallPrompt', () => {
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<InstallPrompt t={ translate }/>
|
||||
const { asFragment } = render(
|
||||
<InstallPrompt />
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering after an install prompt has been requested', () => {
|
||||
const { asFragment } = render(
|
||||
<InstallPrompt />
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
|
||||
const event = new Event('beforeinstallprompt', {
|
||||
prompt: jest.fn()
|
||||
});
|
||||
fireEvent(window, event);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('removing event listener', () => {
|
||||
jest.spyOn(window, 'addEventListener');
|
||||
jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = render(
|
||||
<InstallPrompt />
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
'beforeinstallprompt',
|
||||
expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Layout rendering 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="SentryBoundary"
|
||||
data-props="{}"
|
||||
>
|
||||
<noscript />
|
||||
<span
|
||||
data-component="Header"
|
||||
data-props="{
|
||||
\\"banner\\": \\"Test Banner\\"
|
||||
}"
|
||||
/>
|
||||
<span
|
||||
data-component="SentryBoundary"
|
||||
data-props="{}"
|
||||
>
|
||||
Example content
|
||||
</span>
|
||||
<span
|
||||
data-component="Footer"
|
||||
data-props="{
|
||||
\\"buildId\\": \\"test-buildid\\"
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { graphql, StaticQuery } from 'gatsby';
|
||||
|
||||
import SentryBoundary from 'components/SentryBoundary';
|
||||
import Header from 'components/Header';
|
||||
import Footer from 'components/Footer';
|
||||
|
||||
const query = graphql`
|
||||
query LayoutQuery {
|
||||
site {
|
||||
siteMetadata {
|
||||
banner
|
||||
buildId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const noscriptStyle = `
|
||||
[data-requires-js] {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Layout = ({ banner, buildId, children }) => <SentryBoundary>
|
||||
<noscript>
|
||||
<style type="text/css">{ noscriptStyle }</style>
|
||||
</noscript>
|
||||
<Header banner={ banner } />
|
||||
<SentryBoundary>
|
||||
{ children }
|
||||
</SentryBoundary>
|
||||
<Footer buildId={ buildId } />
|
||||
</SentryBoundary>;
|
||||
|
||||
Layout.propTypes = {
|
||||
banner: PropTypes.oneOfType([
|
||||
PropTypes.bool,
|
||||
PropTypes.string
|
||||
]).isRequired,
|
||||
buildId: PropTypes.string.isRequired,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export default props => (
|
||||
<StaticQuery query={ query } render={ ({ site: { siteMetadata } }) => (
|
||||
<Layout { ...props } { ...siteMetadata } />
|
||||
) } />
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
jest.mock('components/SentryBoundary', () =>
|
||||
require('__mocks__/component-mock')('components/SentryBoundary'));
|
||||
jest.mock('components/Header', () =>
|
||||
require('__mocks__/component-mock')('components/Header'));
|
||||
jest.mock('components/Footer', () =>
|
||||
require('__mocks__/component-mock')('components/Footer'));
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import { Layout } from 'components/Layout';
|
||||
|
||||
describe('Layout', () => {
|
||||
test('rendering', () => {
|
||||
const { asFragment } = render(
|
||||
<Layout banner="Test Banner" buildId="test-buildid">
|
||||
Example content
|
||||
</Layout>
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Loader rendering 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="loader"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="2"
|
||||
y2="6"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="18"
|
||||
y2="22"
|
||||
/>
|
||||
<line
|
||||
x1="4.93"
|
||||
x2="7.76"
|
||||
y1="4.93"
|
||||
y2="7.76"
|
||||
/>
|
||||
<line
|
||||
x1="16.24"
|
||||
x2="19.07"
|
||||
y1="16.24"
|
||||
y2="19.07"
|
||||
/>
|
||||
<line
|
||||
x1="2"
|
||||
x2="6"
|
||||
y1="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="18"
|
||||
x2="22"
|
||||
y1="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="4.93"
|
||||
x2="7.76"
|
||||
y1="19.07"
|
||||
y2="16.24"
|
||||
/>
|
||||
<line
|
||||
x1="16.24"
|
||||
x2="19.07"
|
||||
y1="7.76"
|
||||
y2="4.93"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="message"
|
||||
>
|
||||
TRANSLATE(Loading...)
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LoaderIcon from 'react-feather/dist/icons/loader';
|
||||
|
||||
import style from './style.module.css';
|
||||
|
||||
const Loader = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div className={ style.loader }>
|
||||
<LoaderIcon />
|
||||
<div className={ style.message }>{ t('Loading...') }</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
@@ -1,24 +1,11 @@
|
||||
@import url('../../globals.css');
|
||||
|
||||
.render {
|
||||
width: 100%;
|
||||
background: var(--color-white);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
margin: var(--spacing-margin) 0;
|
||||
|
||||
& svg {
|
||||
display: block;
|
||||
transform: scaleZ(1); /* Move to separate render layer in Chrome */
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
.loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: var(--spacing-margin) 0;
|
||||
padding: 2rem;
|
||||
background: var(--color-white);
|
||||
color: var(--color-black);
|
||||
@@ -26,6 +13,7 @@
|
||||
& .message {
|
||||
font-weight: bold;
|
||||
font-size: 2.5rem;
|
||||
margin-top: 2rem;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -33,8 +21,8 @@
|
||||
& svg {
|
||||
display: block;
|
||||
transform: scaleZ(1); /* Move to separate render layer in Chrome */
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
stroke: var(--color-black);
|
||||
animation: loader-spin 1s steps(8) infinite;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
|
||||
describe('Loader', () => {
|
||||
test('rendering', () => {
|
||||
// Using full rendering here since styles for this depend on the structure
|
||||
// of the SVG.
|
||||
const { asFragment } = render(
|
||||
<Loader/>
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,36 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`LocaleSwitcher rendering 1`] = `
|
||||
<label>
|
||||
<Trans>
|
||||
<DocumentFragment>
|
||||
<label>
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{}"
|
||||
>
|
||||
Language
|
||||
</Trans>
|
||||
</span>
|
||||
<div
|
||||
className="switcher"
|
||||
class="switcher"
|
||||
>
|
||||
<select
|
||||
onChange={[Function]}
|
||||
value="en"
|
||||
data-testid="language-select"
|
||||
>
|
||||
<option
|
||||
key="en"
|
||||
value="en"
|
||||
>
|
||||
/displayName
|
||||
English
|
||||
</option>
|
||||
<option
|
||||
key="fr"
|
||||
value="fr"
|
||||
value="other"
|
||||
>
|
||||
/displayName
|
||||
Other
|
||||
</option>
|
||||
</select>
|
||||
<SvgMock />
|
||||
<span
|
||||
data-component="ChevronsDown"
|
||||
data-props="{}"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -1,63 +1,47 @@
|
||||
import React from 'react';
|
||||
import { translate, Trans } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import style from './style.css';
|
||||
import ExpandIcon from 'feather-icons/dist/icons/chevrons-down.svg';
|
||||
import ExpandIcon from 'react-feather/dist/icons/chevrons-down';
|
||||
|
||||
import locales from 'locales';
|
||||
import i18n, { locales } from 'i18n';
|
||||
|
||||
const localeToAvailable = (locale, available, defaultLocale) => {
|
||||
if (available.includes(locale)) {
|
||||
return locale;
|
||||
}
|
||||
import localeToAvailable from './locale-to-available';
|
||||
import style from './style.module.css';
|
||||
|
||||
const parts = locale.split('-');
|
||||
const LocaleSwitcher = () => {
|
||||
const [ current, updateCurrent ] = useState(localeToAvailable(
|
||||
i18n.language || '',
|
||||
locales.map(l => l.code),
|
||||
'en'));
|
||||
|
||||
if (parts.length > 0 && available.includes(parts[0])) {
|
||||
return parts[0];
|
||||
}
|
||||
useEffect(() => {
|
||||
i18n.on('languageChanged', updateCurrent);
|
||||
|
||||
return defaultLocale;
|
||||
};
|
||||
return () => {
|
||||
i18n.off('languageChanged', updateCurrent);
|
||||
};
|
||||
});
|
||||
|
||||
class LocaleSwitcher extends React.PureComponent {
|
||||
state = {
|
||||
current: localeToAvailable(i18n.language || '', Object.keys(locales), 'en')
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
i18n.on('languageChanged', this.handleLanguageChange);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
i18n.off('languageChanged', this.handleLanguageChange);
|
||||
}
|
||||
|
||||
handleSelectChange = ({ target }) => {
|
||||
const handleSelectChange = useCallback(({ target }) => {
|
||||
i18n.changeLanguage(target.value);
|
||||
}
|
||||
|
||||
handleLanguageChange = lang => {
|
||||
this.setState({ current: lang });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { current } = this.state;
|
||||
});
|
||||
|
||||
return <label>
|
||||
<Trans>Language</Trans>
|
||||
<div className={ style.switcher }>
|
||||
<select value={ current } onChange={ this.handleSelectChange }>
|
||||
{ Object.keys(locales).map(locale => (
|
||||
<option value={ locale } key={ locale }>{ i18n.getFixedT(locale)('/displayName') }</option>
|
||||
<select data-testid="language-select"
|
||||
value={ current }
|
||||
onChange={ handleSelectChange }
|
||||
>
|
||||
{ locales.map(locale => (
|
||||
<option value={ locale.code } key={ locale.code }>
|
||||
{ locale.name }
|
||||
</option>
|
||||
)) }
|
||||
</select>
|
||||
<ExpandIcon/>
|
||||
<ExpandIcon />
|
||||
</div>
|
||||
</label>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default translate()(LocaleSwitcher);
|
||||
export { LocaleSwitcher };
|
||||
export default LocaleSwitcher;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const localeToAvailable = (locale, available, defaultLocale) => {
|
||||
if (available.includes(locale)) {
|
||||
return locale;
|
||||
}
|
||||
|
||||
const parts = locale.split('-');
|
||||
|
||||
if (parts.length > 0 && available.includes(parts[0])) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
};
|
||||
|
||||
export default localeToAvailable;
|
||||
@@ -0,0 +1,18 @@
|
||||
import localeToAvailable from './locale-to-available';
|
||||
|
||||
describe('localeToAvailable', () => {
|
||||
test('when requested language and region are available', () => {
|
||||
expect(localeToAvailable('en-US', ['en', 'en-US', 'other'], 'other'))
|
||||
.toEqual('en-US');
|
||||
});
|
||||
|
||||
test('when only requested language is available', () => {
|
||||
expect(localeToAvailable('en-US', ['en', 'en-GB', 'other'], 'other'))
|
||||
.toEqual('en');
|
||||
});
|
||||
|
||||
test('when language is unavailable', () => {
|
||||
expect(localeToAvailable('en-US', ['tlh', 'other'], 'other'))
|
||||
.toEqual('other');
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
@import url('../../globals.css');
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
:root {
|
||||
--control-gradient: var(--color-tan) var(--gradient-tan);
|
||||
@@ -7,5 +7,5 @@
|
||||
}
|
||||
|
||||
.switcher {
|
||||
@apply --fancy-select;
|
||||
composes: fancy-select;
|
||||
}
|
||||
@@ -1,31 +1,63 @@
|
||||
jest.mock('components/SVG');
|
||||
jest.mock('locales', () => ({
|
||||
en: {},
|
||||
fr: {}
|
||||
}));
|
||||
jest.mock('react-feather/dist/icons/chevrons-down', () =>
|
||||
require('__mocks__/component-mock')(
|
||||
'react-feather/dist/icons/chevrons-down'));
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { render, fireEvent, act } from 'react-testing-library';
|
||||
|
||||
import { LocaleSwitcher } from 'components/LocaleSwitcher';
|
||||
import { translate } from '__mocks__/i18n';
|
||||
import i18n from 'i18n';
|
||||
import LocaleSwitcher from 'components/LocaleSwitcher';
|
||||
|
||||
// Ensure initial locale is always "en" during tests
|
||||
jest.mock('./locale-to-available', () => jest.fn(() => 'en'));
|
||||
|
||||
describe('LocaleSwitcher', () => {
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<LocaleSwitcher t={ translate }/>
|
||||
const { asFragment } = render(
|
||||
<LocaleSwitcher />
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('changing language', () => {
|
||||
const component = shallow(
|
||||
<LocaleSwitcher t={ translate }/>
|
||||
);
|
||||
const selectInput = component.find('select');
|
||||
selectInput.value = 'fr';
|
||||
selectInput.simulate('change', { target: { value: 'fr' } });
|
||||
jest.spyOn(i18n, 'changeLanguage');
|
||||
|
||||
expect(component.state('current')).toEqual('fr');
|
||||
const { getByTestId } = render(
|
||||
<LocaleSwitcher />
|
||||
);
|
||||
const event = new Event('change', { bubbles: true });
|
||||
const select = getByTestId('language-select');
|
||||
select.value = 'other';
|
||||
|
||||
fireEvent(select, event);
|
||||
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith('other');
|
||||
});
|
||||
|
||||
test('interface update from language change', () => {
|
||||
const { getByTestId } = render(
|
||||
<LocaleSwitcher />
|
||||
);
|
||||
|
||||
expect(getByTestId('language-select').value).toEqual('en');
|
||||
|
||||
act(() => {
|
||||
i18n.emit('languageChanged', 'other');
|
||||
});
|
||||
|
||||
expect(getByTestId('language-select').value).toEqual('other');
|
||||
});
|
||||
|
||||
test('disconnecting event handler on unmount', () => {
|
||||
const { unmount } = render(
|
||||
<LocaleSwitcher />
|
||||
);
|
||||
|
||||
jest.spyOn(i18n, 'off');
|
||||
|
||||
unmount();
|
||||
expect(i18n.off).toHaveBeenCalledWith(
|
||||
'languageChanged',
|
||||
expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,63 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Message rendering 1`] = `
|
||||
<div
|
||||
className="message"
|
||||
>
|
||||
<DocumentFragment>
|
||||
<div
|
||||
className="header"
|
||||
class="message"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<h2>
|
||||
Testing
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="content"
|
||||
class="content"
|
||||
>
|
||||
<p>
|
||||
Message content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Message rendering with a close button 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="message"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<h2>
|
||||
Testing
|
||||
</h2>
|
||||
<button>
|
||||
<span
|
||||
data-component="XSquare"
|
||||
data-props="{}"
|
||||
/>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<p>
|
||||
Message content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Message rendering with icon 1`] = `
|
||||
<div
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="message"
|
||||
>
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
@@ -40,17 +73,22 @@ exports[`Message rendering with icon 1`] = `
|
||||
Message content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Message rendering with type 1`] = `
|
||||
<div
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="message error"
|
||||
>
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<svg />
|
||||
<span
|
||||
data-component="AlertOctagon"
|
||||
data-props="{}"
|
||||
/>
|
||||
<h2>
|
||||
Testing
|
||||
</h2>
|
||||
@@ -62,5 +100,6 @@ exports[`Message rendering with type 1`] = `
|
||||
Message content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from './style.css';
|
||||
import style from './style.module.css';
|
||||
|
||||
import InfoIcon from 'feather-icons/dist/icons/info.svg';
|
||||
import ErrorIcon from 'feather-icons/dist/icons/alert-octagon.svg';
|
||||
import WarningIcon from 'feather-icons/dist/icons/alert-triangle.svg';
|
||||
import InfoIcon from 'react-feather/dist/icons/info';
|
||||
import ErrorIcon from 'react-feather/dist/icons/alert-octagon';
|
||||
import WarningIcon from 'react-feather/dist/icons/alert-triangle';
|
||||
import CloseIcon from 'react-feather/dist/icons/x-square';
|
||||
|
||||
const iconTypes = {
|
||||
info: InfoIcon,
|
||||
@@ -14,21 +15,26 @@ const iconTypes = {
|
||||
};
|
||||
|
||||
const renderIcon = (type, icon) => {
|
||||
icon = icon || iconTypes[type];
|
||||
const Icon = icon || iconTypes[type];
|
||||
|
||||
if (!icon) {
|
||||
if (!Icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
const Icon = icon;
|
||||
return <Icon/>;
|
||||
return <Icon />;
|
||||
};
|
||||
|
||||
const Message = ({ type, icon, heading, children }) => (
|
||||
<div className={ [ style.message, type && style[type] ].filter(Boolean).join(' ') }>
|
||||
const Message = ({ type, icon, heading, onClose, children }) => (
|
||||
<div className={ [
|
||||
style.message,
|
||||
type && style[type]
|
||||
].filter(Boolean).join(' ') }>
|
||||
<div className={ style.header }>
|
||||
{ renderIcon(type, icon) }
|
||||
<h2>{ heading }</h2>
|
||||
{ onClose && <button onClick={ onClose }>
|
||||
<CloseIcon /> Close
|
||||
</button> }
|
||||
</div>
|
||||
<div className={ style.content }>
|
||||
{ children }
|
||||
@@ -47,6 +53,7 @@ Message.propTypes = {
|
||||
PropTypes.func
|
||||
]),
|
||||
heading: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
@import url('../../globals.css');
|
||||
|
||||
.message {
|
||||
background: var(--color-tan);
|
||||
color: var(--color-black);
|
||||
margin: var(--spacing-margin) 0;
|
||||
box-shadow: 0 0 1rem color(var(--color-black) alpha(0.7));
|
||||
|
||||
& .header {
|
||||
& h2 {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-margin);
|
||||
line-height: 2.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& svg {
|
||||
padding: var(--spacing-margin);
|
||||
height: 2.8rem;
|
||||
width: 2.8rem;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
& .content {
|
||||
padding: var(--spacing-margin);
|
||||
}
|
||||
|
||||
& p {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-margin) 0;
|
||||
}
|
||||
|
||||
& p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.error {
|
||||
& .header {
|
||||
background: var(--color-red);
|
||||
color: var(--color-white);
|
||||
|
||||
& svg {
|
||||
background: var(--color-white);
|
||||
color: var(--color-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.warning {
|
||||
& .header {
|
||||
background: var(--color-orange);
|
||||
}
|
||||
|
||||
& svg {
|
||||
background: var(--color-black);
|
||||
color: var(--color-orange);
|
||||
}
|
||||
}
|
||||
|
||||
&.info {
|
||||
& .header {
|
||||
background: var(--color-blue);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
& svg {
|
||||
background: var(--color-white);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
.message {
|
||||
background: var(--color-tan);
|
||||
color: var(--color-black);
|
||||
margin: var(--spacing-margin) 0;
|
||||
box-shadow: 0 0 1rem color(var(--color-black) alpha(0.7));
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
& h2 {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-margin);
|
||||
line-height: 2.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& svg {
|
||||
padding: var(--spacing-margin);
|
||||
height: 2.8rem;
|
||||
width: 2.8rem;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
& button {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0 none;
|
||||
background: transparent;
|
||||
color: var(--color-tan);
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
font-size: 0;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--spacing-margin);
|
||||
overflow: auto;
|
||||
|
||||
& p {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-margin);
|
||||
}
|
||||
|
||||
& p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
& .header {
|
||||
background: var(--color-red);
|
||||
color: var(--color-white);
|
||||
|
||||
& svg {
|
||||
background: var(--color-white);
|
||||
color: var(--color-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
& .header {
|
||||
background: var(--color-orange);
|
||||
|
||||
& > svg {
|
||||
background: var(--color-black);
|
||||
color: var(--color-orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
& .header {
|
||||
background: var(--color-blue);
|
||||
color: var(--color-white);
|
||||
|
||||
& > svg {
|
||||
background: var(--color-white);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,56 @@
|
||||
jest.mock('react-feather/dist/icons/info', () =>
|
||||
require('__mocks__/component-mock')('react-feather/dist/icons/info'));
|
||||
jest.mock('react-feather/dist/icons/alert-octagon', () =>
|
||||
require('__mocks__/component-mock')(
|
||||
'react-feather/dist/icons/alert-octagon'
|
||||
));
|
||||
jest.mock('react-feather/dist/icons/alert-triangle', () =>
|
||||
require('__mocks__/component-mock')(
|
||||
'react-feather/dist/icons/alert-triangle'
|
||||
));
|
||||
jest.mock('react-feather/dist/icons/x-square', () =>
|
||||
require('__mocks__/component-mock')('react-feather/dist/icons/x-square'));
|
||||
|
||||
import React from 'react';
|
||||
import { shallow, render } from 'enzyme';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import Message from 'components/Message';
|
||||
|
||||
describe('Message', () => {
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<Message heading="Testing" className="testing">
|
||||
const { asFragment } = render(
|
||||
<Message heading="Testing">
|
||||
<p>Message content</p>
|
||||
</Message>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with icon', () => {
|
||||
const Icon = () => 'Sample icon SVG';
|
||||
const component = render(
|
||||
const { asFragment } = render(
|
||||
<Message heading="Testing" icon={ Icon }>
|
||||
<p>Message content</p>
|
||||
</Message>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with type', () => {
|
||||
const component = render(
|
||||
const { asFragment } = render(
|
||||
<Message heading="Testing" type="error">
|
||||
<p>Message content</p>
|
||||
</Message>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with a close button', () => {
|
||||
const { asFragment } = render(
|
||||
<Message heading="Testing" onClose={ jest.fn() }>
|
||||
<p>Message content</p>
|
||||
</Message>
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Metadata rendering 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="HelmetWrapper"
|
||||
data-props="{
|
||||
\\"title\\": \\"Regexper\\",
|
||||
\\"htmlAttributes\\": {},
|
||||
\\"meta\\": [
|
||||
{
|
||||
\\"name\\": \\"description\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Metadata rendering with a title and description 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="HelmetWrapper"
|
||||
data-props="{
|
||||
\\"title\\": \\"Regexper - Testing\\",
|
||||
\\"htmlAttributes\\": {},
|
||||
\\"meta\\": [
|
||||
{
|
||||
\\"name\\": \\"description\\",
|
||||
\\"content\\": \\"Test description\\"
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const Metadata = ({ title, description }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const helmetProps = {
|
||||
title: title ? `Regexper - ${ title }` : 'Regexper',
|
||||
htmlAttributes: {
|
||||
lang: i18n.language
|
||||
},
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: description
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return <Helmet { ...helmetProps }></Helmet>;
|
||||
};
|
||||
|
||||
Metadata.propTypes = {
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.string
|
||||
};
|
||||
|
||||
export default Metadata;
|
||||
@@ -0,0 +1,30 @@
|
||||
jest.mock('react-helmet', () => {
|
||||
const helmet = jest.requireActual('react-helmet');
|
||||
return {
|
||||
...helmet,
|
||||
Helmet: require('__mocks__/component-mock').buildMock(helmet.Helmet)
|
||||
};
|
||||
});
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import Metadata from 'components/Metadata';
|
||||
|
||||
describe('Metadata', () => {
|
||||
test('rendering', () => {
|
||||
const { asFragment } = render(
|
||||
<Metadata/>
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with a title and description', () => {
|
||||
const { asFragment } = render(
|
||||
<Metadata
|
||||
title="Testing"
|
||||
description="Test description" />
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+19
-16
@@ -1,18 +1,22 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Privacy policy page component rendering 1`] = `
|
||||
<React.Fragment>
|
||||
<Translate(Header) />
|
||||
<Message
|
||||
heading="Privacy Policy"
|
||||
icon={[Function]}
|
||||
type="info"
|
||||
exports[`PrivacyPolicy rendering 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
data-component="Message"
|
||||
data-props="{
|
||||
\\"type\\": \\"info\\",
|
||||
\\"heading\\": \\"TRANSLATE(Privacy Policy)\\"
|
||||
}"
|
||||
>
|
||||
<Trans
|
||||
i18nKey="Privacy Policy Content"
|
||||
<span
|
||||
data-component="Trans"
|
||||
data-props="{
|
||||
\\"i18nKey\\": \\"Privacy policy copy\\"
|
||||
}"
|
||||
>
|
||||
<p>
|
||||
Regexper and the tools used to create it are all open source. If you are concerned that the JavaScript being delivered is in any way malicious, please inspect the source by following the
|
||||
Regexper and the tools used to create it are all open source. If you are concerned that the JavaScript being delivered is in any way malicious, please inspect the source in the
|
||||
<a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
@@ -30,13 +34,13 @@ exports[`Privacy policy page component rendering 1`] = `
|
||||
<b>
|
||||
Google Analytics
|
||||
</b>
|
||||
is used to track browser usage data and application performance.
|
||||
is used to track browser usage data and application performance. It is configured to anonymize the client IP address.
|
||||
</li>
|
||||
<li>
|
||||
<b>
|
||||
Sentry.io
|
||||
</b>
|
||||
is a tool used to capture and report client-side JavaScript errors.
|
||||
is a tool used to capture and report client-side JavaScript errors. It is configured to not store the client IP address.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -53,8 +57,7 @@ exports[`Privacy policy page component rendering 1`] = `
|
||||
<p>
|
||||
Regexper is not supported by ad revenue or sales of any kind.
|
||||
</p>
|
||||
</Trans>
|
||||
</Message>
|
||||
<Translate(Footer) />
|
||||
</React.Fragment>
|
||||
</span>
|
||||
</span>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import Message from 'components/Message';
|
||||
|
||||
export const PrivacyPolicy = props => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <Message type="info" heading={ t('Privacy Policy') } { ...props }>
|
||||
<Trans i18nKey="Privacy policy copy">
|
||||
<p>
|
||||
Regexper and the tools used to create it are all open source. If you are
|
||||
concerned that the JavaScript being delivered is in any way malicious,
|
||||
please inspect the source in the <a
|
||||
href="https://gitlab.com/javallone/regexper-static"
|
||||
rel="external noopener noreferrer"
|
||||
target="_blank">GitLab repository</a>.
|
||||
</p>
|
||||
<p>
|
||||
There are two data collection tools integrated in the app. These tools
|
||||
are not used to collect personal information:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<b>Google Analytics</b> is used to track browser usage data and
|
||||
application performance. It is configured to anonymize the client IP
|
||||
address.
|
||||
</li>
|
||||
<li>
|
||||
<b>Sentry.io</b> is a tool used to capture and report client-side
|
||||
JavaScript errors. It is configured to not store the client IP
|
||||
address.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Regexper honors the browser <b>“Do Not Track”</b> setting
|
||||
and will not enable these data collection tools if that setting is
|
||||
enabled. Also, most popular ad blockers will prevent these tools from
|
||||
sending any tracking data. Disabling or blocking these data collection
|
||||
tools will <b>not</b> impact the performance of this app. The
|
||||
information collected by these tools is used to monitor application
|
||||
performance, determine browser support, and collect error reports.
|
||||
</p>
|
||||
<p>
|
||||
Regexper is not supported by ad revenue or sales of any kind.
|
||||
</p>
|
||||
</Trans>
|
||||
</Message>;
|
||||
};
|
||||
|
||||
export default PrivacyPolicy;
|
||||
@@ -0,0 +1,16 @@
|
||||
jest.mock('components/Message', () =>
|
||||
require('__mocks__/component-mock')('components/Message'));
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import PrivacyPolicy from 'components/PrivacyPolicy';
|
||||
|
||||
describe('PrivacyPolicy', () => {
|
||||
test('rendering', () => {
|
||||
const { asFragment } = render(
|
||||
<PrivacyPolicy onClose={ jest.fn() } />
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RavenBoundary rendering (with error) 1`] = `<Child />`;
|
||||
|
||||
exports[`RavenBoundary rendering (with error) 2`] = `
|
||||
<Translate(RavenError)
|
||||
details={
|
||||
Object {
|
||||
"extra": Object {
|
||||
"details": "test details",
|
||||
},
|
||||
}
|
||||
}
|
||||
error={
|
||||
Object {
|
||||
"error": "test error",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`RavenBoundary rendering 1`] = `<Child />`;
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import RavenError from 'components/RavenError';
|
||||
|
||||
class RavenBoundary extends React.Component {
|
||||
state = {
|
||||
error: null
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, errorInfo } = this.state;
|
||||
const { children } = this.props;
|
||||
|
||||
if (error) {
|
||||
const errorProps = {
|
||||
details: { extra: errorInfo },
|
||||
error
|
||||
};
|
||||
|
||||
return <RavenError { ...errorProps }/>;
|
||||
} else {
|
||||
return children;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RavenBoundary.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired
|
||||
};
|
||||
|
||||
export default RavenBoundary;
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import RavenBoundary from 'components/RavenBoundary';
|
||||
|
||||
const testError = { error: 'test error' };
|
||||
const testDetails = { details: 'test details' };
|
||||
|
||||
describe('RavenBoundary', () => {
|
||||
test('rendering', () => {
|
||||
const Child = () => <b>Child</b>;
|
||||
const component = shallow(
|
||||
<RavenBoundary>
|
||||
<Child/>
|
||||
</RavenBoundary>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering (with error)', () => {
|
||||
const Child = () => <b>Child</b>;
|
||||
const component = shallow(
|
||||
<RavenBoundary>
|
||||
<Child/>
|
||||
</RavenBoundary>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
component.instance().componentDidCatch(testError, testDetails);
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RavenError rendering 1`] = `
|
||||
<Message
|
||||
heading="translate(An error has occurred)"
|
||||
type="error"
|
||||
>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="This error has been logged"
|
||||
>
|
||||
This error has been logged. You may also
|
||||
<a
|
||||
href="#error-report"
|
||||
onClick={[Function]}
|
||||
>
|
||||
fill out a report
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</Message>
|
||||
`;
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { translate, Trans } from 'react-i18next';
|
||||
import Raven from 'raven-js';
|
||||
|
||||
import Message from 'components/Message';
|
||||
|
||||
class RavenError extends React.Component {
|
||||
componentDidMount() {
|
||||
const { error, details } = this.props;
|
||||
Raven.captureException(error, details);
|
||||
}
|
||||
|
||||
reportError = event => {
|
||||
event.preventDefault();
|
||||
|
||||
if (Raven.lastEventId()) {
|
||||
Raven.showReportDialog();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
|
||||
return <Message type="error" heading={ t('An error has occurred') }>
|
||||
<p><Trans i18nKey="This error has been logged">
|
||||
This error has been logged. You may also <a href="#error-report" onClick={ this.reportError }>fill out a report</a>.
|
||||
</Trans></p>
|
||||
</Message>;
|
||||
}
|
||||
}
|
||||
|
||||
RavenError.propTypes = {
|
||||
error: PropTypes.object.isRequired,
|
||||
details: PropTypes.object.isRequired,
|
||||
t: PropTypes.func
|
||||
};
|
||||
|
||||
export default translate()(RavenError);
|
||||
export { RavenError };
|
||||
@@ -1,65 +0,0 @@
|
||||
jest.mock('raven-js');
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import Raven from 'raven-js';
|
||||
|
||||
import { RavenError } from 'components/RavenError';
|
||||
import { translate } from '__mocks__/i18n';
|
||||
|
||||
const testError = { error: 'test error' };
|
||||
const testDetails = { details: 'test details' };
|
||||
|
||||
describe('RavenError', () => {
|
||||
test('rendering', () => {
|
||||
const component = shallow(
|
||||
<RavenError
|
||||
error={ testError }
|
||||
details={ testDetails }
|
||||
t={ translate }/>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('captures exception', () => {
|
||||
shallow(
|
||||
<RavenError
|
||||
error={ testError }
|
||||
details={ testDetails }
|
||||
t={ translate }/>
|
||||
);
|
||||
expect(Raven.captureException).toHaveBeenCalledWith(testError, testDetails);
|
||||
});
|
||||
|
||||
describe('error reporting', () => {
|
||||
test('clicking to fill out a report when an event has been logged', () => {
|
||||
Raven.lastEventId.mockReturnValue(1);
|
||||
const component = shallow(
|
||||
<RavenError
|
||||
error={ testError }
|
||||
details={ testDetails }
|
||||
t={ translate }/>
|
||||
);
|
||||
const eventObj = { preventDefault: jest.fn() };
|
||||
component.find('a').simulate('click', eventObj);
|
||||
|
||||
expect(eventObj.preventDefault).toHaveBeenCalled();
|
||||
expect(Raven.showReportDialog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clicking to fill out a report when an event has not been logged', () => {
|
||||
Raven.lastEventId.mockReturnValue(false);
|
||||
const component = shallow(
|
||||
<RavenError
|
||||
error={ testError }
|
||||
details={ testDetails }
|
||||
t={ translate }/>
|
||||
);
|
||||
const eventObj = { preventDefault: jest.fn() };
|
||||
component.find('a').simulate('click', eventObj);
|
||||
|
||||
expect(eventObj.preventDefault).toHaveBeenCalled();
|
||||
expect(Raven.showReportDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render debugging 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="render"
|
||||
>
|
||||
<svg
|
||||
style="background-color: rgb(255, 255, 255);"
|
||||
viewBox="0 0 "
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
>
|
||||
<metadata>
|
||||
<rdf:rdf>
|
||||
|
||||
|
||||
<cc:license
|
||||
rdf:about="http://creativecommons.org/licenses/by/3.0/"
|
||||
>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
|
||||
|
||||
</cc:license>
|
||||
|
||||
|
||||
</rdf:rdf>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(10 10)"
|
||||
>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Example
|
||||
</span>
|
||||
<rect
|
||||
height="50"
|
||||
style="fill: transparent; stroke: red; stroke-width: 1px; stroke-dasharray: 2,2; opacity: 0.5;"
|
||||
width="100"
|
||||
/>
|
||||
<circle
|
||||
cx="5"
|
||||
cy="10"
|
||||
r="3"
|
||||
style="fill: red; opacity: 0.5;"
|
||||
/>
|
||||
<circle
|
||||
cx="95"
|
||||
cy="10"
|
||||
r="3"
|
||||
style="fill: red; opacity: 0.5;"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Render types Box 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="render"
|
||||
>
|
||||
<svg
|
||||
style="background-color: rgb(255, 255, 255);"
|
||||
viewBox="0 0 "
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
>
|
||||
<metadata>
|
||||
<rdf:rdf>
|
||||
|
||||
|
||||
<cc:license
|
||||
rdf:about="http://creativecommons.org/licenses/by/3.0/"
|
||||
>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
|
||||
|
||||
</cc:license>
|
||||
|
||||
|
||||
</rdf:rdf>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(10 10)"
|
||||
>
|
||||
<span
|
||||
data-component="Box"
|
||||
data-props="{}"
|
||||
>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Example
|
||||
</span>
|
||||
</span>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Render types HorizontalLayout 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="render"
|
||||
>
|
||||
<svg
|
||||
style="background-color: rgb(255, 255, 255);"
|
||||
viewBox="0 0 "
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
>
|
||||
<metadata>
|
||||
<rdf:rdf>
|
||||
|
||||
|
||||
<cc:license
|
||||
rdf:about="http://creativecommons.org/licenses/by/3.0/"
|
||||
>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
|
||||
|
||||
</cc:license>
|
||||
|
||||
|
||||
</rdf:rdf>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(10 10)"
|
||||
>
|
||||
<span
|
||||
data-component="HorizontalLayout"
|
||||
data-props="{}"
|
||||
>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Example
|
||||
</span>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Another Example
|
||||
</span>
|
||||
</span>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Render types Loop 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="render"
|
||||
>
|
||||
<svg
|
||||
style="background-color: rgb(255, 255, 255);"
|
||||
viewBox="0 0 "
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
>
|
||||
<metadata>
|
||||
<rdf:rdf>
|
||||
|
||||
|
||||
<cc:license
|
||||
rdf:about="http://creativecommons.org/licenses/by/3.0/"
|
||||
>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
|
||||
|
||||
</cc:license>
|
||||
|
||||
|
||||
</rdf:rdf>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(10 10)"
|
||||
>
|
||||
<span
|
||||
data-component="Loop"
|
||||
data-props="{}"
|
||||
>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Example
|
||||
</span>
|
||||
</span>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Render types Pin 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="render"
|
||||
>
|
||||
<svg
|
||||
style="background-color: rgb(255, 255, 255);"
|
||||
viewBox="0 0 "
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
>
|
||||
<metadata>
|
||||
<rdf:rdf>
|
||||
|
||||
|
||||
<cc:license
|
||||
rdf:about="http://creativecommons.org/licenses/by/3.0/"
|
||||
>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
|
||||
|
||||
</cc:license>
|
||||
|
||||
|
||||
</rdf:rdf>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(10 10)"
|
||||
>
|
||||
<span
|
||||
data-component="Pin"
|
||||
data-props="{}"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Render types Text 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="render"
|
||||
>
|
||||
<svg
|
||||
style="background-color: rgb(255, 255, 255);"
|
||||
viewBox="0 0 "
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
>
|
||||
<metadata>
|
||||
<rdf:rdf>
|
||||
|
||||
|
||||
<cc:license
|
||||
rdf:about="http://creativecommons.org/licenses/by/3.0/"
|
||||
>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
|
||||
|
||||
</cc:license>
|
||||
|
||||
|
||||
</rdf:rdf>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(10 10)"
|
||||
>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Example
|
||||
</span>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`Render types VerticalLayout 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="render"
|
||||
>
|
||||
<svg
|
||||
style="background-color: rgb(255, 255, 255);"
|
||||
viewBox="0 0 "
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
>
|
||||
<metadata>
|
||||
<rdf:rdf>
|
||||
|
||||
|
||||
<cc:license
|
||||
rdf:about="http://creativecommons.org/licenses/by/3.0/"
|
||||
>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Notice"
|
||||
/>
|
||||
|
||||
|
||||
<cc:requires
|
||||
rdf:resource="http://creativecommons.org/ns#Attribution"
|
||||
/>
|
||||
|
||||
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
|
||||
|
||||
</cc:license>
|
||||
|
||||
|
||||
</rdf:rdf>
|
||||
</metadata>
|
||||
<g
|
||||
transform="translate(10 10)"
|
||||
>
|
||||
<span
|
||||
data-component="VerticalLayout"
|
||||
data-props="{}"
|
||||
>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Example
|
||||
</span>
|
||||
<span
|
||||
data-component="Text"
|
||||
data-props="{}"
|
||||
>
|
||||
Another Example
|
||||
</span>
|
||||
</span>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useRef, useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import nodeTypes from 'rendering/types';
|
||||
|
||||
import style from './style.module.css';
|
||||
|
||||
const debugBox = {
|
||||
fill: 'transparent',
|
||||
stroke: 'red',
|
||||
strokeWidth: '1px',
|
||||
strokeDasharray: '2,2',
|
||||
opacity: 0.5
|
||||
};
|
||||
const debugPin = {
|
||||
fill: 'red',
|
||||
opacity: 0.5
|
||||
};
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const renderDebug = ({ x, y, width, height, axisX1, axisX2, axisY }) => <>
|
||||
<rect style={ debugBox } x={ x } y={ y } width={ width } height={ height }/>
|
||||
<circle style={ debugPin } cx={ axisX1 } cy={ axisY } r="3" />
|
||||
<circle style={ debugPin } cx={ axisX2 } cy={ axisY } r="3" />
|
||||
</>;
|
||||
|
||||
const render = (data, key) => {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const { type, props, debug, box } = data;
|
||||
const children = (data.children || []).map(render);
|
||||
|
||||
return <React.Fragment key={ key }>
|
||||
{ React.createElement(
|
||||
nodeTypes[type] ? nodeTypes[type].default : type,
|
||||
props,
|
||||
children.length === 1 ? children[0] : children) }
|
||||
{ debug && renderDebug(box) }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
const Render = ({ data, onRender }) => {
|
||||
const svgContainer = useRef();
|
||||
|
||||
const provideSVGData = useCallback(() => {
|
||||
if (!svgContainer.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = svgContainer.current.querySelector('svg');
|
||||
onRender({
|
||||
svg: svg.outerHTML,
|
||||
width: Number(svg.getAttribute('width')),
|
||||
height: Number(svg.getAttribute('height'))
|
||||
});
|
||||
}, [svgContainer, onRender]);
|
||||
|
||||
useEffect(() => {
|
||||
provideSVGData();
|
||||
}, [provideSVGData]);
|
||||
|
||||
return <div className={ style.render } ref={ svgContainer }>
|
||||
{ render({
|
||||
...data,
|
||||
props: {
|
||||
...data.props,
|
||||
onReflow: provideSVGData
|
||||
}
|
||||
}) }
|
||||
</div>;
|
||||
};
|
||||
|
||||
Render.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
onRender: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Render;
|
||||
@@ -0,0 +1,15 @@
|
||||
@import url('../../globals.module.css');
|
||||
|
||||
.render {
|
||||
width: 100%;
|
||||
background: var(--color-white);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
margin: var(--spacing-margin) 0;
|
||||
|
||||
& svg {
|
||||
display: block;
|
||||
transform: scaleZ(1); /* Move to separate render layer in Chrome */
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
jest.mock('rendering/Box', () =>
|
||||
require('__mocks__/component-mock')('rendering/Box'));
|
||||
jest.mock('rendering/HorizontalLayout', () =>
|
||||
require('__mocks__/component-mock')('rendering/HorizontalLayout'));
|
||||
jest.mock('rendering/Loop', () =>
|
||||
require('__mocks__/component-mock')('rendering/Loop'));
|
||||
jest.mock('rendering/Pin', () =>
|
||||
require('__mocks__/component-mock')('rendering/Pin'));
|
||||
jest.mock('rendering/Text', () =>
|
||||
require('__mocks__/component-mock')('rendering/Text'));
|
||||
jest.mock('rendering/VerticalLayout', () =>
|
||||
require('__mocks__/component-mock')('rendering/VerticalLayout'));
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'react-testing-library';
|
||||
|
||||
import Render from 'components/Render';
|
||||
|
||||
const testType = (name, item) => {
|
||||
test(name, () => {
|
||||
const data = { type: 'SVG', children: [item] };
|
||||
const { asFragment } = render(
|
||||
<Render data={ data } onRender={ jest.fn() }/>
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
test('debugging', () => {
|
||||
const data = {
|
||||
type: 'SVG',
|
||||
children: [
|
||||
{
|
||||
type: 'Text',
|
||||
debug: true,
|
||||
box: {
|
||||
width: 100,
|
||||
height: 50,
|
||||
axisY: 10,
|
||||
axisX1: 5,
|
||||
axisX2: 95
|
||||
},
|
||||
children: [
|
||||
'Example'
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
const { asFragment } = render(
|
||||
<Render data={ data } onRender={ jest.fn() }/>
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('types', () => {
|
||||
testType('Pin', {
|
||||
type: 'Pin'
|
||||
});
|
||||
|
||||
testType('Text', {
|
||||
type: 'Text',
|
||||
children: [
|
||||
'Example'
|
||||
]
|
||||
});
|
||||
|
||||
testType('Box', {
|
||||
type: 'Box',
|
||||
children: [
|
||||
{
|
||||
type: 'Text',
|
||||
children: [
|
||||
'Example'
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
testType('Loop', {
|
||||
type: 'Loop',
|
||||
children: [
|
||||
{
|
||||
type: 'Text',
|
||||
children: [
|
||||
'Example'
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
testType('HorizontalLayout', {
|
||||
type: 'HorizontalLayout',
|
||||
children: [
|
||||
{
|
||||
type: 'Text',
|
||||
children: [
|
||||
'Example'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'Text',
|
||||
children: [
|
||||
'Another Example'
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
testType('VerticalLayout', {
|
||||
type: 'VerticalLayout',
|
||||
children: [
|
||||
{
|
||||
type: 'Text',
|
||||
children: [
|
||||
'Example'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'Text',
|
||||
children: [
|
||||
'Another Example'
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from './style';
|
||||
|
||||
import reflowable from './reflowable';
|
||||
|
||||
@reflowable
|
||||
class Box extends React.PureComponent {
|
||||
static defaultProps = {
|
||||
padding: 5,
|
||||
radius: 3
|
||||
}
|
||||
|
||||
label = React.createRef()
|
||||
|
||||
children = [React.createRef()]
|
||||
|
||||
reflow() {
|
||||
const { padding, useAnchors } = this.props;
|
||||
const box = this.children[0].current.getBBox();
|
||||
const labelBox = this.label.current ? this.label.current.getBBox() : { width: 0, height: 0};
|
||||
|
||||
this.setBBox({
|
||||
width: Math.max(box.width + 2 * padding, labelBox.width),
|
||||
height: box.height + 2 * padding + labelBox.height,
|
||||
axisY: (useAnchors ? box.axisY : box.height / 2) + padding + labelBox.height,
|
||||
axisX1: useAnchors ? box.axisX1 + padding : 0,
|
||||
axisX2: useAnchors ? box.axisX2 + padding : box.width + 2 * padding
|
||||
});
|
||||
|
||||
this.setStateAsync({
|
||||
width: this.getBBox().width,
|
||||
height: box.height + 2 * padding,
|
||||
contentTransform: `translate(${ padding } ${ padding + labelBox.height })`,
|
||||
rectTransform: `translate(0 ${ labelBox.height })`,
|
||||
labelTransform: `translate(0 ${ labelBox.height })`
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { theme, radius, label, children } = this.props;
|
||||
const { width, height, labelTransform, rectTransform, contentTransform } = this.state || {};
|
||||
|
||||
const rectProps = {
|
||||
style: style[theme],
|
||||
width,
|
||||
height,
|
||||
rx: radius,
|
||||
ry: radius,
|
||||
transform: rectTransform
|
||||
};
|
||||
const textProps = {
|
||||
transform: labelTransform,
|
||||
style: style.infoText,
|
||||
ref: this.label
|
||||
};
|
||||
|
||||
return <React.Fragment>
|
||||
<rect { ...rectProps } ></rect>
|
||||
{ label && <text { ...textProps }>{ label }</text> }
|
||||
<g transform={ contentTransform }>
|
||||
{ React.cloneElement(React.Children.only(children), {
|
||||
ref: this.children[0]
|
||||
}) }
|
||||
</g>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
Box.propTypes = {
|
||||
children: PropTypes.node,
|
||||
label: PropTypes.string,
|
||||
padding: PropTypes.number,
|
||||
useAnchors: PropTypes.bool,
|
||||
radius: PropTypes.number,
|
||||
theme: PropTypes.string
|
||||
};
|
||||
|
||||
export default Box;
|
||||
@@ -1,53 +0,0 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import Box from './Box';
|
||||
|
||||
import SVGElement from '__mocks__/SVGElement';
|
||||
|
||||
const originalGetBBox = window.Element.prototype.getBBox;
|
||||
|
||||
describe('Box', () => {
|
||||
beforeEach(() => {
|
||||
window.Element.prototype.getBBox = function() {
|
||||
return { width: 100, height: 10 };
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.Element.prototype.getBBox = originalGetBBox;
|
||||
});
|
||||
|
||||
test('rendering', async () => {
|
||||
const component = mount(
|
||||
<Box>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Box>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with content anchors', async () => {
|
||||
const component = mount(
|
||||
<Box useAnchors>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Box>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with label', async () => {
|
||||
const component = mount(
|
||||
<Box label="Test label">
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Box>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { List } from 'immutable';
|
||||
|
||||
import style from './style';
|
||||
|
||||
import reflowable from './reflowable';
|
||||
import Path from './path';
|
||||
|
||||
@reflowable
|
||||
class HorizontalLayout extends React.PureComponent {
|
||||
static defaultProps = {
|
||||
withConnectors: false,
|
||||
spacing: 10
|
||||
}
|
||||
|
||||
state = {
|
||||
childTransforms: List()
|
||||
}
|
||||
|
||||
children = []
|
||||
|
||||
updateChildTransforms(childBoxes) {
|
||||
return this.state.childTransforms.withMutations(transforms => (
|
||||
childBoxes.forEach((box, i) => (
|
||||
transforms.set(i, `translate(${ box.offsetX } ${ box.offsetY })`)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
updateConnectorPaths(childBoxes) {
|
||||
let last = childBoxes[0];
|
||||
|
||||
return childBoxes.slice(1).reduce((path, box) => {
|
||||
try {
|
||||
return path
|
||||
.moveTo({ x: last.offsetX + last.axisX2, y: this.getBBox().axisY })
|
||||
.lineTo({ x: box.offsetX + box.axisX1 });
|
||||
}
|
||||
finally {
|
||||
last = box;
|
||||
}
|
||||
}, new Path()).toString();
|
||||
}
|
||||
|
||||
reflow() {
|
||||
const { spacing, withConnectors } = this.props;
|
||||
|
||||
const childBoxes = this.children.map(child => child.current.getBBox());
|
||||
const verticalCenter = childBoxes.reduce((center, box) => Math.max(center, box.axisY), 0);
|
||||
const width = childBoxes.reduce((width, box) => width + box.width, 0) + (childBoxes.length - 1) * spacing;
|
||||
const height = childBoxes.reduce((ascHeight, box) => Math.max(ascHeight, box.axisY), 0) +
|
||||
childBoxes.reduce((decHeight, box) => Math.max(decHeight, box.height - box.axisY), 0);
|
||||
this.setBBox({ width, height, axisY: verticalCenter }, { axisX1: true, axisX2: true });
|
||||
|
||||
let offset = 0;
|
||||
childBoxes.forEach(box => {
|
||||
box.offsetX = offset;
|
||||
box.offsetY = this.getBBox().axisY - box.axisY;
|
||||
offset += box.width + spacing;
|
||||
});
|
||||
|
||||
this.setStateAsync({
|
||||
childTransforms: this.updateChildTransforms(childBoxes),
|
||||
connectorPaths: withConnectors ? this.updateConnectorPaths(childBoxes) : ''
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { childTransforms, connectorPaths } = this.state;
|
||||
|
||||
this.makeRefCollection(this.children, React.Children.count(children));
|
||||
|
||||
return <React.Fragment>
|
||||
<path d={ connectorPaths } style={ style.connectors }></path>
|
||||
{ React.Children.map(children, (child, i) => (
|
||||
<g transform={ childTransforms.get(i) }>
|
||||
{ React.cloneElement(child, {
|
||||
ref: this.children[i]
|
||||
}) }
|
||||
</g>
|
||||
))}
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired,
|
||||
spacing: PropTypes.number,
|
||||
withConnectors: PropTypes.bool
|
||||
};
|
||||
|
||||
export default HorizontalLayout;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import HorizontalLayout from './HorizontalLayout';
|
||||
|
||||
import SVGElement from '__mocks__/SVGElement';
|
||||
|
||||
describe('HorizontalLayout', () => {
|
||||
test('rendering', async () => {
|
||||
const component = mount(
|
||||
<HorizontalLayout>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</HorizontalLayout>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with connectors', async () => {
|
||||
const component = mount(
|
||||
<HorizontalLayout withConnectors>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</HorizontalLayout>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from './style';
|
||||
|
||||
import reflowable from './reflowable';
|
||||
|
||||
const namespaceProps = {
|
||||
'xmlns': 'http://www.w3.org/2000/svg',
|
||||
'xmlns:cc': 'http://creativecommons.org/ns#',
|
||||
'xmlns:rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
|
||||
};
|
||||
const metadata = `<rdf:rdf>
|
||||
<cc:license rdf:about="http://creativecommons.org/licenses/by/3.0/">
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"></cc:permits>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"></cc:permits>
|
||||
<cc:requires rdf:resource="http://creativecommons.org/ns#Notice"></cc:requires>
|
||||
<cc:requires rdf:resource="http://creativecommons.org/ns#Attribution"></cc:requires>
|
||||
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"></cc:permits>
|
||||
</cc:license>
|
||||
</rdf:rdf>`;
|
||||
|
||||
@reflowable
|
||||
class Image extends React.PureComponent {
|
||||
static defaultProps = {
|
||||
padding: 10
|
||||
}
|
||||
|
||||
state = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
svg = React.createRef()
|
||||
|
||||
children = [React.createRef()]
|
||||
|
||||
async svgUrl(type) {
|
||||
const markup = this.svg.current.outerHTML;
|
||||
return new Blob([markup], { type });
|
||||
}
|
||||
|
||||
async pngUrl(type) {
|
||||
const markup = this.svg.current.outerHTML;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
const loader = new window.Image(); // Using window.Image to avoid name conflict :(
|
||||
|
||||
loader.width = canvas.width = Number(this.svg.current.getAttribute('width')) * 2;
|
||||
loader.height = canvas.height = Number(this.svg.current.getAttribute('height')) * 2;
|
||||
|
||||
await new Promise(resolve => {
|
||||
loader.onload = resolve;
|
||||
loader.src = 'data:image/svg+xml,' + encodeURIComponent(markup);
|
||||
});
|
||||
|
||||
context.drawImage(loader, 0, 0, loader.width, loader.height);
|
||||
return new Promise(resolve => canvas.toBlob(resolve, type));
|
||||
}
|
||||
|
||||
reflow() {
|
||||
const { padding } = this.props;
|
||||
const box = this.children[0].current.getBBox();
|
||||
|
||||
this.setStateAsync({
|
||||
width: Math.round(box.width + 2 * padding),
|
||||
height: Math.round(box.height + 2 * padding)
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { width, height } = this.state;
|
||||
const { padding, children } = this.props;
|
||||
|
||||
const svgProps = {
|
||||
width,
|
||||
height,
|
||||
viewBox: [0, 0, width, height].join(' '),
|
||||
style: style.image,
|
||||
ref: this.svg,
|
||||
...namespaceProps
|
||||
};
|
||||
|
||||
return <svg { ...svgProps }>
|
||||
<metadata dangerouslySetInnerHTML={{ __html: metadata }}></metadata>
|
||||
<g transform={ `translate(${ padding } ${ padding })` }>
|
||||
{ React.cloneElement(React.Children.only(children), {
|
||||
ref: this.children[0]
|
||||
}) }
|
||||
</g>
|
||||
</svg>;
|
||||
}
|
||||
}
|
||||
|
||||
Image.propTypes = {
|
||||
children: PropTypes.node,
|
||||
padding: PropTypes.number
|
||||
};
|
||||
|
||||
export default Image;
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import Image from './Image';
|
||||
|
||||
import SVGElement from '__mocks__/SVGElement';
|
||||
|
||||
describe('Image', () => {
|
||||
test('rendering', async () => {
|
||||
const component = mount(
|
||||
<Image>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Image>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from './style';
|
||||
|
||||
import reflowable from './reflowable';
|
||||
import Path from './path';
|
||||
|
||||
const skipPath = (box, greedy) => {
|
||||
const vert = Math.max(0, box.axisY - 10);
|
||||
const horiz = box.width - 10;
|
||||
|
||||
let path = new Path({ relative: true });
|
||||
|
||||
if (!greedy) {
|
||||
path
|
||||
.moveTo({ x: 10, y: box.axisY + box.offsetY - 15, relative: false })
|
||||
.lineTo({ x: 5, y: 5 })
|
||||
.moveTo({ x: -5, y: -5 })
|
||||
.lineTo({ x: -5, y: 5 });
|
||||
}
|
||||
|
||||
return path
|
||||
.moveTo({ x: 0, y: box.axisY + box.offsetY, relative: false })
|
||||
.quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: -10 })
|
||||
.lineTo({ y: -vert })
|
||||
.quadraticCurveTo({ cx: 0, cy: -10, x: 10, y: -10 })
|
||||
.lineTo({ x: horiz })
|
||||
.quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: 10 })
|
||||
.lineTo({ y: vert })
|
||||
.quadraticCurveTo({ cx: 0, cy: 10, x: 10, y: 10 });
|
||||
};
|
||||
|
||||
const repeatPath = (box, greedy) => {
|
||||
const vert = box.height - box.axisY - 10;
|
||||
|
||||
let path = new Path({ relative: true });
|
||||
|
||||
if (greedy) {
|
||||
path
|
||||
.moveTo({ x: box.offsetX + box.width + 10, y: box.axisY + box.offsetY + 15, relative: false })
|
||||
.lineTo({ x: 5, y: -5 })
|
||||
.moveTo({ x: -5, y: 5 })
|
||||
.lineTo({ x: -5, y: -5 });
|
||||
}
|
||||
|
||||
return path
|
||||
.moveTo({ x: box.offsetX, y: box.axisY + box.offsetY, relative: false })
|
||||
.quadraticCurveTo({ cx: -10, cy: 0, x: -10, y: 10 })
|
||||
.lineTo({ y: vert })
|
||||
.quadraticCurveTo({ cx: 0, cy: 10, x: 10, y: 10 })
|
||||
.lineTo({ x: box.width })
|
||||
.quadraticCurveTo({ cx: 10, cy: 0, x: 10, y: -10 })
|
||||
.lineTo({ y: -vert })
|
||||
.quadraticCurveTo({ cx: 0, cy: -10, x: -10, y: -10 });
|
||||
};
|
||||
|
||||
@reflowable
|
||||
class Loop extends React.PureComponent {
|
||||
label = React.createRef()
|
||||
|
||||
children = [React.createRef()]
|
||||
|
||||
get contentOffset() {
|
||||
const { skip, repeat } = this.props;
|
||||
|
||||
if (skip) {
|
||||
return { x: 15, y: 10 };
|
||||
} else if (repeat) {
|
||||
return { x: 10, y: 0 };
|
||||
} else {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
reflow() {
|
||||
const { skip, repeat, greedy } = this.props;
|
||||
const box = this.children[0].current.getBBox();
|
||||
const labelBox = this.label.current ? this.label.current.getBBox() : { width: 0, height: 0 };
|
||||
|
||||
let height = box.height + labelBox.height;
|
||||
if (skip) {
|
||||
height += 10;
|
||||
}
|
||||
if (repeat) {
|
||||
height += 10;
|
||||
}
|
||||
|
||||
this.setBBox({
|
||||
width: box.width + this.contentOffset.x * 2,
|
||||
height,
|
||||
axisY: box.axisY + this.contentOffset.y,
|
||||
axisX1: box.axisX1 + this.contentOffset.x,
|
||||
axisX2: box.axisX2 + this.contentOffset.x
|
||||
});
|
||||
|
||||
box.offsetX = this.contentOffset.x;
|
||||
box.offsetY = this.contentOffset.y;
|
||||
|
||||
this.setStateAsync({
|
||||
labelTransform: `translate(${ this.getBBox().width - labelBox.width - 10 } ${ this.getBBox().height + 2 })`,
|
||||
loopPaths: [
|
||||
skip && skipPath(box, greedy),
|
||||
repeat && repeatPath(box, greedy)
|
||||
].filter(Boolean).join('')
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { label, children } = this.props;
|
||||
const { loopPaths, labelTransform } = this.state || {};
|
||||
|
||||
const textProps = {
|
||||
transform: labelTransform,
|
||||
style: style.infoText,
|
||||
ref: this.label
|
||||
};
|
||||
|
||||
return <React.Fragment>
|
||||
<path d={ loopPaths } style={ style.connectors }></path>
|
||||
{ label && <text { ...textProps }>{ label }</text> }
|
||||
<g transform={ `translate(${ this.contentOffset.x } ${ this.contentOffset.y })` }>
|
||||
{ React.cloneElement(React.Children.only(children), {
|
||||
ref: this.children[0]
|
||||
}) }
|
||||
</g>
|
||||
</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
Loop.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
greedy: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
skip: PropTypes.bool,
|
||||
repeat: PropTypes.bool
|
||||
};
|
||||
|
||||
export default Loop;
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import Loop from './Loop';
|
||||
|
||||
import SVGElement from '__mocks__/SVGElement';
|
||||
|
||||
const originalGetBBox = window.Element.prototype.getBBox;
|
||||
|
||||
describe('Loop', () => {
|
||||
beforeEach(() => {
|
||||
window.Element.prototype.getBBox = function() {
|
||||
return { width: 100, height: 10 };
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.Element.prototype.getBBox = originalGetBBox;
|
||||
});
|
||||
|
||||
test('rendering', async () => {
|
||||
const component = mount(
|
||||
<Loop>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Loop>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with skip path', async () => {
|
||||
const component = mount(
|
||||
<Loop skip>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Loop>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with repeat path', async () => {
|
||||
const component = mount(
|
||||
<Loop repeat>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Loop>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with repeat path and label', async () => {
|
||||
const component = mount(
|
||||
<Loop repeat label="Test label">
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Loop>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with skip and repeat paths', async () => {
|
||||
const component = mount(
|
||||
<Loop skip repeat>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Loop>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with greedy skip and repeat paths', async () => {
|
||||
const component = mount(
|
||||
<Loop greedy skip repeat>
|
||||
<SVGElement bbox={{ width: 100, height: 100 }}/>
|
||||
</Loop>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from './style';
|
||||
|
||||
import reflowable from './reflowable';
|
||||
|
||||
@reflowable
|
||||
class Pin extends React.PureComponent {
|
||||
static defaultProps = {
|
||||
radius: 5
|
||||
}
|
||||
|
||||
reflow() {
|
||||
const { radius } = this.props;
|
||||
|
||||
this.setBBox({
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { radius } = this.props;
|
||||
|
||||
const circleProps = {
|
||||
r: radius,
|
||||
style: style.pin,
|
||||
transform: `translate(${ radius } ${ radius })`
|
||||
};
|
||||
|
||||
return <circle { ...circleProps }></circle>;
|
||||
}
|
||||
}
|
||||
|
||||
Pin.propTypes = {
|
||||
radius: PropTypes.number
|
||||
};
|
||||
|
||||
export default Pin;
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import Pin from './Pin';
|
||||
|
||||
describe('Pin', () => {
|
||||
test('rendering', async () => {
|
||||
const component = mount(
|
||||
<Pin/>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from './style';
|
||||
|
||||
import reflowable from './reflowable';
|
||||
|
||||
@reflowable
|
||||
class Text extends React.PureComponent {
|
||||
text = React.createRef()
|
||||
|
||||
reflow() {
|
||||
const box = this.text.current.getBBox();
|
||||
|
||||
this.setBBox({
|
||||
width: box.width,
|
||||
height: box.height
|
||||
});
|
||||
|
||||
this.setStateAsync({
|
||||
transform: `translate(${-box.x} ${-box.y})`
|
||||
});
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { children, quoted } = this.props;
|
||||
if (!quoted) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<tspan style={ style.textQuote }>“</tspan>
|
||||
<tspan>{ children }</tspan>
|
||||
<tspan style={ style.textQuote }>”</tspan>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { theme } = this.props;
|
||||
const { transform } = this.state || {};
|
||||
|
||||
const textProps = {
|
||||
style: { ...style.text, ...style[theme] },
|
||||
transform,
|
||||
ref: this.text
|
||||
};
|
||||
|
||||
return <text { ...textProps }>
|
||||
{ this.renderContent() }
|
||||
</text>;
|
||||
}
|
||||
}
|
||||
|
||||
Text.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
]).isRequired,
|
||||
quoted: PropTypes.bool,
|
||||
theme: PropTypes.string
|
||||
};
|
||||
|
||||
export default Text;
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import Text from './Text';
|
||||
|
||||
const originalGetBBox = window.Element.prototype.getBBox;
|
||||
|
||||
describe('Text', () => {
|
||||
beforeEach(() => {
|
||||
window.Element.prototype.getBBox = function() {
|
||||
return { x: 10, y: 10 };
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.Element.prototype.getBBox = originalGetBBox;
|
||||
});
|
||||
|
||||
test('rendering', async () => {
|
||||
const component = mount(
|
||||
<Text>Test content</Text>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('rendering with quotes', async () => {
|
||||
const component = mount(
|
||||
<Text quoted>Test content</Text>
|
||||
);
|
||||
await component.instance().doReflow();
|
||||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user