190 Commits

Author SHA1 Message Date
Jeff Avallone 3b07ed2ab5 Updating FormActions to use hooks 2019-03-28 07:12:41 -04:00
Jeff Avallone 97878c3881 Updating Render to use hooks 2019-03-28 06:56:31 -04:00
Jeff Avallone 14c1e14f51 Updating Header to use hooks 2019-03-28 06:49:38 -04:00
Jeff Avallone 1621e0e16a Removing mockT from Footer tests 2019-03-28 06:49:29 -04:00
Jeff Avallone 8d78f41ed6 Addressing Helmet crash 2019-03-28 06:47:45 -04:00
Jeff Avallone 42b3d0a9d8 Updating Form to use hooks 2019-03-26 21:51:49 -04:00
Jeff Avallone 3105371954 Updating LocaleSwitcher to use hooks 2019-03-26 21:25:32 -04:00
Jeff Avallone 18427f9fc6 Updating InstallPrompt to use hooks 2019-03-26 21:12:35 -04:00
Jeff Avallone f9b3d5dbd7 Updating PrivacyPolicy to use hooks 2019-03-26 20:42:16 -04:00
Jeff Avallone c6020a225f Updating Footer to use hooks 2019-03-26 20:39:40 -04:00
Jeff Avallone e01b6d424e Updating SentryError to use hooks 2019-03-26 20:38:07 -04:00
Jeff Avallone 94d7f3d3ce Updating Metadata to use hooks 2019-03-26 18:44:35 -04:00
Jeff Avallone 8186e1cf87 Updating Loader to use hooks 2019-03-26 18:44:35 -04:00
Jeff Avallone 461d8aa7ac Cleanup tests for index page 2019-03-26 18:44:35 -04:00
Jeff Avallone 110543a537 Updatnig privacy page to use hooks 2019-03-26 18:44:35 -04:00
Jeff Avallone aa10ecf18c Converting 404 page to use hooks 2019-03-26 18:44:35 -04:00
Jeff Avallone 280412e2db Mocking the useTranslation hook 2019-03-26 18:44:35 -04:00
Jeff Avallone dd8c405482 Merge branch 'feature/gatsby-no-enzyme' into 'gatsby'
Replacing Enzyme with react-testing-library

See merge request javallone/regexper-static!42
2019-03-25 01:51:26 +00:00
Jeff Avallone ead8927b02 Updating App to use react-testing-library 2019-03-24 21:48:57 -04:00
Jeff Avallone d46ce46b93 Updating FormActions to use react-testing-library 2019-03-24 21:31:59 -04:00
Jeff Avallone 07cd0a4799 Updating Form to use react-testing-library 2019-03-24 21:19:46 -04:00
Jeff Avallone 0473c27e39 Updating Render to use react-testing-library 2019-03-24 19:46:58 -04:00
Jeff Avallone 26e0776f1d Disabling yet-to-be-updated tests 2019-03-24 14:58:12 -04:00
Jeff Avallone c8068762b5 Updating Pin rendering to use react-testing-library 2019-03-24 14:55:10 -04:00
Jeff Avallone f63e9b2a47 Updating Text rendering to use react-testing-library 2019-03-24 14:54:13 -04:00
Jeff Avallone e449eade9d Updating Box rendering to use react-testing-library 2019-03-24 14:51:01 -04:00
Jeff Avallone 84fd88f1d0 Updating SVG render to use react-testing-library 2019-03-24 14:38:44 -04:00
Jeff Avallone f11c41b05e Updating LocaleSwitcher to use react-testing-library 2019-03-24 14:32:11 -04:00
Jeff Avallone 0d512a1a4d Updating Metadata to use react-testing-library 2019-03-24 14:16:07 -04:00
Jeff Avallone a1281543e2 Updating Messasge to use react-testing-library 2019-03-24 14:09:05 -04:00
Jeff Avallone 53def33627 Updating Header to use react-testing-library 2019-03-24 14:03:39 -04:00
Jeff Avallone 791142005e Supporting different module types 2019-03-24 13:47:34 -04:00
Jeff Avallone 4d31079ca2 Updating InstallPrompt to use react-testing-library 2019-03-24 13:47:00 -04:00
Jeff Avallone e29b40990c Updating SentryBoundary to use react-testing-library 2019-03-24 13:18:29 -04:00
Jeff Avallone 092fd39da6 Updating tests for Loader to use react-testing-library 2019-03-24 13:07:56 -04:00
Jeff Avallone ace907779f Updating Layout to use react-testing-library 2019-03-24 13:06:11 -04:00
Jeff Avallone 383197a4c1 Updating Footer to use react-testing-library 2019-03-24 13:04:06 -04:00
Jeff Avallone ced6c42c3d Updating PrivacyPolicy to use react-testing-library 2019-03-24 13:02:22 -04:00
Jeff Avallone 9f15287fcc Updating privacy page to use react-testing-library 2019-03-24 13:01:03 -04:00
Jeff Avallone 5d700f47aa Updating 404 page to use react-testing-library 2019-03-24 12:59:31 -04:00
Jeff Avallone 58dc36b0c6 Updating index page tests to use react-testing-library 2019-03-24 12:57:53 -04:00
Jeff Avallone d813fdf742 Updating component mock to handle non-standard attrs 2019-03-24 12:57:46 -04:00
Jeff Avallone babab418c7 Replacing enzyme in SentryError tests 2019-03-24 12:15:24 -04:00
Jeff Avallone 143a18807e Adding mocking tools for components 2019-03-24 12:15:06 -04:00
Jeff Avallone 4fd02d661d Removing enzyme 2019-03-24 12:13:50 -04:00
Jeff Avallone 728a7fbb14 Adding react-testing-library 2019-03-24 12:08:36 -04:00
Jeff Avallone 2f7c67c953 Adding a jest transform to handle yaml files 2019-03-23 18:51:32 -04:00
Jeff Avallone b5cde63e4e Disabling i18next XHR backend
This gets the build working until I can figure out the issues between it
and react-i18next
2019-03-23 18:08:12 -04:00
Jeff Avallone 274fdf038d Adding late-breaking upgrades 2019-03-23 17:34:37 -04:00
Jeff Avallone 51c21881cc Upgarding dependencies 2019-03-23 16:11:24 -04:00
Jeff Avallone 59ebe433b6 Tests for Box rendering component 2019-02-01 18:06:29 -05:00
Jeff Avallone 5a782b439e Adding tests for rendering layout 2019-02-01 08:49:55 -05:00
Jeff Avallone b3b7480358 Adding some initial tests for Render component 2019-01-31 21:51:53 -05:00
Jeff Avallone b7393b3a4b Breaking getBBox function out of layout 2019-01-31 21:32:14 -05:00
Jeff Avallone a13f26286d Moving quadratic curve helper to be owned by VerticalLayout 2019-01-31 21:28:31 -05:00
Jeff Avallone 8830fad923 Tests for SVG rendering component 2019-01-31 21:19:19 -05:00
Jeff Avallone 47ee62d387 Tests for Pin rendering component 2019-01-31 21:15:11 -05:00
Jeff Avallone ee915c39dc Tests for Text rendering component 2019-01-31 21:10:33 -05:00
Jeff Avallone f4e7bc0e76 Adding Loop rendering component 2019-01-31 18:20:58 -05:00
Jeff Avallone c1716570a8 Fixing calculation of axis for HorizontalLayout 2019-01-31 17:33:10 -05:00
Jeff Avallone 3920c716e4 Fixing text positioning
IE & Edge don't support dominant-baseline
2019-01-31 17:08:30 -05:00
Jeff Avallone 91ab1dbd05 Adding VerticalLayout rendering component 2019-01-30 18:12:23 -05:00
Jeff Avallone 67ca83ff16 Updating demos 2019-01-29 17:48:14 -05:00
Jeff Avallone 97509773af Adding useAnchor prop to Box and calculating axis coords 2019-01-29 17:47:52 -05:00
Jeff Avallone cdb77255a7 Cleaning up HorizontalLayout path calculations 2019-01-29 17:47:22 -05:00
Jeff Avallone b47d03cb31 Switching to an SVG for CC license logo 2019-01-29 17:23:54 -05:00
Jeff Avallone 3fcf31bc48 Adjusting font baseline to avoid needing to transform it 2019-01-29 17:16:09 -05:00
Jeff Avallone 9d7da52ee3 Adding Pin rendering component 2019-01-28 18:35:55 -05:00
Jeff Avallone 4e27c4ef87 Adding HorizontalLayout 2019-01-28 18:27:40 -05:00
Jeff Avallone 4f1ad26635 Refactoring to work with multiple children correctly 2019-01-28 18:26:13 -05:00
Jeff Avallone 6c603b7958 Adding debug tooling for rendering component bounding boxes 2019-01-28 07:18:20 -05:00
Jeff Avallone f71d707e23 Normalizing bounding boxes 2019-01-27 11:49:48 -05:00
Jeff Avallone 67d970c837 Moving dimension calculation to layout for SVG 2019-01-27 11:49:33 -05:00
Jeff Avallone 35efa7cdb0 Adding theme support for Text rendering component 2019-01-27 11:14:49 -05:00
Jeff Avallone fe714f2363 Adding Box rendering component 2019-01-27 11:14:30 -05:00
Jeff Avallone a118519c3a Extracting type mapping into shared module 2019-01-27 08:50:03 -05:00
Jeff Avallone f16a51abcb Moving layout function into component module 2019-01-27 08:46:48 -05:00
Jeff Avallone 1ee3055f37 Calling layout on children before laying out node 2019-01-27 08:42:09 -05:00
Jeff Avallone e70705be5f Extracting bounding box code into its own function 2019-01-27 08:39:49 -05:00
Jeff Avallone 754868b9d5 Converting rendering components to functional components 2019-01-27 08:30:05 -05:00
Jeff Avallone d4aa207f75 Removing dead code 2019-01-27 08:24:47 -05:00
Jeff Avallone b299d32fc3 Adding a layout pass to SVG image components
text nodes are the only elements that need to be "measured". The
dimensions of all other image components can be determined based on the
dimensions of their children. This adds a pre-rendering pass to work out
dimensions so multiple renders don't need to happen
2019-01-26 17:25:38 -05:00
Jeff Avallone 21c392752e Stubbing out parsing and starting on rendering flow 2019-01-26 16:46:49 -05:00
Jeff Avallone 3378c68aed Starting to add SVG components 2019-01-26 11:02:45 -05:00
Jeff Avallone 57dbea8c40 Updating dependencies 2019-01-24 17:14:18 -05:00
Jeff Avallone fcf9a354f4 Moving close button for Privacy modal to Message
This allow any Message to have a configurable close button. It also
makes the styling more robust
2019-01-19 13:41:42 -05:00
Jeff Avallone f9b34ebd94 Moving modal styles to CSS 2019-01-19 13:26:13 -05:00
Jeff Avallone ef8b3a4bde Removing unnecessary property 2019-01-19 13:13:37 -05:00
Jeff Avallone d57a4c1147 Using a button for the close control and privacy policy modal 2019-01-19 13:10:40 -05:00
Jeff Avallone 9e0cf951d2 Tweaking styles in FormActions 2019-01-18 22:16:21 -05:00
Jeff Avallone 86552860f6 Missing semicolon 2019-01-18 22:16:11 -05:00
Jeff Avallone 1bb01ab8eb Allowing Privacy Policy link click to happen with modifier key 2019-01-18 16:33:02 -05:00
Jeff Avallone 2c8b779793 Opening the privacy policy as an overlay when possible
It still exists as a separate page, but will open as an overlay for a
simple click
2019-01-18 16:24:10 -05:00
Jeff Avallone 83de8ebcbc Splitting privacy policy into a separate component 2019-01-18 15:40:29 -05:00
Jeff Avallone f776d19404 Putting a contoured outline on the favicon 2019-01-16 20:59:38 -05:00
Jeff Avallone cfd7e1ab02 Tweaking mobile styling 2019-01-16 20:09:17 -05:00
Jeff Avallone d41dad14a1 Adding install prompt link in the header 2019-01-16 20:09:04 -05:00
Jeff Avallone a23e72d633 Adding pre-commit hook 2019-01-16 07:01:34 -05:00
Jeff Avallone 67771e07b0 Removing left margin from fancy selects 2019-01-16 06:53:38 -05:00
Jeff Avallone f0233ee030 Hiding content that requires JS when JS is disabled 2019-01-16 06:50:55 -05:00
Jeff Avallone c7ea0659f4 Adding HTML lang attribute and description metadata 2019-01-16 06:35:43 -05:00
Jeff Avallone 325f01f034 Translating Loader component 2019-01-15 21:57:43 -05:00
Jeff Avallone d48b48bffc Translating FormActions component 2019-01-15 21:48:52 -05:00
Jeff Avallone c4a74ad244 Translating Form component 2019-01-15 21:42:41 -05:00
Jeff Avallone 3f692fc20b Translating the App component 2019-01-15 21:40:07 -05:00
Jeff Avallone bbdc5a3b12 Reverting PWA install prompt
This protocol changed from when the old React implementation was built
and it doesn't work from a user-experience perspective now
2019-01-15 21:20:28 -05:00
Jeff Avallone 8c312a450c Integrating install prompt on index page 2019-01-15 21:08:07 -05:00
Jeff Avallone e77763d0b0 Converting IndexPage to a class 2019-01-15 20:36:45 -05:00
Jeff Avallone 9200c1a8e3 Adding InstallPrompt component 2019-01-15 20:28:21 -05:00
Jeff Avallone 46c956e3da Adding manifest and offline support 2019-01-15 18:51:46 -05:00
Jeff Avallone 4b7f55382f Limiting cookie storage for Google Analytics 2019-01-15 17:48:18 -05:00
Jeff Avallone eab20afe1c Adding tests for App component 2019-01-15 17:46:43 -05:00
Jeff Avallone 7261b0b526 Making propTypes static 2019-01-13 21:30:06 -05:00
Jeff Avallone bf44bce954 Moving propTypes into class definitions 2019-01-13 21:23:49 -05:00
Jeff Avallone 60449249d0 Adding tests for Form component 2019-01-13 18:26:32 -05:00
Jeff Avallone c14aa078b1 Adding tests for FormActions component 2019-01-13 17:47:37 -05:00
Jeff Avallone f1a2dfdd34 Updating tests 2019-01-13 11:56:07 -05:00
Jeff Avallone 3eb0689ff3 Preventing rendering with the wrong component 2019-01-13 11:51:45 -05:00
Jeff Avallone 5de72ffb97 Loading syntax-specific rendering module
Still mocked out for testing
2019-01-13 11:44:57 -05:00
Jeff Avallone c3116bf5b6 Moving supported syntax list to gatsby-config 2019-01-13 11:33:33 -05:00
Jeff Avallone 152cf7f7b3 Cleanup 2019-01-13 11:00:38 -05:00
Jeff Avallone 89bac8953b Pulling SVG and PNG download link code into separate file 2019-01-13 10:57:58 -05:00
Jeff Avallone 42a1788c52 Moving link generation into FormActions 2019-01-13 10:53:07 -05:00
Jeff Avallone f41518bd92 Moving FormActions rendering to App 2019-01-13 10:15:44 -05:00
Jeff Avallone 13cfcca85e React.Context was overkill for this purpose, not using it
Also using Gatsby's built-in location property
2019-01-12 21:47:36 -05:00
Jeff Avallone 2d754227b1 Updating tests for SentryBoundary 2019-01-12 13:36:09 -05:00
Jeff Avallone d8ceec1c07 Adjusting Sentry integration to include extra info 2019-01-12 13:30:02 -05:00
Jeff Avallone abe7879b08 Adding module loading flow 2019-01-12 12:49:49 -05:00
Jeff Avallone 8187865f1f Renaming SVG to Render 2019-01-12 12:32:00 -05:00
Jeff Avallone 1336862bce Removing unnecessary default value 2019-01-12 12:21:24 -05:00
Jeff Avallone 024eb57603 Renaming mutation to a more imperative name 2019-01-12 12:18:48 -05:00
Jeff Avallone d589329883 Moving AppContext into its own component 2019-01-12 12:15:18 -05:00
Jeff Avallone 786cd06cd9 Moving app state management code into App context 2019-01-12 12:12:42 -05:00
Jeff Avallone 1f5da0c690 Improving Gatsby rendering solution
Now the form will be prerendered
2019-01-11 23:12:45 -05:00
Jeff Avallone 9a4f669c2d Adding initial shot at handling build issues 2019-01-11 23:07:17 -05:00
Jeff Avallone a4450b34b3 Adding semi-functional rendering demo of app 2019-01-11 22:32:20 -05:00
Jeff Avallone 0606325d6d Giving locale files better names in built output 2019-01-11 21:02:07 -05:00
Jeff Avallone 50200ae72f Tweaking Jest config 2019-01-11 21:01:55 -05:00
Jeff Avallone befcac2087 Converting SentryError to a functional component 2019-01-10 20:58:37 -05:00
Jeff Avallone ea8e4fba08 Updating packages 2019-01-08 20:09:32 -05:00
Jeff Avallone b774babfb9 Reworking i18n error logging to make it less noisy
Also changing locale loading error handling to make the backend retry on
failure
2019-01-08 17:48:47 -05:00
Jeff Avallone cf0ec81730 Updating GitLab CI to deploy to firebase 2019-01-08 17:15:34 -05:00
Jeff Avallone 297bb650ac Adding firebase deploy config 2019-01-08 17:15:16 -05:00
Jeff Avallone 70b489f2a6 Adding support for setting the banner with an env var 2019-01-08 17:14:12 -05:00
Jeff Avallone aca30c8df3 I forgot to configure enzyme-to-json
I thought those snapshots looked terrible
2019-01-08 07:13:09 -05:00
Jeff Avallone 1e4e5d82d1 Moving query for siteMetadata to Layout 2019-01-08 07:09:06 -05:00
Jeff Avallone f14e018518 Improving test coverage for LocaleSwitcher 2019-01-06 17:12:28 -05:00
Jeff Avallone 2a77792165 Splitting locale matching into separate file to facilitate testing 2019-01-06 16:54:24 -05:00
Jeff Avallone ba8461c281 More realistic faking of setState to improve coverage 2019-01-06 16:42:14 -05:00
Jeff Avallone 02f6f2d252 Cleaning up uses of StaticQuery 2019-01-06 16:39:29 -05:00
Jeff Avallone 8426eaa433 Revert "Adding a HOC for using StaticQuery"
This reverts commit 3b11fcb0b6.
2019-01-06 16:26:55 -05:00
Jeff Avallone 3b11fcb0b6 Adding a HOC for using StaticQuery 2019-01-06 13:56:25 -05:00
Jeff Avallone bf35f26d5b Reworking internal component naming for clarity 2019-01-06 13:25:33 -05:00
Jeff Avallone 618b21bb93 Implementing translations 2019-01-06 13:03:07 -05:00
Jeff Avallone e1c4cb9068 Adding i18next integration 2019-01-06 12:24:47 -05:00
Jeff Avallone 7d7916baf0 Applying Layout component in gatsby-browser 2019-01-05 20:16:29 -05:00
Jeff Avallone 837b8d77df Updating eslint rules and addressing issues 2019-01-05 12:27:13 -05:00
Jeff Avallone 8a3471b916 Adding Jest for testing 2019-01-04 18:38:49 -05:00
Jeff Avallone 6cff032efb Adding Sentry 2019-01-03 21:33:13 -05:00
Jeff Avallone c26bf26bd1 Adding Google Analytics integration 2019-01-03 18:29:42 -05:00
Jeff Avallone a5babf8965 Tweaking language on privacy policy page 2019-01-03 18:29:28 -05:00
Jeff Avallone 1655a7898e Adding 404 and privacy policy pages 2019-01-03 18:01:11 -05:00
Jeff Avallone 533475e613 Adding Message 2019-01-03 18:01:01 -05:00
Jeff Avallone a7ebcd92bf Adding Footer 2019-01-03 18:00:39 -05:00
Jeff Avallone 3ce3a886ed Loading font through CSS to avoid font loading flash 2019-01-03 18:00:10 -05:00
Jeff Avallone fb4a130b3c Adding buildId and banner to siteMetadata 2019-01-03 17:59:22 -05:00
Jeff Avallone c572501d51 Adding Header component 2019-01-03 07:49:54 -05:00
Jeff Avallone 6f391264be Adding some basic styling 2019-01-03 07:14:20 -05:00
Jeff Avallone 6fd1035cf6 Adding PostCSS 2019-01-03 07:11:42 -05:00
Jeff Avallone 9519afa75a Cleaning up GitLab CI config 2019-01-02 19:21:29 -05:00
Jeff Avallone 819d4be1a5 Removing import lint rules
Shortcut for importing components is messing with the lint rules and I
can't get a custom resolver working
2019-01-02 19:12:53 -05:00
Jeff Avallone bad4b4be73 Adding react-helmet and starting on a Layout component 2019-01-02 18:51:23 -05:00
Jeff Avallone 49f3c16c2c Adding config to make component importing easier 2019-01-02 18:39:07 -05:00
Jeff Avallone 6fc3062146 Adding eslint 2019-01-02 18:26:50 -05:00
Jeff Avallone 10bd2c7e36 Adding Gatsby 2019-01-01 20:27:53 -05:00
Jeff Avallone 02f33c6ae2 Clearing out old site code 2019-01-01 20:22:04 -05:00
Jeff Avallone 767433149e Removing temporary build files 2019-01-01 09:13:24 -05:00
Jeff Avallone 4b1d5591c4 Removing unnecessary variables block 2019-01-01 09:09:07 -05:00
Jeff Avallone 59e5d12882 Setting BUILD_PATH as absolute path 2019-01-01 09:08:39 -05:00
Jeff Avallone 123be01b1f Dropping back to Node 8 2019-01-01 09:03:06 -05:00
Jeff Avallone 881bd299f6 Pinning builds to Node 10 to avoid compatibility issue 2019-01-01 09:01:07 -05:00
Jeff Avallone 4136e95e13 Fixing CI config 2019-01-01 08:55:21 -05:00
Jeff Avallone 3682d4ce81 Adding GitLab CI config to build using pages 2019-01-01 08:51:29 -05:00
Jeff Avallone 539a907076 Removing badges from README 2018-06-06 16:42:40 -04:00
Jeff Avallone 09a62efa7d Updating changelog 2018-06-04 21:15:34 -04:00
Jeff Avallone 5c314662e4 Migrating to GitLab 2018-06-04 21:10:07 -04:00
Jeff Avallone ae3d8b7e18 Updating changelog 2018-05-24 16:52:24 -04:00
Jeff Avallone 186152bb3c Enabling "Do Not Track" support around Google Analytics and Sentry 2018-05-24 16:49:50 -04:00
Jeff Avallone 6638994f83 Getting build working again 2018-05-24 16:49:37 -04:00
Jeff Avallone 9cbd923c1f Ignoring errors when building blob URL for PNG
If it fails, then the link won't be displayed
2018-02-10 14:04:55 -05:00
197 changed files with 15222 additions and 9633 deletions
+88
View File
@@ -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
View File
@@ -0,0 +1,17 @@
{
"projects": {
"default": "regexper"
},
"targets": {
"regexper": {
"hosting": {
"production": [
"regexper"
],
"preview": [
"regexper-preview"
]
}
}
}
}
+7 -6
View File
@@ -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
+23 -38
View File
@@ -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:
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
-1
View File
@@ -1 +0,0 @@
registry "https://registry.yarnpkg.com"
-37
View File
@@ -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.
-21
View File
@@ -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'
}
]
};
+12
View File
@@ -0,0 +1,12 @@
{
"hosting": [
{
"target": "production",
"public": "public"
},
{
"target": "preview",
"public": "public"
}
]
}
+33
View File
@@ -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>;
};
+58
View File
@@ -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'
]
};
+9
View File
@@ -0,0 +1,9 @@
const path = require('path');
exports.onCreateWebpackConfig = ({ actions }) => {
actions.setWebpackConfig({
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules']
}
});
};
+15
View File
@@ -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>;
};
+6
View File
@@ -0,0 +1,6 @@
const babelOptions = {
presets: ['babel-preset-gatsby'],
plugins: ['dynamic-import-node']
};
module.exports = require('babel-jest').createTransformer(babelOptions);
+5
View File
@@ -0,0 +1,5 @@
global.___loader = {
enqueue: jest.fn()
};
global.Element.prototype.getBBox = jest.fn();
+7
View File
@@ -0,0 +1,7 @@
const path = require('path');
module.exports = {
process(src, filename) {
return `module.exports = ${ JSON.stringify(path.basename(filename)) };`;
}
};
+7
View File
@@ -0,0 +1,7 @@
const yaml = require('js-yaml');
module.exports = {
process(src) {
return `module.exports = ${ JSON.stringify(yaml.safeLoad(src)) };`;
}
};
+73 -144
View File
@@ -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"
}
}
-16
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
# robotstxt.org/
User-agent: *
-102
View File
@@ -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);
});
-35
View File
@@ -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);
});
-101
View File
@@ -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);
});
-18
View File
@@ -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;
+19
View File
@@ -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;
+14
View File
@@ -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
View File
@@ -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 })`
};
+8
View File
@@ -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 })
};
-5
View File
@@ -1,5 +0,0 @@
import React from 'react';
const SvgMock = () => <svg></svg>;
export default SvgMock;
+87
View File
@@ -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",
}
`;
+250 -12
View File
@@ -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
View File
@@ -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>;
}
}
App.propTypes = {
t: PropTypes.func
const formProps = {
onSubmit: this.handleSubmit,
syntax,
expr,
syntaxList
};
const actionProps = {
imageDetails,
permalinkUrl
};
const renderProps = {
onRender: this.handleSvg,
data: exprData
};
export default translate()(App);
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 } /> }
</>;
}
}
export { App };
export default withTranslation()(App);
+80 -6
View File
@@ -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>
</DocumentFragment>
`;
+121
View File
@@ -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

+29 -18
View File
@@ -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 {
.list {
composes: inline-list with-separator-left;
flex: 1;
}
.buildId {
color: color(var(--color-brown) blend(var(--color-tan) 25%));
}
}
+5 -9
View File
@@ -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();
});
});
+23 -163
View File
@@ -1,194 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Form rendering 1`] = `
<DocumentFragment>
<div
className="form"
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="{}"
/>
</div>
Actions
</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>
</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>
`;
+41 -73
View File
@@ -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 />
</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);
@@ -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
View File
@@ -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>
`;
+60
View File
@@ -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;
+49
View File
@@ -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;
}
}
+42
View File
@@ -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();
});
});
+502 -10
View File
@@ -1,8 +1,400 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header rendering 1`] = `
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
className="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 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>
@@ -12,31 +404,131 @@ 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>
</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>
`;
+65 -16
View File
@@ -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;
+54 -9
View File
@@ -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"
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"
>
<p
className="cta"
<span
data-component="Trans"
data-props="{}"
>
<Trans>
Add Regexper to your home screen?
</Trans>
</p>
<div
className="actions"
>
<button
className="primary"
>
<Trans>
Add It
</Trans>
</button>
<button>
<Trans>
No Thanks
</Trans>
</button>
</div>
</div>
Add to Home Screen
</span>
</a>
</DocumentFragment>
`;
+36 -18
View File
@@ -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;
-38
View File
@@ -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;
}
}
}
+34 -6
View File
@@ -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>
`;
+54
View File
@@ -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 } />
) } />
);
+22
View File
@@ -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>
`;
+17
View File
@@ -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;
+15
View File
@@ -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`] = `
<DocumentFragment>
<label>
<Trans>
<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>
</DocumentFragment>
`;
+28 -44
View File
@@ -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 />
</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');
});
});
@@ -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;
}
+50 -18
View File
@@ -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,27 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Message rendering 1`] = `
<DocumentFragment>
<div
className="message"
class="message"
>
<div
className="header"
class="header"
>
<h2>
Testing
</h2>
</div>
<div
className="content"
class="content"
>
<p>
Message content
</p>
</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`] = `
<DocumentFragment>
<div
class="message"
>
@@ -41,16 +74,21 @@ exports[`Message rendering with icon 1`] = `
</p>
</div>
</div>
</DocumentFragment>
`;
exports[`Message rendering with type 1`] = `
<DocumentFragment>
<div
class="message error"
>
<div
class="header"
>
<svg />
<span
data-component="AlertOctagon"
data-props="{}"
/>
<h2>
Testing
</h2>
@@ -63,4 +101,5 @@ exports[`Message rendering with type 1`] = `
</p>
</div>
</div>
</DocumentFragment>
`;
+16 -9
View File
@@ -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 />;
};
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
-72
View File
@@ -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);
}
}
}
+92
View File
@@ -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);
}
}
}
+30 -8
View File
@@ -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>
`;
+29
View File
@@ -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;
+30
View File
@@ -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();
});
});
@@ -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>
`;
+51
View File
@@ -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>&ldquo;Do Not Track&rdquo;</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;
+16
View File
@@ -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 />`;
-39
View File
@@ -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;
-32
View File
@@ -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>
`;
-40
View File
@@ -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 };
-65
View File
@@ -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>
`;
+79
View File
@@ -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;
+15
View File
@@ -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;
}
}
+128
View File
@@ -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'
]
}
]
});
});
});
-80
View File
@@ -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;
-53
View File
@@ -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();
});
});
-97
View File
@@ -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();
});
});
-100
View File
@@ -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;
-19
View File
@@ -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();
});
});
-139
View File
@@ -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;
-86
View File
@@ -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();
});
});
-40
View File
@@ -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;
-15
View File
@@ -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();
});
});
-63
View File
@@ -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 }>&ldquo;</tspan>
<tspan>{ children }</tspan>
<tspan style={ style.textQuote }>&rdquo;</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;
-36
View File
@@ -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