diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..4fd6ab254 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.dockerignore +.gitignore +README.md +apps/server/node_modules +apps/client/node_modules +node_modules diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..ebff48c76 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +dist/ +build/ +client/_localeFileMap.ts diff --git a/.gitignore b/.gitignore index d03abb4fe..be5587ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,8 @@ -settings.php -cache/* .DS_Store -.project -.buildpath -.settings/* -.idea* -resources/themes/classic/.sass-cache/* -resources/themes/default/.sass-cache/* -resources/themes/.sass-cache/* -node_modules* \ No newline at end of file +node_modules/* +.config +.env +data/db +!data/db/nodelete.txt +.turbo +.npm \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..70f6554c7 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.19.4 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..4a1506530 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 140 +} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1dd1c508c..000000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: php -php: - - "5.5" - - "5.4" - - "5.3" -services: mysql -before_script: - - mysql -e 'create database generatedata_test;' -script: phpunit --configuration tests/phpunit_config.xml --coverage-text \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..ea26052d2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint", + "editor.formatOnType": false, + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "files.autoSave": "onFocusChange", + "vs-code-prettier-eslint.prettierLast": false, + "editor.tabSize": 2 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8e4227e95 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,254 @@ +## Changelog + +- `4.1.9` - Feb 16, 2025 + - Bug fixes + - https://github.com/benkeen/generatedata/milestone/53?closed=1 +- `4.1.8` - Dec 16, 2024 + - Bug fix + - https://github.com/benkeen/generatedata/milestone/52?closed=1 +- `4.1.7` - Dec 14, 2024 + - Mostly bug fixes and package updates + - https://github.com/benkeen/generatedata/milestone/51?closed=1 +- `4.1.6` - Dec 10, 2023 + - Mexican first and last names added + - https://github.com/benkeen/generatedata/milestone/50?closed=1 +- `4.1.5` - Aug 7, 2023 + - Misc bug fixes, including fix for generating valid PAN numbers (thanks @benjamindonnachie!) + - https://github.com/benkeen/generatedata/milestone/49?closed=1 +- `4.1.4` - Jun 20, 2023 + - Misc bug fixes. + - https://github.com/benkeen/generatedata/milestone/48?closed=1 +- `4.1.3` - Mar 2, 2023 + - Misc bug fixes. + - https://github.com/benkeen/generatedata/milestone/47?closed=1 +- `4.1.2` - Mar 1, 2023 + - Misc bug fixes. + - https://github.com/benkeen/generatedata/milestone/46?closed=1 +- `4.1.1` - Feb 25, 2023 + - Misc bug fixes. + - https://github.com/benkeen/generatedata/milestone/45?closed=1 +- `4.1.0` - Feb 14, 2023 + - Refactored code to accommodate upcoming npm package version (command-line generation). + - Moved to Google Identity Services for sign-in process. + - Misc bug fixes. + - https://github.com/benkeen/generatedata/milestone/43?closed=1 +- `4.0.15` - Nov 26, 2022 + - Weighted List Data Type added + - Misc bug fixes. + - https://github.com/benkeen/generatedata/milestone/41?closed=1 +- `4.0.14` - Mar 5, 2022 + - URLs Data Type added + - Ukrainian, Singapore and South Africa country data added + - https://github.com/benkeen/generatedata/milestone/40?closed=1 +- `4.0.13` - Mar 3, 2022 + - Philippines country data added + - https://github.com/benkeen/generatedata/milestone/39?closed=1 +- `4.0.12` - Dec 28, 2021 + - List Data Type now offers a "between" option + - Norwegian country data added. Thanks @maddingo! + - https://github.com/benkeen/generatedata/milestone/38?closed=1 +- `4.0.11` - Dec 8, 2021 + - Dutch name data - thanks @rvanraamsdonk! + - Misc bug fixes + - https://github.com/benkeen/generatedata/milestone/37?closed=1 +- `4.0.10` - Dec 6, 2021 + - Language selection overhauled + - Bug fixes + - Turkey name data - thanks @alicanipek! + - https://github.com/benkeen/generatedata/milestone/36?closed=1 +- `4.0.9` - Nov 20, 2021 + - Node updated to 14 + - German name data - thanks @ntauch! + - https://github.com/benkeen/generatedata/milestone/35?closed=1 +- `4.0.8` - Nov 13, 2021 + - additional bug fix for regions, cities and postal code Data Types throwing errors + - Chilean country names added + - Admin: status filter and total count added to accounts page + - https://github.com/benkeen/generatedata/milestone/34?closed=1 +- `4.0.7` - Nov 11, 2021 + - bug fix for regions, cities and postal code Data Types throwing errors (didn't fully work) + - Fix for error thrown when closing Export Type overlay + - https://github.com/benkeen/generatedata/milestone/33?closed=1 +- `4.0.6` - Nov 6, 2021 + - Regional names added + - Email Data Type now lets you target other fields for more realistic data + - China country data added + - C# now handles auto-increment numeric values better + - https://github.com/benkeen/generatedata/milestone/32?closed=1 +- `4.0.5` - Oct 5, 2021 + - Portuguese translation added + - Misc code improvements + - https://github.com/benkeen/generatedata/milestone/31?closed=1 +- `4.0.4` - Sept 27, 2021 + - Hindi locale added + - Improvements for small screens + - Misc bug fixes + - https://github.com/benkeen/generatedata/milestone/30?closed=1 +- `4.0.3` - Sept 22, 2021 + - Bug fix for CSV Export Type. + - https://github.com/benkeen/generatedata/milestone/29?closed=1 +- `4.0.2` - Sept 21, 2021 + - misc bug fixes, UX improvements + - minor dependency updates + - https://github.com/benkeen/generatedata/milestone/28?closed=1 +- `4.0.1` - Sept 19, 2021: + - localization files now cache-busted + - check for is-safari updated. +- `4.0.0` - Sept 17, 2021: + - initial release! + - bug fixes +- `4.0.0-beta-20210911`: + - Color Data Type added + - Localized date picker components + - bug fixes +- `4.0.0-beta-20210906`: + - Date format standardization in code + - Better error handling for expired accounts + - misc bug fixes +- `4.0.0-beta-20210903`: + - Time Data Type added + - misc bug fixes +- `4.0.0-beta-20210826`: + - account searching + - misc bug fixes +- `4.0.0-beta-20210809`: + - back in the game! Returning to work on generatedata - misc updates. + - Last logged in col on accounts page + - Expiry date added to accounts page + - Fix for TextFixed Data Type +- `4.0.0-alpha-20210608`: + - minor authentication + password reset bug fixes. +- `4.0.0-alpha-20210607`: + - tons of updates & fixes - too many to note! UI updates, database changes, performances fixes and more. +- `4.0.0-alpha-20210429`: + - preview panel scrolling fix. + - List Data Type now allows customizable delimiter. +- `4.0.0-alpha-20210424`: + - Safari error page. +- `4.0.0-alpha-20210418`: + - Email support added. + - Forget password email; expiry emails. +- `4.0.0-alpha-20210305`: + - Track1 Data Type added. +- `4.0.0-alpha-20210301`: + - Credit Card PAN Data Type added. +- `4.0.0-alpha-20210224`: + - Assorted lib updates. +- `4.0.0-alpha-20210223`: + - Improved generation settings panel. + - i18n fixes. +- `4.0.0-alpha-20201111`: + - Chilean RUT number, PIN and CVV Data Types added. + - Fixed/Random Number of Words Data Types expanded to allow providing your own text as word source. +- `4.0.0-alpha-20201108`: + - CSV, LDIF Export Types added. + - All Data Types and Export Types translated. +- `4.0.0-alpha-20201104`: + - Computed and Composite Data Types combined. + - Names Data Type options structure change. + - Fix for help dialog not resetting search text + - version now links to changelog +- `4.0.0-alpha-20201102`: + - all 9 languages now available to toggle between. + - "Clear Page" modal now lets you either just clear the grid, or reset everything (all plugins) to their default + settings +- `4.0.0-alpha-20201101`: + - initial functional alpha. Data now generates! +- `3.4.1` - Nov 24, 2019 + - Excel Export Type updated for new PHP lib, thanks [@adibaby](https://github.com/adibaby)! + - Bug fix for SocialSecurityNumber, courtesy of [@guzzisti](https://github.com/guzzisti). + - https://github.com/benkeen/generatedata/milestone/26?closed=1 +- `3.4.0` - Nov 16, 2019 + - Misc updates, + - new inject SQL feature added. Great work, [@harish81](https://github.com/harish81)! + - https://github.com/benkeen/generatedata/milestone/25?closed=1 +- `3.3.1` - July 18, 2019 + - https://github.com/benkeen/generatedata/milestone/23?closed=1 +- `3.3.0` - July 1, 2019 + - misc bug fixes +- `3.2.8` - Sep 12, 2017 + - misc bug fixes +- `3.2.7` - Jul 29, 2017 + - "Computed" Data Type added. + - misc bug fixes +- `3.2.6` - Apr 17, 2017 + - misc bug fixes: https://github.com/benkeen/generatedata/milestone/20?closed=1 +- `3.2.5` - Apr 16, 2016 + - bug fixes: https://github.com/benkeen/generatedata/issues?utf8=%E2%9C%93&q=milestone%3A3.2.3+ - thanks for your + help, [Conrad Hagemans](https://github.com/conradhagemans)! + - "Precision" option added to Normal Distribution Data Type - thanks [@aevans84](https://github.com/aevans84). + - generation of complex JSON structures added by [Tony OHagan](https://github.com/tohagan). + See: https://github.com/benkeen/generatedata/tree/master/plugins/exportTypes/JSON#generating-complex-objects +- `3.2.4` - Dec 6, 2015 + - patch release for per-user settings. +- `3.2.3` - Nov 15, 2015 + - SIRET/SIREN Data Type added (French business numbers) added. + Merci, [Fabrice Marquès](https://github.com/fmarques56)! + - Bug fixes: https://github.com/benkeen/generatedata/issues?utf8=%E2%9C%93&q=milestone%3A3.2.3+ +- `3.2.2` - Nov 12, 2015 + - The plugins (Data Types, Export Types, Countries) seen in the interface may not be configured on a per-user level. + - Installation script updated to allow customization of plugin selection. +- `3.2.1` - May 25, 2015 + - Configuration history option added to store the last 200 (this is configurable) versions of a data set. In case of + data loss, you can now revert to an older version very simply. + - Assorted bug fixes, including some improvements to the installation script. +- `3.2.0` - Jan 29, 2015 + - Adds a new REST API as an alternative way to generate data. See + the [API Documentation](http://benkeen.github.io/generatedata/api.html) for more information. +- `3.1.4` - Sept 6, 2014 + - Chinese language file added, thanks to [Zhao Yang](https://github.com/jptiancai) + - PAN, Track 1 and Track 2 data type updates, courtesy of Zeeshan Shaikh + - Turkey Country plugin added + - Bug fixes: https://github.com/benkeen/generatedata/issues?q=milestone%3A3.1.4+is%3Aclosed +- `3.1.3` - July 20, 2014 + - Misc data generation efficiency improvements + - Batch Size SQL export option added by [Anton Nizhegorodov](https://github.com/an1zhegorodov) + - Poland, Nigeria Country plugins added + - Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=13&page=1&state=closed +- `3.1.2` - July 12, 2014 + - Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=12&page=1&state=closed +- `3.1.1` - Jan 31, 2014 + - New credit card data types: PAN, PIN, CVV, Track 1 and Track 2 courtesy of Zeeshan Shaikh + - INSERT IGNORE option added to the SQL Export Type, thanks to [Ap.Mathu](https://github.com/apmuthu) + - Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=11&page=1&state=closed +- `3.1.0` - Dec 19, 2013 + - Bug fix for accidental short-tags that were introduced in earlier code +- `3.0.9` - Dec 11, 2013 + - Compression option added to reduce download sizes, courtesy of [Manu Ullas](https://github.com/unullmass) - + thanks! + - New credit card Data Type, thanks to [rsicher1](https://github.com/rsicher1) + - You can now make copies of Data Sets, via the main dialog window. Just check a single row and click "Copy Data + Set" button. + - CodeMirror updated to v3.2.0 + - Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=9&page=1&state=closed +- `3.0.8` - Oct 28, 2013 + - International Bank Numbers - thanks, Joeri Noort! + - PostgreSQL database support added to SQL Export Type + - Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=8&page=1&state=closed +- `3.0.7` - Sept 7, 2013 + - LDIF Export Type support - thanks, [Marco Corona](https://github.com/coronam)! + - Proper (genuine!) French translation courtesy of [Michel Roca](https://github.com/mRoca) + - Optional JS, CSS minimization and bundling via Grunt. See help documentation for more information: + [http://benkeen.github.io/generatedata/developer.html#bundling](http://benkeen.github.io/generatedata/developer.html#bundling) + - PHP 5.5 compatibility fixes: database connection now with mysqli; Generator class renamed to DataGenerator due to + naming conflict + - Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=7&page=1&state=closed +- `3.0.6` - Aug 1, 2013 + - Costa Rica Country plugin, Phone-Regional Data Type added, courtesy of [Andre Fortin](https://github.com/twindual) + - bug fixes, see: https://github.com/benkeen/generatedata/issues?milestone=6&page=1&state=closed +- `3.0.5` - July 13, 2013 + - Currency Data Type added + - Assorted bug fixes, see: https://github.com/benkeen/generatedata/issues?milestone=5&page=1&state=closed +- `3.0.4` - July 2nd, 2013 + - Italy Export Type, courtesy of [Marcello Verona](https://github.com/marciuz) + - Regional Names Data Type added, data for Italy and France from [Marcello Verona](https://github.com/marciuz) +- `3.0.3` - June 23, 2013 + - Bug fixes. See: https://github.com/benkeen/generatedata/issues?milestone=3 +- `3.0.2` - June 12, 2013 + - Spanish translation and Country plugin added (thanks, [@robarago](https://github.com/robarago)!) + - bug fixes, other updates: https://github.com/benkeen/generatedata/issues?milestone=2&state=closed +- `3.0.1` - June 1st, 2013 + - MSSQL support added (thanks, [Kent](https://github.com/kchenery)!) + - Assorted bug fixes / updates. See: https://github.com/benkeen/generatedata/issues?milestone=1&state=closed +- `3.0.0` - May 21st, 2013 + - Initial release diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..70a65e7f4 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,93 @@ +# Development + +Version 4 of generatedata uses Docker to simplify packaging up the app for development and distribution. Docker +wasn't quite the wonder that I hoped it would be, but the benefits overall are undeniable. + +I experimented with getting the dev environment running _entirely_ within Docker containers so you wouldn't require +to install anything locally, but I found it was simply too slow to be of practical use as a dev env. So instead, the +local dev env just uses docker containers for the _server and database_; the FE code is still ran locally. I know that's +a pain for non-frontend developers especially who aren't so familiar with setting up NVM, Grunt etc., but it's a +trade-off I had to make. + +#### Pre-requisites: + +- [Docker](https://docs.docker.com/get-docker/) +- [NVM](https://github.com/nvm-sh/nvm#installing-and-updating) - namely node 12. +- Grunt CLI (`npm install grunt-cli -g`) + +### Running dev environment + +- `git clone https://github.com/benkeen/generatedata.git` - this clones the repo to your local computer. On Mac, I'd + suggest putting it in your `~` folder; I tried it in other locations but Docker ran into permissions issues. + +- `nvm install` + + - assuming your have NVM installed (see above), this'll choose the right node version. If not, just choose the + right node version specified in the `.nvmrc` file. If you're not running the correct version of node it will + throw an error during startup. + +- `pnpm install` +- After starting Docker, in one tab run: `npm run startAndBuildDevServer` - this boots up the server + database containers. + For subsequent runs you can just use `npm run startDevServer` and it'll be faster. +- In a second tab, `npm run start` - this boots up the client-side code. Be warned: this does a _LOT_ of stuff and the + first time you run it it'll take a very long time to run. + +After running the second command it should open up `http://localhost:9000` in your browser. + +#### Shutting down dev env + +`npm run stopDevServer` - shuts down docker. + +I've found that sometimes that command chokes and you have to wait a few minutes before it runs properly. Presumably +it's because the docker container was still in the process of booting up. If there are still problems, you might want to just run +`npm run dockerCleanup`. I do this on the live server every time I update it. It completely clears everything out so you +can start from scratch. It WON'T, however, delete your + +### Troubleshooting + +> ERROR: for db Cannot start service db: error while creating mount source path '/host_mnt/xxx/data/db': mkdir /host_mnt/Users/xxx/data/db: no such file or directory +> ERROR: Encountered errors while bringing up the project. + +Restarting Docker seemed to fix this. I did that via the UI tool. + +## Locale file helpers + +There are a several grunt helper functions for validation and managing the locale files. It's important to keep the files +up to date so every i18n file contains the same keys and won't cause bugs when the user selects the language. + +#### Core localization files + +These are found in `src/i18n`. They contain all the core i18n files. + +- `grunt validateI18n` - general validation function to examine all the localization files and check everything in sync. +- `grunt validateI18n --key=fr` - same as above, except it only looks at a particular locale file. +- `grunt removeI18nKey --key=xxx` - where xxx is the property name. +- `grunt sortI18nFiles` - sorts the keys of all i18n files alphabetically. + +### Text rules: + +- titles, headings: capitalize every letter +- tooltips: sentence case, no ending period + +### Building + +Local dev, general steps: + +- `npm run start` - builds and rubs the client-side code +- `npm run startDevServer` - starts the dev server + +### Common problems + +#### Logging in with Google works but logs out when page is refreshed + +If you find that after logging in with Google it gets lost after refreshing the page, check your system clock. The +OAuth2Client lib we're using uses the system clock when re-validating the google auth info. My own computer locally +(an 2017 Mac) when I leave it on for too long the time gets very out of whack, causing this problem. Restarting the +computer (which restarts the clock) fixes it. + +#### M1 mac + +After upgrading to Ventura, I found I had problems running Docker. + +To fix it I enabled "Use Rosetta for x86/amd64 emulation on Apple Silicon" in Docker Desktop and added a +`export DOCKER_DEFAULT_PLATFORM=linux/amd64` env variable, then wiped out all existing docker containers and started afresh. diff --git a/README.md b/README.md index f10cbb88d..55767f38a 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,43 @@ # generatedata.com -[![Build Status](https://travis-ci.org/benkeen/generatedata.png?branch=master)](https://travis-ci.org/benkeen/generatedata) - -This is the repo for the standalone, downloadable version of [generatedata.com](http://www.generatedata.com). - -Generally the trunk is pretty stable, but it's never guaranteed. If you're downloading the code, I'd suggest getting the most recent tag: https://github.com/benkeen/generatedata/releases +This is the repo for the downloadable version of [generatedata.com](https://generatedata.com). The script is essentially +an _engine_ to generate any sort of random data in any format. It currently comes with 30 or +so _Data Types_ (types of data it generates), 12 _Export Types_ (formats for the data, like CSV, SQL, JSON), plus +around 32 data sets for specific countries (city names, regions etc). But more importantly it can be extended in any +way you want. Check out the [developer documentation](https://benkeen.github.io/generatedata/developerdoc/intro/) for more +information on that. + +### Programmatic generation + +The current major version of the script is 4.x, which was a big change over earlier releases. Earlier versions were written +in PHP and MYSQL and 3.x offered a REST API to let you generate the data programmatically rather than via an API. While this is +still planned for 4.x it's not currently offered, and the plan it to tackle that _after_ making the script available +as an npm package - which we feel will be a more convenient way to programmatically generate data over the more agnostic +REST approach. +` ## Requirements -- PHP 5.3 or later -- MySQL 4.1.3 or later -## How to Install / Documentation +- Docker +- node +- nvm -For the installation instructions, user documentation and developer documentation, check out: -http://benkeen.github.io/generatedata/ +See the [Installation instructions](https://benkeen.github.io/generatedata/userdoc/installation/intro) for full details. -Installation is really, really simple. I deliberately wrote the script to be as self-contained as possible and not require -additional PHP/Server configuration when setting it up. That said, it *does* require PHP 5.3.0 or later. See the documentation -for more info. - -## Test Coverage +## How to Install / Documentation -Test coverage is pretty weak right now! I'm in the midst of adding phpunit tests and integrating it with Travis, but it's going to be a little hairy for a while just yet. +For installation instructions, user and developer documentation, check out: +https://benkeen.github.io/generatedata/ ## License -This script is freely available under the GPL 3 license. See license.txt in the root folder. Please note that all contributors agree that all code is released under this license. +This script is freely available under the GPL 3 license. See license.txt in the root folder. Please note that all +contributors agree that all code is released under this license. ## Contributors -In addition to the many folks who submit bug reports, a big thanks to the following for their help extending the script: - -- Zeeshan Shaikh - PAN, PIN, CVV, Track 1 and 2 Data Types (3.1.1) -- [Ap.Mathu](https://github.com/apmuthu) - SQL Export Type updates (INSERT IGNORE) -- [Manu Ullas](https://github.com/unullmass) - compression option for downloads (3.0.9) -- [rsicher1](https://github.com/rsicher1) - credit card Data Type (3.0.9) -- Joeri Noort - IBAN numbers (3.0.8) -- [Michel Roca](https://github.com/mRoca) - Full and correct French translation (3.0.7) -- Marco Corona - LDIF Export Type added (3.0.7) -- [Andre Fortin](https://github.com/twindual) - original Costa Rica Country plugin & Phone-Regional Data Type (3.0.6) -- [Marcello Verona](https://github.com/marciuz) - Italy Country plugin (3.0.4) -- [Roberto Aragón](https://github.com/robarago), Charo Baena - Spanish translation & Country plugin (3.0.2) -- [Kent Chenery](https://github.com/kchenery) - MS SQL plugin (3.0.1) - -## Changelog - -3.1.1 - Jan 31, 2014 -- New credit card data types: PAN, PIN, CVV, Track 1 and Track 2 courtesy of Zeeshan Shaikh. -- INSERT IGNORE option added to the SQL Export Type, thanks to [Ap.Mathu](https://github.com/apmuthu) -- Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=11&page=1&state=closed - -3.1.0 - Dec 19, 2013 -- Bug fix for accidental short-tags that were introduced in earlier code - -3.0.9 - Dec 11, 2013 -- Compression option added to reduce download sizes, courtesy of [Manu Ullas](https://github.com/unullmass) - thanks! -- New credit card Data Type, thanks to [rsicher1](https://github.com/rsicher1) -- You can now make copies of Data Sets, via the main dialog window. Just check a single row and click "Copy Data Set" button. -- CodeMirror updated to v3.2.0 -- Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=9&page=1&state=closed - -3.0.8 - Oct 28, 2013 -- International Bank Numbers - thanks, Joeri Noort! -- PostgreSQL database support added to SQL Export Type -- Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=8&page=1&state=closed - -3.0.7 - Sept 7, 2013 -- LDIF Export Type support - thanks, [Marco Corona](https://github.com/coronam)! -- Proper (genuine!) French translation courtesy of [Michel Roca](https://github.com/mRoca) -- Optional JS, CSS minimization and bundling via Grunt. See help documentation for more information: -[http://benkeen.github.io/generatedata/developer.html#bundling](http://benkeen.github.io/generatedata/developer.html#bundling) -- PHP 5.5 compatibility fixes: database connection now with mysqli; Generator class renamed to DataGenerator due -to naming conflict. -- Bug fixes: https://github.com/benkeen/generatedata/issues?milestone=7&page=1&state=closed - -3.0.6 - Aug 1, 2013 -- Costa Rica Country plugin, Phone-Regional Data Type added, courtesy of [Andre Fortin](https://github.com/twindual) -- bug fixes, see: https://github.com/benkeen/generatedata/issues?milestone=6&page=1&state=closed - -3.0.5 - July 13, 2013 -- Currency Data Type added. -- Assorted bug fixes, see: https://github.com/benkeen/generatedata/issues?milestone=5&page=1&state=closed - -3.0.4 - July 2nd, 2013 -- Italy Export Type, courtesy of [Marcello Verona](https://github.com/marciuz) -- Regional Names Data Type added, data for Italy and France from [Marcello Verona](https://github.com/marciuz) - -3.0.3 - June 23, 2013 -- Bug fixes. See: https://github.com/benkeen/generatedata/issues?milestone=3 - -3.0.2 - June 12, 2013 -- Spanish translation and Country plugin added (thanks, [@robarago](https://github.com/robarago)!) -- bug fixes, other updates: https://github.com/benkeen/generatedata/issues?milestone=2&state=closed - -3.0.1 - June 1st, 2013 -- MSSQL support added (thanks, [Kent](https://github.com/kchenery)!) -- Assorted bug fixes / updates. See: https://github.com/benkeen/generatedata/issues?milestone=1&state=closed - -3.0.0 - May 21st, 2013 -- Initial release. - +In addition to the many fine folk who submit bug reports, a big thanks to the following for their help extending the script: +https://github.com/benkeen/generatedata/graphs/contributors Ben Keen -[@vancouverben](https://twitter.com/#!/vancouverben) +https://github.com/benkeen diff --git a/V5_CHANGES.md b/V5_CHANGES.md new file mode 100644 index 000000000..5183ca892 --- /dev/null +++ b/V5_CHANGES.md @@ -0,0 +1,39 @@ +# 5.x Changes + +This file tracks the changes in 5.x. I'll keep this updated as I progress. + +## High-level changes + +The repo was a bit of a mess architecturally. Code was being referenced all over the place - env vars were read by FE and BE code, shared code was sometimes duplicated and there was a ton of complex logic around building the app and dynamically generating files out of plugins and so on and so forth. Basically it had grown into a bit of a ball of mud. The 4.x version did tons of great stuff, but didn't really choose a sane architecture. + +Version 5.x is devoted to a _re-architecture only_ - I'm not changing any UI or functionality. I know - _booooring_. The biggest change is that **I'm converted to a standard monorepo using [Turborepo](https://turborepo.com/)**. I haven't used it much before but it seems like a usable and sensible tool. There won't be any fundamental changes to any of the technologies beyond various version updates. It'll still use Docker (sigh, I hate Docker), React, Redux, TS etc. but the structure of the code will be quite different. + +The chief motivation for this major version bump that whenever I return to work on this project to complete the unfinished CLI, I find myself stymied by the lack of a formal architecture and run into problem and problem trying to create the CLI in a clean standalone format. Also, since I the simplicity of PHP / MySQL / MAMP/WAMP etc. it became far harder for devs to actually set up and use. That's a real pity. I don't think I can get around the fact that Docker is far more complex but I can at least make the on-ramp easier. + +## Packages + +Logical units of the code are now going to be split into standard _npm packages_, found under `apps` and `packages`. + +**Apps** + +- `apps/client` - the main client-side app. +- `apps/server` - the server-side app. I'd LOVE to actually convert this to TS and not continue to use plain wild-west JS, but I don't want to bloat the work, so will probably punt on it until a later version. We'll see. + +**Packages** + +- `packages/cli` - this was the never-completed CLI package. Still **definitely** something I want to complete! +- `packages/cli-test` - testing for the CLI package. This might be temporary. Not sure it needs to be separate from `cli` itself. +- `packages/config` - this'll house the main configuration settings and replace the old `.env` file. For simplicity and backward compatibility, I've left the same uppercase names from the old .env variables. +- `packages/plugins` - the countries, Data Types and Export Types. Perhaps I'll split them into separate packages for each, but for now they're lumped in the same package. +- `packages/types` - global types. + +## Other changes + +- moved to pnpm + +## Bootstrap process for new clones + +(Need instructions): + +- install turborepo CLI, nvm, pnpm globals +- `pnpm install` - this bootstraps the whole repo diff --git a/ajax.php b/ajax.php deleted file mode 100755 index 0a2e6cf8d..000000000 --- a/ajax.php +++ /dev/null @@ -1,43 +0,0 @@ -getResponse()); -$errorCode = json_last_error(); -if ($errorCode) { - switch ($errorCode) { - case JSON_ERROR_NONE: - echo ' - No errors'; - break; - case JSON_ERROR_DEPTH: - echo ' - Maximum stack depth exceeded'; - break; - case JSON_ERROR_STATE_MISMATCH: - echo ' - Underflow or the modes mismatch'; - break; - case JSON_ERROR_CTRL_CHAR: - echo ' - Unexpected control character found'; - break; - case JSON_ERROR_SYNTAX: - echo ' - Syntax error, malformed JSON'; - break; - case JSON_ERROR_UTF8: - echo ' - Malformed UTF-8 characters, possibly incorrectly encoded'; - break; - default: - echo ' - Unknown error'; - break; - } -} else { - echo $encoded; -} diff --git a/apps/client/.babelrc b/apps/client/.babelrc new file mode 100644 index 000000000..801c900bf --- /dev/null +++ b/apps/client/.babelrc @@ -0,0 +1,29 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "esmodules": true + } + } + ], + [ + "@babel/preset-react", {} + ] + ], + "plugins": [ + "@babel/plugin-syntax-dynamic-import" + ], + "env": { + "BUILD": { + "comments": true + }, + "DEV": { + "comments": true + }, + "DIST": { + "comments": true + } + } +} diff --git a/apps/client/.eslintrc.js b/apps/client/.eslintrc.js new file mode 100644 index 000000000..d1c6a3b5f --- /dev/null +++ b/apps/client/.eslintrc.js @@ -0,0 +1,64 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + experimentalObjectRestSpread: true + } + }, + env: { + browser: true, + es6: true, + node: true + }, + plugins: ['react'], + extends: ['plugin:react/recommended', 'plugin:@typescript-eslint/recommended'], + rules: { + semi: [2, 'always'], + indent: [ + 'error', + 'tab', + { + SwitchCase: 1 + } + ], + 'max-len': [1, { code: 140 }], + // '@stylistic/js/indent/max-len': ['warning', 140], + 'object-curly-spacing': ['error', 'always'], + allowIndentationTabs: 0, + 'no-extra-parens': ['off'], + 'no-multi-spaces': 'error', + 'react/prop-types': ['off'], + 'comma-dangle': 'off', + 'no-tabs': 'off', + 'no-multiple-empty-lines': 'off', + 'no-plusplus': 'off', + 'import/no-unresolved': 'off', + 'arrow-body-style': 'off', + 'import/extensions': 'off', + 'import/prefer-default-export': 'off', + 'lines-between-class-members': 'off', + 'object-curly-newline': 'off', + quotes: ['error', 'single'], + 'import/no-mutable-exports': 'off', + 'react/no-unused-prop-types': 'off', + 'react/no-unescaped-entities': 'off', + 'react/jsx-indent': [2, 'tab'], + 'react/jsx-indent-props': [2, 'tab'], + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/ban-ts-comment': 'off' + }, + settings: { + react: { + version: '16.13.1' + } + } +}; diff --git a/apps/client/.gitignore b/apps/client/.gitignore new file mode 100644 index 000000000..032880991 --- /dev/null +++ b/apps/client/.gitignore @@ -0,0 +1,4 @@ +_env.ts +dist/* +node_modules/* +.turbo \ No newline at end of file diff --git a/apps/client/_localeFileMap.ts b/apps/client/_localeFileMap.ts new file mode 100644 index 000000000..b0413105d --- /dev/null +++ b/apps/client/_localeFileMap.ts @@ -0,0 +1,17 @@ +/* eslint quotes:0 */ +import { GDLocaleMap } from '@generatedata/types'; + +export const localeFileMap: GDLocaleMap = { + "ar": "ar-082d52b0.js", + "de": "de-9c5c6e88.js", + "en": "en-ec98c99d.js", + "es": "es-d2dc0ab7.js", + "fr": "fr-76831d50.js", + "hi": "hi-dd5691a1.js", + "ja": "ja-79d51d8b.js", + "nl": "nl-1499548d.js", + "pt": "pt-c7796933.js", + "ru": "ru-32bc90c7.js", + "ta": "ta-9e10d34d.js", + "zh": "zh-20134d2b.js" +}; \ No newline at end of file diff --git a/apps/client/_pluginWebWorkers.ts b/apps/client/_pluginWebWorkers.ts new file mode 100644 index 000000000..549f6840d --- /dev/null +++ b/apps/client/_pluginWebWorkers.ts @@ -0,0 +1,7 @@ +/* eslint quotes:0 */ +export default { + "generationWorker": "generation.worker-1dc7caad6d37493ad34aaebb76baa983.js", + "workerUtils": "workerUtils-d87db21604e56391c5d7def129452a61.js", + "dataTypes": {}, + "exportTypes": {} +}; \ No newline at end of file diff --git a/apps/client/build/build.js b/apps/client/build/build.js new file mode 100644 index 000000000..9f3407344 --- /dev/null +++ b/apps/client/build/build.js @@ -0,0 +1,100 @@ +// TODO remove this file once everything is finalized +const fs = require('fs'); +const path = require('path'); +const helpers = require('./helpers'); + +if (result.error) { + console.error("\nMissing .env file. Please see the documentation about setting up your environment.\n"); + return; +} + +const banner = `/** + * This file is autogenerated. Do not edit! + * ---------------------------------------- + **/`; + +// TODO move to CLI package +// const createCliTypesFile = () => { +// let content = banner + '\n\nimport { DataType, ExportType } from \'../../../client/_plugins\';\n'; + +// const blacklistedDataTypes = process.env.GD_DATA_TYPE_BLACKLIST.split(','); +// const dataTypes = helpers.getPlugins('dataTypes', []); +// const dtList = dataTypes.filter((dt) => blacklistedDataTypes.indexOf(dt) === -1); // TODO can this be in the prev lines, second param? + +// dtList.forEach((dt) => { +// content += `import { generate as ${dt}G } from '../../../client/src/plugins/dataTypes/${dt}/${dt}.generate';\n` +// content += `import { defaultGenerationOptions as ${dt}DGO } from '../../../client/src/plugins/dataTypes/${dt}/${dt}.state';\n` +// }); + +// content += `\n\nexport const dataTypeNodeData = {\n`; +// const rows = dtList.map((dt) => `\t[DataType.${dt}]: { generate: ${dt}G, defaultGenerationOptions: ${dt}DGO }`); +// content += `${rows.join(',\n')}\n};\n\n` + +// const blacklistedExportTypes = process.env.GD_EXPORT_TYPE_BLACKLIST.split(','); +// const etList = helpers.getPlugins('exportTypes', blacklistedExportTypes); + +// etList.forEach((et) => { +// content += `import { generate as ${et}G } from '../../../client/src/plugins/exportTypes/${et}/${et}.generate';\n` +// content += `import { defaultGenerationOptions as ${et}DGO } from '../../../client/src/plugins/exportTypes/${et}/${et}.state';\n` +// }); + +// content += `\n\nexport const exportTypeNodeData = {\n`; +// const etRows = etList.map((et) => `\t[ExportType.${et}]: { generate: ${et}G, defaultGenerationOptions: ${et}DGO }`); +// content += `${etRows.join(',\n')}\n};\n\n` + +// const file = path.join(__dirname, '../../../packages/cli/src', '_cliTypes.ts'); // TODO +// if (fs.existsSync(file)) { +// fs.unlinkSync(file); +// } +// fs.writeFileSync(file, content); +// }; + + +// TODO move this to the website code. Until it's needed for the core script; dump it. +// const createImportFile = () => { +// const importLines = []; +// const files = process.env.GD_IMPORT_FILES; + +// if (files) { +// files.split(',').forEach((filePathFromRoot) => { +// importLines.push(`import '../${filePathFromRoot}';`); +// }); +// } + +// const file = path.join(__dirname, '..', '_imports.ts'); +// if (fs.existsSync(file)) { +// fs.unlinkSync(file); +// } + +// // rollup gets confused with an empty file, so we add a default exports just in case +// if (!importLines.length) { +// importLines.push(`// DO NOT EDIT: This is autogenerated by a node script \nexport default {};`); +// } +// fs.writeFileSync(file, importLines.join('\n')); +// }; + + +// const generateNamesFile = () => { +// const namePlugins = helpers.getNamePlugins(); + +// let content = banner + '\n\n'; +// namePlugins.forEach((folder) => { +// content += `import ${folder} from './src/plugins/countries/${folder}/names';\n`; +// }); + +// content += `\nconst nameFiles = {\n\t${namePlugins.join(',\n\t')}\n};`; +// content += `\nexport default nameFiles;\n`; +// content += '\nexport type CountryNameFiles = keyof typeof nameFiles;\n'; + +// const file = path.join(__dirname, '..', '_namePlugins.ts'); +// if (fs.existsSync(file)) { +// fs.unlinkSync(file); +// } +// fs.writeFileSync(file, content); +// }; + +// generateEnvFile('_env.ts', JSON.stringify(envFile, null, '\t')); +// generateNamesFile(); + +// createCliTypesFile(); +// createImportFile(); diff --git a/apps/client/build/helpers.js b/apps/client/build/helpers.js new file mode 100644 index 000000000..8a0a6b14c --- /dev/null +++ b/apps/client/build/helpers.js @@ -0,0 +1,97 @@ +const fs = require('fs'); +const md5File = require('md5-file'); +const path = require('path'); + +const getPlugins = (pluginType, blacklist, checkConfigFileExistence = true) => { + console.log(require.resolve(`@generatedata/plugins/dist/plugins/${pluginType}`)); + + // const baseFolder = path.join(__dirname, '..', `/src/plugins/${pluginType}`); + // const folders = fs.readdirSync(baseFolder); + + // return folders.filter((folder) => { + // if (blacklist.indexOf(folder) !== -1) { + // return false; + // } + // const bundle = `${baseFolder}/${folder}/bundle.ts`; + // const config = `${baseFolder}/${folder}/config.ts`; + // if (checkConfigFileExistence) { + // return fs.existsSync(bundle) && fs.existsSync(config); + // } else { + // return fs.existsSync(bundle); + // } + // }); +}; + +// const getNamePlugins = () => { +// const baseFolder = path.join(__dirname, '..', `/src/plugins/countries`); + +// const folders = fs.readdirSync(baseFolder); + +// return folders.filter((folder) => { +// const nameFile = `${baseFolder}/${folder}/names.ts`; +// return fs.existsSync(nameFile); +// }); +// }; + +const getHashFilename = (target) => `__hash-${path.basename(target, path.extname(target))}`; + +const generateWorkerHashfile = (src, folder) => { + const hashFilename = getHashFilename(src); + const fileHash = md5File.sync(`${folder}/${src}`); + const fileWithPath = `${folder}/${hashFilename}`; + + if (fs.existsSync(fileWithPath)) { + fs.unlinkSync(fileWithPath); + } + fs.writeFileSync(fileWithPath, fileHash); +}; + +/** + * Get a new scoped filename for a web worker plugin file (Export Type, Data Type, Country). + * @param file - the full path including filename or just the filename + * @param workerType - "country", "dataType" or "exportType + */ +const getScopedWorkerFilename = (file, workerType) => { + let fileWithoutExt = path.basename(file, path.extname(file)); + + let prefix = ''; + if (workerType === 'dataType') { + prefix = 'DT-'; + } else if (workerType === 'exportType') { + prefix = 'ET-'; + } else if (workerType === 'country') { + prefix = 'C-'; + const folder = path.dirname(file).split(path.sep); + fileWithoutExt = folder[folder.length-1]; + } + + return `${prefix}${fileWithoutExt}.js`; +}; + +const hasWorkerFileChanged = (filename, hashFile) => { + let hasChanged = true; + + if (fs.existsSync(hashFile) && fs.existsSync(filename)) { + const hash = fs.readFileSync(hashFile, 'utf8'); + + if (md5File.sync(filename) === hash) { + hasChanged = false; + } + } + + return hasChanged; +}; + +// returns all the items in arr1 that are not in arr2 +const arrayDiff = (arr1, arr2) => arr1.filter((a) => arr2.indexOf(a) === -1); + + +module.exports = { + getPlugins, + // getNamePlugins, + getHashFilename, + generateWorkerHashfile, + getScopedWorkerFilename, + hasWorkerFileChanged, + arrayDiff +}; diff --git a/apps/client/build/i18n.js b/apps/client/build/i18n.js new file mode 100644 index 000000000..f1b9a889d --- /dev/null +++ b/apps/client/build/i18n.js @@ -0,0 +1,237 @@ +const fs = require('fs'); +const path = require('path'); +const helpers = require('./helpers'); + +const result = require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }); +if (result.error) { + return; +} + +const locales = process.env.GD_LOCALES.split(','); + +const getCoreLocaleFileStrings = (locale) => { + return require(getCoreLocaleFilePath(locale)); +}; + +const findMissingStrings = (stringsByLocale, targetLocale = null, baseLocale = 'en') => { + const locales = Object.keys(stringsByLocale); + const results = []; + + const baseLocaleKeys = Object.keys(stringsByLocale[baseLocale]); + locales.forEach((locale) => { + if (targetLocale && targetLocale !== locale) { + return; + } + + const targetLocaleKeys = Object.keys(stringsByLocale[locale]); + + // missing from source file + const missing = helpers.arrayDiff(baseLocaleKeys, targetLocaleKeys); + missing.forEach((key) => { + results.push({ key, locale }); + }); + + // extra ones in locale file + const extra = helpers.arrayDiff(targetLocaleKeys, baseLocaleKeys); + extra.forEach((key) => { + results.push({ key, locale, isExtra: true }); + }); + }); + + return results; +}; + +const findStringsInDataTypeEnFileMissingFromOtherLangFiles = (results, dataType, stringsByLocale) => { + const langs = Object.keys(stringsByLocale); + + let count = 0; + results.lines.push(`\nEnglish strings missing from other lang files:\n-------------------------------------------`); + Object.keys(stringsByLocale['en']).forEach((key) => { + const missing = []; + langs.forEach((locale) => { + if (targetLocale && targetLocale !== locale) { + return; + } + + if (!stringsByLocale[locale][key]) { + missing.push(locale); + } + }); + if (missing.length > 0) { + count++; + results.lines.push(`${key}\n -missing from: ${missing.join(', ')}`); + } + }); + + if (count > 0) { + results.error = true; + results.lines.push(`-- MISSING ${count}`); + } else { + results.lines.push('All good!\n'); + } + + return results; +}; + + + +const getCoreLocaleFilePath = (locale) => path.join(__dirname, '..', `src/i18n/${locale}.json`); +const getDataTypeLocaleFilePath = (dataType, locale) => path.join(__dirname, '..', `src/plugins/dataTypes/${dataType}/i18n/${locale}.json`); + +const getPluginLocaleFilePath = (plugin, pluginType, locale) => { + const pluginFolder = pluginType === 'dataType' ? 'dataTypes' : 'exportTypes'; + return path.join(__dirname, '..', `src/plugins/${pluginFolder}/${plugin}/i18n/${locale}.json`); +}; + +const removeKeyFromI18nFiles = (key) => { + locales.forEach((locale) => { + const localeFile = getCoreLocaleFileStrings(locale); + delete localeFile[key]; + const file = getCoreLocaleFilePath(locale); + fs.writeFileSync(file, JSON.stringify(localeFile, null, '\t')); + }); +}; + +const parseCoreToFindUnusedStrings = (results, en) => { + // let missingKeys = Object.keys(en); + // + // const ignoreFolders = [ + // 'src/global/lang/', + // 'src/global/vendor/', + // 'src/global/codemirror/', + // 'src/global/fancybox/', + // 'src/global/images/', + // 'dist/', + // 'node_modules/', + // 'src/modules/' + // ]; + // + // const files = walk('./src'); + // files.forEach((file) => { + // for (let i=0; i { + // const regex = new RegExp(key); + // + // // very kludgy, but the only place Form Tools uses dynamic keys is for dates: ignore all those keys. + // // We also ignore any i18n keys flagged for global use across FT modules + // if (!(/^date_/.test(key)) && !regex.test(line) && globalI18nStrings.indexOf(key) === -1) { + // updatedKeys.push(key); + // } + // }); + // + // missingKeys = updatedKeys; + // } + // }); + // + // if (missingKeys.length > 0) { + // results.error = true; + // results.lines.push(`\nUNUSED KEYS:\n${missingKeys.join('\n --')}`); + // } +}; + +const getPluginLocaleStrings = (plugin, pluginType) => { + const result = {}; + locales.forEach((locale) => { + result[locale] = require(getPluginLocaleFilePath(plugin, pluginType, locale)); + }); + return result; +}; + +const validateCoreI18n = (baseLocale, targetLocale) => { + const stringsByLocale = {}; + locales.forEach((locale) => { + stringsByLocale[locale] = getCoreLocaleFileStrings(locale); + }); + + const missing = findMissingStrings(stringsByLocale, targetLocale, baseLocale); + return getMissingStrMessage(missing, baseLocale); +}; + +const validateDataTypeI18n = (baseLocale, targetDataType) => { + const dataTypes = helpers.getPlugins('dataTypes', [], false); + + let str = ''; + dataTypes.forEach((dataType) => { + if (targetDataType && targetDataType !== dataType) { + return; + } + + const stringsByLocale = getPluginLocaleStrings(dataType, 'dataType'); + const missing = findMissingStrings(stringsByLocale); + + str += getMissingStrMessage(missing, baseLocale, `${dataType} -- `); + }); + + return str; +}; + +const validateExportTypeI18n = (baseLocale, targetExportType) => { + const exportTypes = helpers.getPlugins('exportTypes', [], false); + + let str = ''; + exportTypes.forEach((dataType) => { + if (targetExportType && targetExportType !== dataType) { + return; + } + + const stringsByLocale = getPluginLocaleStrings(dataType, 'exportType'); + const missing = findMissingStrings(stringsByLocale); + + str += getMissingStrMessage(missing, baseLocale, `${dataType} -- `); + }); + + return str; +}; + +const getMissingStrMessage = (missing, baseLocale, prefix) => { + let str = ''; + if (missing.length) { + let missingStr = []; + let extraStr = []; + missing.forEach(({ key, locale, isExtra }) => { + if (isExtra) { + extraStr.push(`- ${key}: ${locale}`); + } else { + missingStr.push(`- ${key}: ${locale}`); + } + }); + + if (missingStr.length) { + str += `\n\n${prefix}"${baseLocale}" strings missing from other lang files:\n-------------------------------------------\n`; + str += missingStr.join('\n'); + } + if (extraStr.length) { + str += `\n\n${prefix}Extra strings in locale files that are NOT in "${baseLocale}" file:\n-------------------------------------------\n`; + str += extraStr.join('\n'); + } + } + return str; +}; + +module.exports = { + locales, + getCoreLocaleFileStrings, + parseCoreToFindUnusedStrings, + removeKeyFromI18nFiles, + getPluginLocaleStrings, + validateCoreI18n, + validateDataTypeI18n, + validateExportTypeI18n +}; diff --git a/apps/client/build/rollup-plugin-remove-imports.js b/apps/client/build/rollup-plugin-remove-imports.js new file mode 100644 index 000000000..d83ee3a98 --- /dev/null +++ b/apps/client/build/rollup-plugin-remove-imports.js @@ -0,0 +1,12 @@ +const RemoveImports = () => ({ + name: 'remove-imports', // TODO rename to remove-gd-utils-import if this is all it ends up doing + + transform (code) { + // replace the import utils line with a declaration. This is because utils is included once for all Data Types + // and we don't want to be re-bundling it with each Data Type + const cleanCode = code.replace(/\bimport\sutils[^;]+;/, 'declare var utils: any;'); + return cleanCode; + } +}); + +export default RemoveImports; diff --git a/apps/client/build/rollup-plugin-worker-hash.js b/apps/client/build/rollup-plugin-worker-hash.js new file mode 100644 index 000000000..8e04f14d5 --- /dev/null +++ b/apps/client/build/rollup-plugin-worker-hash.js @@ -0,0 +1,15 @@ +const helpers = require('./build/helpers.js'); // ... yup. + +// runs after each build. It generates a file in the dist folder containing the hash of the file just generated. This +// lets the grunt tasks only every regenerate the files that are necessary - because it's really slow +const WorkerHash = () => { + return { + name: 'WorkerHash', + writeBundle (outputOptions, bundle) { + const file = Object.keys(bundle)[0]; + helpers.generateWorkerHashfile(file, `dist/workers`); + } + }; +}; + +export default WorkerHash; diff --git a/apps/client/gruntfile.js b/apps/client/gruntfile.js new file mode 100644 index 000000000..b52684197 --- /dev/null +++ b/apps/client/gruntfile.js @@ -0,0 +1,524 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const helpers = require('./build/helpers'); +const i18n = require('./build/i18n'); +const clientConfig = require('@generatedata/config/clientConfig'); + +const locales = clientConfig.default.appSettings.GD_LOCALES; + +const distFolder = path.join(__dirname, '/dist'); +if (!fs.existsSync(distFolder)) { + fs.mkdirSync(distFolder); +} + +const workersFolder = path.join(__dirname, '/dist/workers'); +if (!fs.existsSync(workersFolder)) { + fs.mkdirSync(workersFolder); +} + +// returns an 8 char version of the filename hash, used for cache-busting +const getFilenameHash = (filename) => { + const fileBuffer = fs.readFileSync(filename); + const hashSum = crypto.createHash('sha256'); + hashSum.update(fileBuffer); + + return hashSum.digest('hex').substring(0, 8); +}; + +// stored in memory here. For the dev environment, changes to web worker files are watched and built separately, +// then this object is updated with the change & the final map file is regenerated. For prod it's just done in +// one go +const webWorkerMap = { + generationWorker: '', + workerUtils: '', + dataTypes: {}, + exportTypes: {} +}; + +module.exports = function (grunt) { + const dataTypesFolder = 'dataTypes'; + const exportTypesFolder = 'exportTypes'; + const countriesFolder = 'countries'; + const mainTranslationsFolder = 'src/i18n/'; + + const checkPlugin = (pluginType) => { + const folderMap = { + dataType: dataTypesFolder, + exportType: exportTypesFolder, + countries: countriesFolder + }; + + const en = getPluginLocaleFiles(grunt, 'en', folderMap[pluginType]); + + const propsWithI18n = {}; + Object.keys(en).forEach((plugin) => { + Object.keys(en[plugin]).forEach((prop) => { + const matches = en[plugin][prop].match(/%\d/g); + if (!matches) { + return; + } + + if (!propsWithI18n[plugin]) { + propsWithI18n[plugin] = []; + } + propsWithI18n[plugin].push({ prop, count: matches.length }); + }); + }); + + const invalidPlugins = []; + locales.forEach((locale) => { + if (locale === 'en') { + return; + } + const currLangStrings = getPluginLocaleFiles(grunt, locale, folderMap[pluginType]); + + Object.keys(propsWithI18n).forEach((plugin) => { + propsWithI18n[plugin].forEach(({ prop, count }) => { + // now loop through each of the placeholders and confirm that the + let isValid = true; + for (let i = 1; i <= count; i++) { + const re = new RegExp(`%${i}`); + if (!re.test(currLangStrings[plugin][prop])) { + isValid = false; + } + } + + if (!isValid) { + invalidPlugins.push(`Invalid: "${prop}", lang "${locale}", DT: "${plugin}": ${currLangStrings[plugin][prop]}`); + } + }); + }); + }); + + return invalidPlugins; + }; + + const validateStringsWithPlaceholders = () => { + let errors = ''; + const dtErrors = checkPlugin('dataType'); + if (dtErrors.length) { + errors += '\n\nData Type placeholder errors:\n\n' + dtErrors.join('\n'); + } + + const etErrors = checkPlugin('exportType'); + if (etErrors.length) { + errors += 'Export Type placeholder errors:\n\n' + etErrors.join('\n'); + } + + const countriesErrors = checkPlugin('countries'); + if (countriesErrors.length) { + errors += 'Export Type placeholder errors:\n\n' + countriesErrors.join('\n'); + } + + return errors; + }; + + const generateI18nBundles = () => { + const fileHashMap = locales.reduce((acc, locale) => { + const coreLocaleStrings = JSON.parse(fs.readFileSync(`src/i18n/${locale}.json`, 'utf8')); + const dtImports = getPluginLocaleFiles(grunt, locale, dataTypesFolder); + const etImports = getPluginLocaleFiles(grunt, locale, exportTypesFolder); + const countryImports = getPluginLocaleFiles(grunt, locale, countriesFolder); + + acc = { + ...acc, + ...generateLocaleFileTemplate(locale, coreLocaleStrings, dtImports, etImports, countryImports) + }; + return acc; + }, {}); + + // generate the i18n hashmap file. This is imported by the source code to know what files to load + generateI18nHashMap(fileHashMap); + }; + + const generateI18nHashMap = (content) => { + const filename = './_localeFileMap.ts'; + const tsContent = `/* eslint quotes:0 */ +import { GDLocaleMap } from '@generatedata/types'; + +export const localeFileMap: GDLocaleMap = ${JSON.stringify(content, null, '\t')};`; + fs.writeFileSync(filename, tsContent); + }; + + const getPluginLocaleFiles = (grunt, locale, pluginTypeFolder) => { + const fullPluginFolder = path.resolve(__dirname, `./node_modules/@generatedata/plugins/dist/${pluginTypeFolder}`); + const plugins = fs.readdirSync(fullPluginFolder); + const imports = {}; + plugins.forEach((folder) => { + const localeFile = `${fullPluginFolder}/${folder}/i18n/${locale}.json`; + if (fs.existsSync(localeFile)) { + try { + imports[folder] = JSON.parse(fs.readFileSync(localeFile, 'utf8')); + } catch (e) { + grunt.fail.fatal('problem parsing i18n file: ' + localeFile); + } + } + }); + return imports; + }; + + const generateLocaleFileTemplate = (locale, coreLocaleStrings, dtImports, etImports, countryImports) => { + const template = `// DO NOT EDIT. This file is generated by a Grunt task. +// ---------------------------------------------------- + +(function() { +const i18n = { + core: ${JSON.stringify(coreLocaleStrings)}, + dataTypes: ${JSON.stringify(dtImports)}, + exportTypes: ${JSON.stringify(etImports)}, + countries: ${JSON.stringify(countryImports)} +}; + +// load the locale info via an exposed global +window.gd.localeLoaded(i18n); +})();`; + + const filename = `./dist/${locale}.js`; + fs.writeFileSync(filename, template); + + const hash = getFilenameHash(filename); + const hashedFilename = `${locale}-${hash}.js`; + fs.renameSync(filename, `./dist/${hashedFilename}`); + + return { + [locale]: hashedFilename + }; + }; + + // looks through the plugins and finds the plugins that have a generator web worker file + const dataTypeWebWorkerMap = (() => { + const baseFolder = path.join(__dirname, 'node_modules/@generatedata/plugins/dist/dataTypes'); + const folders = fs.readdirSync(baseFolder); + + const map = {}; + folders.forEach((folder) => { + const webworkerFile = path.join(`${baseFolder}/${folder}/${folder}.worker.ts`); + if (!fs.existsSync(webworkerFile)) { + return; + } + // map[`dist/workers/DT-${folder}.worker.js`] = [`src/plugins/dataTypes/${folder}/${folder}.worker.ts`]; + map[`dist/workers/DT-${folder}.worker.js`] = [`${baseFolder}/${folder}/${folder}.worker.ts`]; + }); + + return map; + })(); + + console.log(dataTypeWebWorkerMap); + + // const exportTypeWebWorkerMap = (() => { + // const baseFolder = path.join(__dirname, '/src/plugins/exportTypes'); + // const folders = fs.readdirSync(baseFolder); + + // const map = {}; + // folders.forEach((folder) => { + // const webworkerFile = path.join(__dirname, `/src/plugins/exportTypes/${folder}/${folder}.worker.ts`); + // if (!fs.existsSync(webworkerFile)) { + // return; + // } + // map[`dist/workers/ET-${folder}.worker.js`] = [`src/plugins/exportTypes/${folder}/${folder}.worker.ts`]; + // }); + + // return map; + // })(); + + const webWorkerFileListWithType = [ + { file: 'src/core/generator/generation.worker.ts', type: 'core' }, + { file: 'src/utils/workerUtils.ts', type: 'core' } + ]; + // Object.values(dataTypeWebWorkerMap).forEach((dt) => { + // webWorkerFileListWithType.push({ file: dt[0], type: 'dataType' }); + // }); + // Object.values(exportTypeWebWorkerMap).forEach((et) => { + // webWorkerFileListWithType.push({ file: et[0], type: 'exportType' }); + // }); + + // const webWorkerFileList = webWorkerFileListWithType.map((i) => i.file); + + const generateWorkerMapFile = () => { + fs.writeFileSync('./_pluginWebWorkers.ts', `/* eslint quotes:0 */\nexport default ${JSON.stringify(webWorkerMap, null, '\t')};`); + }; + + const getWebWorkerShellCommands = (omitFiles = {}) => { + const commands = {}; + + webWorkerFileListWithType.forEach(({ file, type }, index) => { + if (omitFiles[file]) { + return; + } + + const filename = path.basename(file, path.extname(file)); + let target = `dist/workers/${filename}.js`; + + if (['dataType', 'exportType'].indexOf(type) !== -1) { + // 'country' + const filename = helpers.getScopedWorkerFilename(file, type); + target = `dist/workers/${filename}`; + } + + // TODO detect when the command is run and look for the generated __hash-[filename] content, then update + commands[`buildWebWorker${index}`] = { + command: `npx rollup -c --config-src=${file} --config-target=${target}` + }; + }); + + return commands; + }; + + // generating every web worker bundle takes time. To get around that, rollup generates a file in the dist/workers + // file for each bundle, with the filename of form: + // Plugins (e.g.): + // __hash-DT-Alphanumeric.generator + // __hash-ET-JSON.generator + // + // Core workers: + // __hash-core.worker + // __hash-generation.worker + // __hash-workerUtils + // we then use that information here to check to see if we need to regenerate or not + const getWebWorkerBuildCommandNames = () => { + const omitFiles = {}; + webWorkerFileListWithType.forEach(({ file, type }) => { + const filename = helpers.getScopedWorkerFilename(file, type); + const filenameHash = helpers.getHashFilename(filename); + + if (!helpers.hasWorkerFileChanged(`${workersFolder}/${filename}`, `${workersFolder}/${filenameHash}`)) { + omitFiles[file] = true; + } + }); + + return Object.keys(getWebWorkerShellCommands(omitFiles)).map((cmdName) => `shell:${cmdName}`); + }; + + // const webWorkerWatchers = (() => { + // const tasks = {}; + + // // this contains *ALL* web worker tasks. It ensures that everything is watched. + // webWorkerFileList.forEach((workerPath, index) => { + // tasks[`webWorkerWatcher${index}`] = { + // files: [workerPath], + // options: { spawn: false }, + // tasks: [`shell:buildWebWorker${index}`, `md5:webWorkerMd5Task${index}`, 'generateWorkerMapFile'] + // }; + // }); + + // return tasks; + // })(); + + const processMd5Change = (fileChanges) => { + const oldPath = fileChanges[0].oldPath; + const oldFile = path.basename(oldPath); + const newFilename = path.basename(fileChanges[0].newPath); + + if (oldPath === 'dist/workers/generation.worker.js') { + webWorkerMap.generationWorker = newFilename; + } else if (oldPath === 'dist/workers/workerUtils.js') { + webWorkerMap.workerUtils = newFilename; + } else { + const [pluginFolder] = oldFile.split('.'); + const cleanPluginFolder = pluginFolder.replace(/^(DT-|ET-)/, ''); + + if (/^DT-/.test(oldFile)) { + webWorkerMap.dataTypes[cleanPluginFolder] = newFilename; + } else if (/^ET-/.test(oldFile)) { + webWorkerMap.exportTypes[cleanPluginFolder] = newFilename; + } + } + }; + + // these tasks execute individually AFTER the worker has already been generated in the dist/workers folder + const webWorkerMd5Tasks = (() => { + const tasks = {}; + webWorkerFileListWithType.forEach(({ file, type }, index) => { + const fileName = helpers.getScopedWorkerFilename(file, type); + const newFileLocation = `dist/workers/${fileName}`; // N.B. here it's now a JS file, not TS + + tasks[`webWorkerMd5Task${index}`] = { + files: { + [newFileLocation]: newFileLocation + }, + options: { + after: (fileChanges) => processMd5Change(fileChanges, webWorkerMap) + } + }; + }); + + return tasks; + })(); + + const getWebWorkerMd5TaskNames = () => { + return Object.keys(webWorkerMd5Tasks).map((cmdName) => `md5:${cmdName}`); + }; + + grunt.initConfig({ + cssmin: { + options: { + mergeIntoShorthands: false, + roundingPrecision: -1 + }, + target: { + files: { + 'dist/styles.css': [ + 'src/resources/codemirror.css', + 'src/resources/ambience.css', + 'src/resources/bespin.css', + 'src/resources/cobalt.css', + 'src/resources/darcula.css', + 'src/resources/lucario.css' + ] + } + } + }, + + copy: { + main: { + files: [ + { + expand: true, + cwd: 'src/images', + src: ['*'], + dest: 'dist/images/' + } + ] + }, + + // TODO should minify these too + codeMirrorModes: { + files: [ + { + expand: true, + cwd: './node_modules/codemirror/mode', + src: ['**/*'], + dest: 'dist/codeMirrorModes/' + } + ] + } + }, + + clean: { + dist: ['dist'] + }, + + shell: { + webpackProd: { + command: 'npm run prod' + }, + + // note these aren't executed right away, so they contain ALL web workers, even those don't need regeneration + ...getWebWorkerShellCommands() + }, + + watch: { + // ...webWorkerWatchers + }, + + md5: { + ...webWorkerMd5Tasks + } + }); + + // const validateI18n = () => { + // const baseLocale = grunt.option('baseLocale') || 'en'; + // const targetLocale = grunt.option('locale') || null; + // const targetDataType = grunt.option('dataType') || null; + // const targetExportType = grunt.option('exportType') || null; + + // let errors = ''; + // if (targetDataType) { + // errors += i18n.validateDataTypeI18n(baseLocale, targetDataType); + // } else if (targetExportType) { + // errors += i18n.validateExportTypeI18n(baseLocale, targetDataType); + // } else { + // errors += i18n.validateCoreI18n(baseLocale, targetLocale); + // errors += i18n.validateDataTypeI18n(baseLocale); + // errors += i18n.validateExportTypeI18n(baseLocale); + // } + + // errors += validateStringsWithPlaceholders(); + + // if (errors) { + // grunt.fail.fatal(errors); + // } + // }; + + // const sortI18nFiles = () => { + // i18n.locales.forEach((locale) => { + // const data = i18n.getCoreLocaleFileStrings(locale); + // const file = `./src/i18n/${locale}.json`; + // const sortedKeys = Object.keys(data).sort(); + + // let sortedObj = {}; + // sortedKeys.forEach((key) => { + // sortedObj[key] = data[key]; + // }); + + // fs.writeFileSync(file, JSON.stringify(sortedObj, null, '\t')); + // }); + // }; + + // helper methods to operate on all lang files at once + // grunt.registerTask('removeI18nKey', () => { + // const key = grunt.option('key') || null; + // if (!key) { + // grunt.fail.fatal('Please enter a key to remove. Format: `grunt removeI18nKey --key=word_goodbye'); + // } + // i18n.removeKeyFromI18nFiles(grunt.option('key')); + // }); + + // grunt.registerTask('addLocale', () => { + // const locale = grunt.option('locale') || null; + // if (!locale) { + // grunt.fail.fatal('Please enter a locale to add. Locales should be the ISO-3166 2-char code: `grunt addLocale --locale=xy'); + // } + + // const dataTypes = fs.readdirSync(dataTypesFolder); + // dataTypes.forEach((folder) => { + // const en = `${dataTypesFolder}/${folder}/i18n/en.json`; + // const newLocaleFile = `${dataTypesFolder}/${folder}/i18n/${locale}.json`; + // if (fs.existsSync(en)) { + // fs.copyFileSync(en, newLocaleFile); + // } + // }); + + // const exportTypes = fs.readdirSync(exportTypesFolder); + // exportTypes.forEach((folder) => { + // const en = `${exportTypesFolder}/${folder}/i18n/en.json`; + // const newLocaleFile = `${exportTypesFolder}/${folder}/i18n/${locale}.json`; + // if (fs.existsSync(en)) { + // fs.copyFileSync(en, newLocaleFile); + // } + // }); + + // const countries = fs.readdirSync(countriesFolder); + // countries.forEach((folder) => { + // const en = `${countriesFolder}/${folder}/i18n/en.json`; + // const newLocaleFile = `${countriesFolder}/${folder}/i18n/${locale}.json`; + // if (fs.existsSync(en)) { + // fs.copyFileSync(en, newLocaleFile); + // } + // }); + + // // main translation file + // const mainEn = `${mainTranslationsFolder}/en.json`; + // if (fs.existsSync(mainEn)) { + // fs.copyFileSync(mainEn, `${mainTranslationsFolder}/${locale}.json`); + // } + // }); + + grunt.loadNpmTasks('grunt-contrib-cssmin'); + grunt.loadNpmTasks('grunt-contrib-copy'); + grunt.loadNpmTasks('grunt-contrib-clean'); + grunt.loadNpmTasks('grunt-shell'); + grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-contrib-watch'); + grunt.loadNpmTasks('grunt-md5'); + + // grunt.registerTask('sortI18nFiles', sortI18nFiles); + grunt.registerTask('default', ['cssmin', 'copy', 'generateI18nBundles', 'webWorkers']); + // grunt.registerTask('dev', ['cssmin', 'copy', 'generateI18nBundles', 'webWorkers', 'watch']); + grunt.registerTask('generateWorkerMapFile', generateWorkerMapFile); + grunt.registerTask('generateI18nBundles', generateI18nBundles); + // grunt.registerTask('validateI18n', validateI18n); + + grunt.registerTask('webWorkers', [...getWebWorkerBuildCommandNames(), ...getWebWorkerMd5TaskNames(), 'generateWorkerMapFile']); +}; diff --git a/apps/client/package.json b/apps/client/package.json new file mode 100644 index 000000000..143fab46a --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,205 @@ +{ + "name": "@generatedata/client", + "description": "Client-side code for generatedata app", + "private": true, + "scripts": { + "build": "grunt && npm run webpackProd", + "clean": "npx rimraf client/dist", + "coverage": "npx jest --coverage", + "i18n": "grunt && grunt i18n", + "start": "grunt dev & npm run build & npm run webpackDev", + "test": "env NODE_ENV=test jest", + "webWorkers": "grunt && grunt webWorkers", + "webpackDev": "webpack serve --config ./webpack.config.js --mode=development", + "webpackProd": "webpack --config ./webpack.config.js --mode=production" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "tsx", + "js" + ], + "testEnvironment": "jsdom", + "transform": { + "node_modules/nanoid/index.js$": "ts-jest", + "node_modules/@react-hook/throttle/dist/module/index.js$": "ts-jest", + "\\.(ts|tsx)$": "ts-jest" + }, + "transformIgnorePatterns": [ + "node_modules/(?!nanoid)/.*", + "node_modules/@react-hook/throttle/dist/module/.*" + ], + "setupFilesAfterEnv": [ + "/client/tests/jestSetup.ts" + ], + "testRegex": "/__tests__/.*\\.(ts|tsx)$", + "moduleNameMapper": { + "\\.(css|less|scss|sss|styl)$": "/node_modules/jest-css-modules", + "^~components(.*)$": "/client/src/components$1", + "^~types(.*)$": "/client/types$1", + "^~utils(.*)$": "/client/src/utils$1", + "^~store(.*)$": "/client/src/core/store$1", + "^~core(.*)$": "/client/src/core$1" + }, + "collectCoverageFrom": [ + "/client/src/**/*.(ts|tsx)" + ], + "coveragePathIgnorePatterns": [ + "bundle.ts", + ".*.scss.d.ts", + ".*.types.d.ts" + ], + "modulePathIgnorePatterns": [ + "/cli/dist" + ] + }, + "globals": { + "ts-jest": { + "tsconfig": { + "allowJs": true + } + } + }, + "dependencies": { + "@apollo/client": "^4.0.0", + "@date-io/date-fns": "^1.3.13", + "@mui/material": "^7.3.1", + "@mui/icons-material": "^7.3.1", + "@mui/x-date-pickers": "^8.10.2", + "@react-hook/throttle": "^2.2.0", + "@reduxjs/toolkit": "^2.8.2", + "@types/randomcolor": "^0.5.9", + "@uidotdev/usehooks": "^2.4.1", + "codemirror": "^5.58.2", + "date-fns": "^2.17.0", + "dotenv": "^8.2.0", + "googleapis": "^65.0.0", + "graphql": "^16.11.0", + "immer": "^10.1.1", + "js-cookie": "^3.0.5", + "nanoid": "^5.1.5", + "pretty-bytes": "^7.0.1", + "randomcolor": "^0.6.2", + "react": "^19.1.1", + "react-beautiful-dnd": "^13.1.1", + "react-codemirror2": "^8.0.1", + "react-copy-to-clipboard": "^5.1.0", + "react-countup": "^6.5.3", + "react-dom": "^19.1.1", + "react-hooks-window-size": "^0.3.0", + "react-input-autosize": "^3.0.0", + "react-number-format": "5.4.4", + "react-redux": "^9.2.0", + "react-router": "^7.8.2", + "react-router-dom": "^7.8.2", + "react-select": "^5.10.2", + "react-sortable-hoc": "^1.11.0", + "react-split-pane": "^0.1.89", + "reactour": "^1.18.0", + "recharts": "^3.1.2", + "redux": "^5.0.1", + "redux-persist": "^6.0.0", + "rollup": "^2.31.0", + "rxjs": "^7.3.0", + "sass": "^1.49.9", + "styled-components": "^4.0.0", + "terser-webpack-plugin": "^3.0.3", + "underscore": "^1.13.7" + }, + "devDependencies": { + "@babel/core": "^7.7.4", + "@babel/plugin-proposal-class-properties": "^7.10.4", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/preset-env": "^7.7.4", + "@babel/preset-react": "^7.7.4", + "@esbuild-plugins/tsconfig-paths": "^0.0.4", + "@generatedata/config": "workspace:*", + "@generatedata/plugins": "workspace:*", + "@generatedata/utils": "workspace:*", + "@generatedata/types": "workspace:*", + "@rollup/plugin-commonjs": "^15.1.0", + "@rollup/plugin-node-resolve": "^8.4.0", + "@stylistic/eslint-plugin-js": "^3.1.0", + "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", + "@testing-library/react": "^16.3.0", + "@types/codemirror": "^0.0.99", + "@types/jest": "^26.0.15", + "@types/js-cookie": "^3.0.5", + "@types/loadable__component": "^5.13.8", + "@types/node": "^20.19.4", + "@types/react": "^19.1.11", + "@types/react-beautiful-dnd": "^13.1.1", + "@types/react-copy-to-clipboard": "^5.0.7", + "@types/react-input-autosize": "^2.2.4", + "@types/reactour": "^1.18.5", + "@types/redux-testkit": "^1.0.8", + "@types/sinon": "^7.5.1", + "@typescript-eslint/eslint-plugin": "^5.54.0", + "@typescript-eslint/parser": "^5.54.0", + "autoprefixer": "6.7.2", + "babel-eslint": "^10.0.3", + "babel-loader": "^8.0.6", + "babel-preset-airbnb": "^4.4.0", + "browser-env": "3.3.0", + "case-sensitive-paths-webpack-plugin": "^2.3.0", + "concurrently": "^5.2.0", + "coveralls": "^3.0.9", + "cross-fetch": "^3.1.5", + "css-loader": "^3.4.0", + "cypress": "^6.0.1", + "dotenv-webpack": "^6.0.0", + "eslint": "^8.52.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.32.0", + "eslint-webpack-plugin": "^4.0.1", + "extract-text-webpack-plugin": "1.0.1", + "grunt": "^1.6.1", + "grunt-cli": "^1.3.2", + "grunt-contrib-clean": "^2.0.0", + "grunt-contrib-copy": "^1.0.0", + "grunt-contrib-cssmin": "^5.0.0", + "grunt-contrib-uglify": "^5.2.2", + "grunt-contrib-watch": "^1.1.0", + "grunt-md5": "^0.1.12", + "grunt-shell": "^4.0.0", + "history": "^5.3.0", + "html-webpack-plugin": "^5.5.0", + "jest": "^29.4.3", + "jest-cli": "^29.4.3", + "jest-css-modules": "^2.1.0", + "jest-environment-jsdom": "^29.4.3", + "json-loader": "0.5.4", + "md5-file": "^5.0.0", + "mini-css-extract-plugin": "^0.8.0", + "postcss-loader": "1.2.2", + "prettier": "^3.4.2", + "prettier-eslint": "^16.3.0", + "promise": "7.1.1", + "redux-testkit": "^1.0.6", + "reselect": "^4.0.0", + "rimraf": "^6.0.1", + "rollup-plugin-multi-entry": "^2.1.0", + "rollup-plugin-strip-exports": "^2.0.6", + "rollup-plugin-terser": "^6.1.0", + "rollup-plugin-typescript2": "^0.27.1", + "sass-loader": "^16.0.5", + "sinon": "^8.0.4", + "style-loader": "^1.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.4.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "4.1.1", + "typescript": "^4.9.3", + "use-onclickoutside": "^0.3.1", + "webpack": "^5.94.0", + "webpack-bundle-analyzer": "^4.8.0", + "webpack-cli": "^5.0.1", + "webpack-dev-middleware": "^6.1.2", + "webpack-dev-server": "^4.11.0", + "webpack-hot-middleware": "^2.25.3", + "webpack-manifest-plugin": "5.0.0" + } +} diff --git a/apps/client/rollup.config.js b/apps/client/rollup.config.js new file mode 100644 index 000000000..16a9faa74 --- /dev/null +++ b/apps/client/rollup.config.js @@ -0,0 +1,72 @@ +/** + * This generates es5 files for single entry-point TS files. It's used for the webworker files: core, core utils, plugins. + * + * TODO at the moment we're actually loading the utils code twice: once for the web workers, one in the code bundle. + * The core script COULD load this generated file & use the methods from the window object; as long as the typings were + * provided that'd cut down on build size. But honestly it's <20KB and there are bigger fish to fry. + */ +import path from 'path'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from 'rollup-plugin-typescript2'; +import removeExports from 'rollup-plugin-strip-exports'; +import { terser } from 'rollup-plugin-terser'; +import removeImports from './build/rollup-plugin-remove-imports'; +import workerHash from './build/rollup-plugin-worker-hash'; + +// example usage: +// npx rollup -c --config-src=src/utils/coreUtils.ts --config-target=dist/workers/coreUtils.js +// npx rollup -c --config-src=src/utils/workerUtils.ts --config-target=dist/debug.js +// npx rollup -c --config-src=src/plugins/countries/Australia/bundle.ts --config-target=dist/australia.js +// npx rollup -c --config-src=src/plugins/dataTypes/AutoIncrement/AutoIncrement.generator.ts --config-target=dist/workers/DT-AutoIncrement.generator.js +export default (cmdLineArgs) => { + const { 'config-src': src, 'config-target': target } = cmdLineArgs; + + if (!src || !target) { + console.error('\n*** Missing command line args. See file for usage. ***\n'); + return; + } + + const terserCompressProps = {}; + + // the whole point of the workerUtils file is to expose all utility methods in a single `utils` object + // for use by plugin web workers. This is available on the global scope within a web worker + if (src === 'src/utils/workerUtils.ts') { + terserCompressProps.top_retain = ['utils', 'onmessage']; + } else if (/src\/plugins\/countries/.test(src)) { + const folder = path.dirname(src).split(path.sep); + terserCompressProps.top_retain = [folder[folder.length - 1]]; + } else { + terserCompressProps.unused = true; + terserCompressProps.top_retain = ['utils', 'onmessage']; + } + + return { + input: src, + output: { + file: target, + format: 'es' + }, + treeshake: false, + plugins: [ + removeImports(), + commonjs(), + nodeResolve(), + typescript({ + tsconfigOverride: { + compilerOptions: { + target: 'es5' + } + } + }), + terser({ + mangle: false, + compress: { + ...terserCompressProps + } + }), + removeExports(), + workerHash() + ] + }; +}; diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx new file mode 100644 index 000000000..4106df99f --- /dev/null +++ b/apps/client/src/app.tsx @@ -0,0 +1,109 @@ +/* istanbul ignore file */ +import React, { useEffect } from 'react'; +import { Provider } from 'react-redux'; +import { BrowserRouter as Router, Routes, Route, useNavigation } from 'react-router-dom'; +import { ApolloProvider } from '@apollo/client/react'; +import * as codemirror from 'codemirror'; +import { PersistGate } from 'redux-persist/integration/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { apolloClient } from '~core/apolloClient'; +import store, { persistor } from '~core/store'; +import Page from '~core/page/Page.container'; +import * as core from '~core/index'; +import ErrorBoundary from '~core/ErrorBoundary.component'; +import theme from '~core/theme'; +import SaveDataSetDialog from '~core/dialogs/saveDataSet/SaveDataSet.container'; +import Toast from '~components/toast/Toast.component'; +import C from '@generatedata/config/constants'; +import { getAppStateVersion } from '~store/main/main.selectors'; +import { resetStore, initRouteListener } from '~store/main/main.actions'; +import { getRoutes } from '~utils/routeUtils'; +import { getLocaleMap } from '@generatedata/utils/lang'; +import '~store/generator/generator.reducer'; +import './styles/global.scss'; + +window.CodeMirror = codemirror; + +const checkState = async (state: any): Promise => { + const lastAppStateVersion = getAppStateVersion(state.getState()); + if (lastAppStateVersion !== C.APP_STATE_VERSION) { + await state.dispatch(resetStore()); + } +}; + +const routes = getRoutes(); + +// there's probably a cleaner way to do this, but this seems performant and not too complicated +const LocalizationWrapper = (args: any) => { + const availableLocalesMap = getLocaleMap(); + const lang = args.match?.params?.lang; + let localizedRoutes = routes; + + // this rewrites any routes that include a valid (known) lang path root folder so the routing + // works as expected. Note: the actual loading of the locale file will have taken place prior to here. It either + // occurs on first boot and handles waiting to show the whole application until it's ready, or when + // the user changes is via the lang selector dialog - that alters the URL to include the locale only after it's been + // successfully loaded + if (lang && lang !== 'en' && availableLocalesMap[lang]) { + localizedRoutes = routes.map((route) => ({ + ...route, + path: `/${lang}${route.path}` + })); + } + + return ( + + {localizedRoutes.map(({ path, component: Component }, index) => ( + + + + ))} + + ); +}; + +const App = () => { + const navigation = useNavigation(); + + useEffect(() => { + initRouteListener(navigation); + }, []); + + return ( + + + + + + + + + + ); +}); + +const AppWrapper = () => ( + + + + => checkState(store)}> + {(bootstrapped) => { + // PersistGate handles repopulating the redux store; core.init() re-initializes everything else we + // need, including checking auth and loading the appropriate locale file + if (bootstrapped) { + core.init(); + } + + return ( + + + + ); + }} + + + + +); + +export default AppWrapper; diff --git a/apps/client/src/components/Buttons.component.tsx b/apps/client/src/components/Buttons.component.tsx new file mode 100644 index 000000000..700736d0e --- /dev/null +++ b/apps/client/src/components/Buttons.component.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { styled } from '@mui/material/styles'; +import Button, { ButtonProps } from '@mui/material/Button'; + +export const PrimaryButton = ({ children, ...props }: ButtonProps) => ( + +); + +export const NullButton = ({ children, ...props }: ButtonProps) => ( + +); + +const SecondaryStyledButton = styled(Button)(() => ({ + root: { + border: '1px solid #047a12', + color: '#047a12', + backgroundColor: '#fafffb', + '&:hover': { + border: '1px solid #0e961e', + backgroundColor: '#f6fff7' + } + } +})); + +export const SecondaryButton = ({ children, ...props }: ButtonProps) => ( + + {children} + +); + +export const StyledPreviewPanelButton = styled(Button)(() => ({ + root: { + borderColor: '#ffffff', + color: '#ffffff', + marginRight: 6, + '&:hover': { + backgroundColor: '#0069d9', + borderColor: '#0062cc', + boxShadow: 'none' + } + } +})); + +export const PreviewPanelButton = ({ children, ...props }: ButtonProps) => ( + + {children} + +); diff --git a/apps/client/src/components/Link.component.tsx b/apps/client/src/components/Link.component.tsx new file mode 100644 index 000000000..a55711da7 --- /dev/null +++ b/apps/client/src/components/Link.component.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export type LinkParams = { + url: string; + children?: any; + offSite?: boolean; +}; + +const Link = ({ url, children = null, offSite = false }: LinkParams) => { + const props: any = { + href: url + }; + if (offSite) { + props.target = '_blank'; + props.rel = 'noopener noreferrer'; + } + + return {children ? children : url}; +}; + +export default Link; diff --git a/apps/client/src/components/Pagination.tsx b/apps/client/src/components/Pagination.tsx new file mode 100644 index 000000000..9666aef65 --- /dev/null +++ b/apps/client/src/components/Pagination.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import MuiPagination from '@mui/material/Pagination'; + +const Pagination = ({ numPages, currentPage, onChange }: any) => ; + +export default Pagination; diff --git a/apps/client/src/components/Portal.tsx b/apps/client/src/components/Portal.tsx new file mode 100644 index 000000000..82eff71f3 --- /dev/null +++ b/apps/client/src/components/Portal.tsx @@ -0,0 +1,11 @@ +import { ReactNode, ReactPortal } from 'react'; +import { createPortal } from 'react-dom'; +import usePortal from '../hooks/usePortal'; + +const Portal = ({ id, children }: { id: string; children: ReactNode }): ReactPortal => { + const target = usePortal(id); + + return createPortal(children, target, null); +}; + +export default Portal; diff --git a/apps/client/src/components/TextField.tsx b/apps/client/src/components/TextField.tsx new file mode 100644 index 000000000..7069e7092 --- /dev/null +++ b/apps/client/src/components/TextField.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { ErrorTooltip } from '~components/tooltips'; +import sharedStyles from '../styles/shared.scss'; +import { useThrottle } from '../hooks/useThrottle'; + +type TextFieldProps = { + value: string; + onChange: (e: any) => void; + throttle?: boolean; + error?: string; + ref?: React.MutableRefObject; + placeholder?: string; + autoFocus?: boolean; + className?: string; + tooltipPlacement?: string; + name?: string; + style?: React.CSSProperties; + disabled?: boolean; + type?: string; + onPaste?: (e: any) => void; + onKeyDown?: (e: any) => void; +}; + +const TextField = ({ throttle, error, value, onChange, tooltipPlacement, className, ref, ...props }: TextFieldProps) => { + let classes = className ? className : ''; + if (error) { + classes += ' ' + sharedStyles.errorField; + } + + const [innerValue, setInnerValue] = useState(value || ''); + const [lastEvent, setChangeEvent] = useThrottle(null, 2); // second param is frames per second... + + const cleanProps = { ...props }; + if (props.type === 'intOnly') { + cleanProps.type = 'number'; + cleanProps.onKeyDown = (e: any): void => { + if (e.key === '.') { + e.preventDefault(); + } + }; + } + + React.useEffect(() => { + if (lastEvent === null || !throttle) { + return; + } + onChange(lastEvent); + }, [lastEvent]); + + React.useEffect(() => { + setInnerValue(value); + }, [value]); + + const controlledOnChange = (e: any): void => { + if (throttle) { + e.persist(); + setChangeEvent(e); + setInnerValue(e.target.value); + } else { + onChange(e); + } + }; + + return ( + + + + ); +}; +TextField.displayName = 'TextField'; + +TextField.defaultProps = { + throttle: true, + type: 'text', + error: '', + tooltipPlacement: 'bottom' +}; + +export default TextField; diff --git a/apps/client/src/components/__tests__/Link.component.tsx b/apps/client/src/components/__tests__/Link.component.tsx new file mode 100644 index 000000000..bb00fc88c --- /dev/null +++ b/apps/client/src/components/__tests__/Link.component.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Link from '../Link.component'; + +describe('Link', () => { + it('renders link as label if not supplied', () => { + const { container } = render(); + + expect(container.innerHTML).toEqual('http://google.com'); + }); + + it('renders link as label if not supplied', () => { + const { container } = render(Link here); + + expect(container.innerHTML).toEqual('Link here'); + }); + + it('renders offsite links', () => { + const { container } = render( + + Link here + + ); + + expect(container.innerHTML).toEqual('Link here'); + }); +}); diff --git a/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.component.tsx b/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.component.tsx new file mode 100644 index 000000000..892c3e0ee --- /dev/null +++ b/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.component.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { AccountStatus } from '~types/account'; +import styles from './AccountStatusPill.scss'; + +type AccountStatusPillProps = { + status: AccountStatus; + i18n: any; +}; + +const AccountStatusPill = ({ status, i18n }: AccountStatusPillProps) => { + let label; + if (status === 'live') { + label = i18n.live; + } else if (status === 'expired') { + label = i18n.expired; + } else if (status === 'disabled') { + label = i18n.disabled; + } + + return {label}; +}; + +export default AccountStatusPill; diff --git a/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.scss b/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.scss new file mode 100644 index 000000000..0bb39445f --- /dev/null +++ b/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.scss @@ -0,0 +1,20 @@ +.pill { + border-radius: 3px; + padding: 1px 6px 2px; + font-size: 11px; +} + +.live { + background-color: green; + color: white; +} + +.disabled { + background-color: #999999; + color: white; +} + +.expired { + background-color: #990000; + color: white; +} diff --git a/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.scss.d.ts b/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.scss.d.ts new file mode 100644 index 000000000..42c0d5c15 --- /dev/null +++ b/apps/client/src/components/accounts/accountStatusPill/AccountStatusPill.scss.d.ts @@ -0,0 +1,15 @@ +declare namespace AccountStatusPillScssNamespace { + export interface IAccountStatusPillScss { + disabled: string; + expired: string; + live: string; + pill: string; + } +} + +declare const AccountStatusPillScssModule: AccountStatusPillScssNamespace.IAccountStatusPillScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: AccountStatusPillScssNamespace.IAccountStatusPillScss; +}; + +export = AccountStatusPillScssModule; diff --git a/apps/client/src/components/accounts/accountStatusPill/__tests__/AccountStatusPill.test.tsx b/apps/client/src/components/accounts/accountStatusPill/__tests__/AccountStatusPill.test.tsx new file mode 100644 index 000000000..a7875d305 --- /dev/null +++ b/apps/client/src/components/accounts/accountStatusPill/__tests__/AccountStatusPill.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import AccountStatusPill from '../AccountStatusPill.component'; +import { render } from '@testing-library/react'; +import { AccountStatus } from '~types/account'; + +const i18n = require('../../../../i18n/en.json'); + +describe('AccountStatusPill', () => { + it('renders live pill', () => { + const { baseElement } = render(); + expect((baseElement.querySelector('span') as HTMLSpanElement).innerHTML).toEqual(i18n.live); + }); + + it('renders expired pill', () => { + const { baseElement } = render(); + expect((baseElement.querySelector('span') as HTMLSpanElement).innerHTML).toEqual(i18n.expired); + }); + + it('renders disabled pill', () => { + const { baseElement } = render(); + expect((baseElement.querySelector('span') as HTMLSpanElement).innerHTML).toEqual(i18n.disabled); + }); +}); diff --git a/apps/client/src/components/accounts/mainFields/MainFields.component.tsx b/apps/client/src/components/accounts/mainFields/MainFields.component.tsx new file mode 100644 index 000000000..45a37565f --- /dev/null +++ b/apps/client/src/components/accounts/mainFields/MainFields.component.tsx @@ -0,0 +1,224 @@ +import React, { useRef, useEffect, useState } from 'react'; +import Button from '@mui/material/Button'; +import TextField from '~components/TextField'; +import IconButton from '@mui/material/IconButton'; +import Dropdown from '~components/dropdown/Dropdown'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { canadianProvinceOptions, countryDropdownOptions } from '@generatedata/plugins'; +import Refresh from '@mui/icons-material/Refresh'; +import { AccountEditingData } from '~store/account/account.reducer'; +import { isValidEmail } from '@generatedata/utils/general'; +import { generateRandomAlphanumericStr } from '@generatedata/utils/random'; +import sharedStyles from '../../../styles/shared.scss'; + +export type MainFieldsProps = { + data: AccountEditingData; + accountHasChanges: boolean; + updateAccount: (data: AccountEditingData) => void; + submitButtonLabel: string; + i18n: any; + onSave: (setOneTimePassword: boolean) => void; + onCancel: () => void; + showRequiredFieldError: boolean; + isAddingUser: boolean; + className?: string; +}; + +const MainFields = ({ + data, + accountHasChanges, + updateAccount, + onSave, + onCancel, + submitButtonLabel, + i18n, + showRequiredFieldError, + isAddingUser, + className = '' +}: MainFieldsProps) => { + const emailFieldRef = useRef(null); + const [oneTimePasswordFieldVisible, setOneTimePasswordFieldVisible] = useState(false); + + // very fussy indeed! + const [emailFieldHasFocus, setEmailFieldHasFocus] = useState(false); + const [emailFieldHasHadFocus, setEmailFieldHasHadFocus] = useState(false); + + const onBlurEmail = (): void => { + setEmailFieldHasFocus(false); + setEmailFieldHasHadFocus(true); + }; + + const update = (fieldName: string, value: string): void => { + updateAccount({ + ...data, + [fieldName]: value + }); + }; + + let fieldsValid = true; + if (!data.firstName.trim() || !data.lastName.trim() || !data.email.trim()) { + if (showRequiredFieldError) { + fieldsValid = false; + } + } + let emailError; + if (data.email.trim() === '') { + if (showRequiredFieldError) { + emailError = i18n.requiredField; + } + } else if (!isValidEmail(data.email)) { + // subtle. We only want to show the email field is in an invalid state when + // (a) adding an email and the user's moved off the field & left it in an invalid state + // (b) is editing the email + if (!isAddingUser || (emailFieldHasHadFocus && !emailFieldHasFocus)) { + emailError = i18n.validationInvalidEmail; + fieldsValid = false; + } + } + let oneTimePasswordError; + if (isAddingUser && data.oneTimePassword?.trim() === '') { + oneTimePasswordError = i18n.requiredField; + } + + const saveButtonEnabled = accountHasChanges && fieldsValid; + + const getCanadianRegions = () => { + if (data.country !== 'CA') { + return null; + } + + return ( + <> + +
+ update('region', item.value)} options={canadianProvinceOptions} /> +
+ + ); + }; + + const handleSave = (e: any): void => { + e.preventDefault(); + + if (!fieldsValid) { + return; + } + + onSave(isAddingUser && oneTimePasswordFieldVisible); + }; + + const generatePassword = () => { + const pwd = generateRandomAlphanumericStr('CcEVvFLlDXxH'); + update('oneTimePassword', pwd); + }; + + const firstNameError = showRequiredFieldError && data.firstName.trim() === '' ? i18n.requiredField : ''; + const lastNameError = showRequiredFieldError && data.lastName.trim() === '' ? i18n.requiredField : ''; + + let cancelLinkClasses = sharedStyles.cancelLink; + if (!saveButtonEnabled) { + cancelLinkClasses += ` ${sharedStyles.hidden}`; + } + + return ( +
+
+ +
+ update('firstName', e.target.value)} + style={{ width: '100%' }} + autoFocus + /> +
+ + +
+ update('lastName', e.target.value)} + style={{ width: '100%' }} + /> +
+ + +
+ update('email', e.target.value)} + onFocus={(): void => setEmailFieldHasFocus(true)} + onBlur={onBlurEmail} + style={{ width: '100%' }} + ref={emailFieldRef} + /> +
+ + +
+ update('country', item.value)} options={countryDropdownOptions} /> +
+ + {getCanadianRegions()} + + {isAddingUser && ( +
+ + setOneTimePasswordFieldVisible(!oneTimePasswordFieldVisible)} + /> + } + label="Set one-time password" + /> + + {oneTimePasswordFieldVisible && ( + <> +
+ update('oneTimePassword', e.target.value)} + style={{ width: '100%', marginRight: 8 }} + /> + + + +
+ + )} +
+ )} +
+ +
+ + + + {i18n.cancel} + +
+
+ ); +}; + +export default MainFields; diff --git a/apps/client/src/components/accounts/manageAccount/ManageAccount.component.tsx b/apps/client/src/components/accounts/manageAccount/ManageAccount.component.tsx new file mode 100644 index 000000000..ca99dae26 --- /dev/null +++ b/apps/client/src/components/accounts/manageAccount/ManageAccount.component.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import { format, fromUnixTime, add } from 'date-fns'; +import MainFields from '~components/accounts/mainFields/MainFields.component'; +import RadioPill, { RadioPillRow } from '~components/pills/RadioPill'; +import { LocalizedDatePicker, LocalizedDatePickerProvider } from '~components/datePicker/LocalizedDatePicker.component'; +import { getFormattedNum } from '@generatedata/utils/number'; +import * as dateStyles from '@generatedata/plugins/dist/dataTypes/Date/Date.scss'; +import C from '@generatedata/config/constants'; +import styles from './ManageAccount.scss'; + +export type ManageAccountProps = { + i18n: any; + onSave: (state: ManageAccountState) => void; + initialState: ManageAccountState; + submitButtonLabel: string; + onCancel?: () => void; +}; + +export enum ExpiryOption { + none = 'none', + date = 'date' +} + +export type ManageAccountState = { + firstName: string; + lastName: string; + email: string; + country: string; + region: string; + oneTimePassword?: string; + disabled: boolean; + expiry: ExpiryOption; + expiryDate: null | number; + numRowsGenerated: number; + isAddingUser: boolean; +}; + +const yearFromNow = Number(format(add(new Date(), { years: 1 }), 't')); + +const ManageAccount = ({ i18n, onCancel, onSave, initialState, submitButtonLabel }: ManageAccountProps) => { + const [data, setData] = useState(initialState); + const [showDatepicker, setShowDatepicker] = useState(false); + const [showErrors] = useState(false); + + let accountHasChanges = data.firstName !== '' && data.lastName !== '' && data.email !== '' && data.country !== ''; + if (data.country === 'CA' && data.region === '') { + accountHasChanges = false; + } + + const onClickCancel = (): void => { + setData(initialState); + if (onCancel) { + onCancel(); + } + }; + + const updateAccountData = (newData: any): void => { + setData({ + ...data, + ...newData + }); + }; + + const toggleAccountDisabled = (disabled: boolean): void => { + setData({ + ...data, + disabled + }); + }; + + const toggleExpiry = (expiry: ExpiryOption): void => { + setData({ + ...data, + expiry + }); + + if (expiry === ExpiryOption.date) { + setShowDatepicker(true); + } + }; + + const onSelectDate = (expiryDate: any): void => { + setData({ + ...data, + expiryDate + }); + }; + + const accountData = { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + country: data.country, + region: data.region, + oneTimePassword: data.oneTimePassword, + numRowsGenerated: data.numRowsGenerated + }; + + let expiryLabel = i18n.selectExpiryDate; + + if (data.expiryDate) { + expiryLabel = format(fromUnixTime(Math.round(data.expiryDate / 1000)), C.DATE_FORMAT); + } + + return ( +
+
+ { + if (!setOneTimePassword) { + data.oneTimePassword = undefined; + } + onSave(data); + }} + isAddingUser={data.isAddingUser} + /> +
+
+
+
+ toggleAccountDisabled(e.target.checked)} + /> + +
+
+
+
+ + toggleExpiry(ExpiryOption.none)} + name="expiry" + checked={data.expiry === 'none'} + style={{ marginRight: 6 }} + /> + toggleExpiry(ExpiryOption.date)} + name="expiry" + checked={data.expiry === 'date'} + /> + +
+
+
+ +
{getFormattedNum(initialState.numRowsGenerated)}
+
+
+ +
+ + onSelectDate(format(val, 'T'))} + onClose={(): void => setShowDatepicker(false)} + /> + +
+
+ ); +}; + +export default ManageAccount; diff --git a/apps/client/src/components/accounts/manageAccount/ManageAccount.scss b/apps/client/src/components/accounts/manageAccount/ManageAccount.scss new file mode 100644 index 000000000..0c536e507 --- /dev/null +++ b/apps/client/src/components/accounts/manageAccount/ManageAccount.scss @@ -0,0 +1,38 @@ +.root { + display: flex; + flex: 1; + margin-bottom: 15px; +} + +.rightCol { + flex: 1; + margin-left: 20px; + border-left: 1px solid #f2f2f2; + padding-left: 20px; +} + +.disabledFieldRow { + display: flex; + margin-bottom: 15px; + margin-top: 2px; +} + +.rightBlock { + & > div { + margin-bottom: 15px; + font-size: 32px; + } +} + +@media (max-width: 720px) { + .root { + flex-direction: column; + } + + .rightCol { + margin-top: 20px; + margin-left: 0; + border-left: 0; + padding-left: 0; + } +} diff --git a/apps/client/src/components/accounts/manageAccount/ManageAccount.scss.d.ts b/apps/client/src/components/accounts/manageAccount/ManageAccount.scss.d.ts new file mode 100644 index 000000000..b548ad859 --- /dev/null +++ b/apps/client/src/components/accounts/manageAccount/ManageAccount.scss.d.ts @@ -0,0 +1,15 @@ +declare namespace ManageAccountScssNamespace { + export interface IManageAccountScss { + disabledFieldRow: string; + rightBlock: string; + rightCol: string; + root: string; + } +} + +declare const ManageAccountScssModule: ManageAccountScssNamespace.IManageAccountScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: ManageAccountScssNamespace.IManageAccountScss; +}; + +export = ManageAccountScssModule; diff --git a/apps/client/src/components/copyToClipboard/CopyToClipboard.scss b/apps/client/src/components/copyToClipboard/CopyToClipboard.scss new file mode 100644 index 000000000..f755a16df --- /dev/null +++ b/apps/client/src/components/copyToClipboard/CopyToClipboard.scss @@ -0,0 +1,9 @@ +.copyIcon { + color: #bbbbbb; + cursor: pointer; + font-size: 12px; +} + +:global(.MuiAlert-message) { + font-size: 13px; +} diff --git a/apps/client/src/components/copyToClipboard/CopyToClipboard.scss.d.ts b/apps/client/src/components/copyToClipboard/CopyToClipboard.scss.d.ts new file mode 100644 index 000000000..49cfc1c6a --- /dev/null +++ b/apps/client/src/components/copyToClipboard/CopyToClipboard.scss.d.ts @@ -0,0 +1,12 @@ +declare namespace CopyToClipboardScssNamespace { + export interface ICopyToClipboardScss { + copyIcon: string; + } +} + +declare const CopyToClipboardScssModule: CopyToClipboardScssNamespace.ICopyToClipboardScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: CopyToClipboardScssNamespace.ICopyToClipboardScss; +}; + +export = CopyToClipboardScssModule; diff --git a/apps/client/src/components/copyToClipboard/CopyToClipboard.tsx b/apps/client/src/components/copyToClipboard/CopyToClipboard.tsx new file mode 100644 index 000000000..b875c2bb5 --- /dev/null +++ b/apps/client/src/components/copyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; +import FileCopyIcon from '@mui/icons-material/FileCopy'; +import { addToast } from '@generatedata/utils/general'; +import styles from './CopyToClipboard.scss'; + +const Copy = ({ message, tooltip, content }: any) => { + const onCopy = (): void => { + addToast({ + type: 'success', + message, + verticalPosition: 'top' + }); + }; + + return ( + + + + ); +}; + +export default Copy; diff --git a/apps/client/src/components/copyToClipboard/__tests__/CopyToClipboard.test.tsx b/apps/client/src/components/copyToClipboard/__tests__/CopyToClipboard.test.tsx new file mode 100644 index 000000000..d87ed6ac4 --- /dev/null +++ b/apps/client/src/components/copyToClipboard/__tests__/CopyToClipboard.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import CopyToClipboard from '../CopyToClipboard'; + +jest.mock('copy-to-clipboard', () => { + return jest.fn(); +}); + +const defaultProps = { + message: 'Message here', + tooltip: 'Tooltip here', + content: 'Content here', + autoHideDuration: 0 +}; + +describe('CopyToClipboard', () => { + it('renders copy icon with tooltip', () => { + const { baseElement } = render(); + + expect(baseElement.querySelector('.copyIcon')).toBeTruthy(); + expect(baseElement.innerHTML).toContain(defaultProps.tooltip); + }); +}); diff --git a/apps/client/src/components/creatablePillField/CreatablePillField.scss b/apps/client/src/components/creatablePillField/CreatablePillField.scss new file mode 100644 index 000000000..0e84a8742 --- /dev/null +++ b/apps/client/src/components/creatablePillField/CreatablePillField.scss @@ -0,0 +1,6 @@ +@use '../../styles/variables' as c; + +.errorField > div, +.errorField:hover > div { + border: 1px solid c.$error; +} diff --git a/apps/client/src/components/creatablePillField/CreatablePillField.scss.d.ts b/apps/client/src/components/creatablePillField/CreatablePillField.scss.d.ts new file mode 100644 index 000000000..98d9da3ae --- /dev/null +++ b/apps/client/src/components/creatablePillField/CreatablePillField.scss.d.ts @@ -0,0 +1,12 @@ +declare namespace CreatablePillFieldScssNamespace { + export interface ICreatablePillFieldScss { + errorField: string; + } +} + +declare const CreatablePillFieldScssModule: CreatablePillFieldScssNamespace.ICreatablePillFieldScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: CreatablePillFieldScssNamespace.ICreatablePillFieldScss; +}; + +export = CreatablePillFieldScssModule; diff --git a/apps/client/src/components/creatablePillField/CreatablePillField.tsx b/apps/client/src/components/creatablePillField/CreatablePillField.tsx new file mode 100644 index 000000000..70029da7d --- /dev/null +++ b/apps/client/src/components/creatablePillField/CreatablePillField.tsx @@ -0,0 +1,158 @@ +import * as React from 'react'; +import { components } from 'react-select'; +import CreatableSelect from 'react-select/creatable'; +import { SortableContainer, SortableElement } from 'react-sortable-hoc'; +import { DropdownOption } from '../dropdown/Dropdown'; +import { ErrorTooltip } from '~components/tooltips'; +import { arrayMove } from '@generatedata/utils/array'; +import styles from './CreatablePillField.scss'; + +export const SortableMultiValue = SortableElement((props: any) => { + const onMouseDown = (e: any): void => { + e.preventDefault(); + e.stopPropagation(); + }; + const innerProps = { onMouseDown }; + return ; +}); + +const customComponents = { + DropdownIndicator: null, + MultiValue: SortableMultiValue +}; + +const selectStyles = { + control: (base: React.CSSProperties): React.CSSProperties => ({ + ...base, + boxShadow: 'none', + minHeight: 30, + maxHeight: 100, + overflow: 'scroll' + }), + dropdownIndicator: (base: React.CSSProperties): React.CSSProperties => ({ + ...base, + padding: 4 + }), + clearIndicator: (base: React.CSSProperties): React.CSSProperties => ({ + ...base, + padding: 4 + }), + multiValue: (base: React.CSSProperties): React.CSSProperties => ({ + ...base, + backgroundColor: '#e0ebfd' + }), + valueContainer: (base: React.CSSProperties): React.CSSProperties => ({ + ...base, + padding: '0px 2px' + }), + container: (base: React.CSSProperties): React.CSSProperties => ({ + ...base + }), + input: (base: React.CSSProperties): React.CSSProperties => ({ + ...base, + margin: 0, + padding: 0 + }), + indicatorsContainer: (base: React.CSSProperties): React.CSSProperties => ({ + ...base, + alignItems: 'flex-start' + }) +}; + +export const createOption = (label: string): DropdownOption => ({ + label, + value: label +}); + +const SortableCreatableSelect: any = SortableContainer(CreatableSelect); + +export type CreatablePillFieldProps = { + onChange: (newValues: string[]) => void; + value: string[]; + error: string; + placeholder: string; + onValidateNewItem?: (value: string) => boolean; + className?: string; + isClearable?: boolean; +}; + +const CreatablePillField = ({ + onChange, + onValidateNewItem, + value, + error, + placeholder, + className, + isClearable = true +}: CreatablePillFieldProps) => { + const [tempValue, setTempValue] = React.useState(''); + const options = value.map(createOption); + + const handleInputChange = (newTempValue: string): void => setTempValue(newTempValue); + const handleKeyDown = (e: any): void => { + if (!tempValue) { + return; + } + switch (e.key) { + case 'Enter': + case 'Tab': + if (onValidateNewItem) { + const isValid = onValidateNewItem(tempValue); + if (!isValid) { + return; + } + } + setTempValue(''); + onChange([...value, tempValue]); + e.preventDefault(); + } + }; + + const onSortEnd = ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }): void => { + const sortedOptions = arrayMove(options, oldIndex, newIndex); + onChange(sortedOptions.map((i: DropdownOption) => i.value)); + }; + + const classes: string[] = []; + if (className) { + classes.push(className); + } + if (error) { + classes.push(styles.errorField); + } + + // onCreateOption={(a: any) => console.log(a)} + return ( + + node.getBoundingClientRect()} + isClearable={isClearable} + isMulti + onSortEnd={onSortEnd} + menuIsOpen={false} + onChange={(options: any): void => { + const newValues = options ? options.map(({ value }: DropdownOption) => value) : []; + onChange(newValues); + }} + onInputChange={handleInputChange} + onKeyDown={handleKeyDown} + placeholder={placeholder} + value={options} + menuPlacement="auto" + menuPortalTarget={document.body} + /> + + ); +}; +CreatablePillField.defaultProps = { + placeholder: 'Press enter to create item', + error: '' +}; + +export default CreatablePillField; diff --git a/apps/client/src/components/creatablePillField/__tests__/CreatablePillField.test.tsx b/apps/client/src/components/creatablePillField/__tests__/CreatablePillField.test.tsx new file mode 100644 index 000000000..aef4f7a00 --- /dev/null +++ b/apps/client/src/components/creatablePillField/__tests__/CreatablePillField.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import CreatablePillField from '../CreatablePillField'; + +const defaultProps = { + onChange: () => {}, + value: ['one', 'two', 'three'] +}; + +describe('CreatablePillField', () => { + it('renders', () => { + const { baseElement } = render(); + }); +}); diff --git a/apps/client/src/components/datePicker/LocalizedDatePicker.component.tsx b/apps/client/src/components/datePicker/LocalizedDatePicker.component.tsx new file mode 100644 index 000000000..a35379bfa --- /dev/null +++ b/apps/client/src/components/datePicker/LocalizedDatePicker.component.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import DateFnsUtils from '@date-io/date-fns'; +// import { DatePicker, MuiPickersUtilsProvider } from '@mui/lab'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { getLocale, getStrings } from '@generatedata/utils/lang'; +import { arDZ, de, enUS, es, fr, ja, hi, nl, pt, ru, ta, zhCN } from 'date-fns/locale'; + +// localized wrapper for the date picker provider +export const LocalizedDatePicker = (props: any) => { + const { core: i18n } = getStrings(); + + return ; +}; + +// localized wrapper for the date picker provider +export const LocalizedDatePickerProvider = ({ children }: any) => { + const locale = getLocale(); + + const localeMap = { + ar: arDZ, + en: enUS, + fr, + de, + es, + ja, + hi, + nl, + pt, + ru, + ta, + zh: zhCN + }; + + return ( + + {children} + + ); +}; diff --git a/apps/client/src/components/dialogs/index.tsx b/apps/client/src/components/dialogs/index.tsx new file mode 100644 index 000000000..2a148985a --- /dev/null +++ b/apps/client/src/components/dialogs/index.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import MuiDialog from '@mui/material/Dialog'; +import MuiDialogTitle from '@mui/material/DialogTitle'; +import MuiDialogContent from '@mui/material/DialogContent'; +import MuiDialogActions from '@mui/material/DialogActions'; +import { styled, withStyles, makeStyles } from '@mui/material'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import Typography from '@mui/material/Typography'; + +const dialogStyles = (theme: any): any => ({ + root: { + margin: 0, + padding: theme.spacing(2) + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: 6, + color: theme.palette.grey[500] + } +}); + +export const DialogTitle = withStyles(dialogStyles)((props: any): any => { + const { children, classes, onClose, customCloseIcon, ...other } = props; + const Close = customCloseIcon ? customCloseIcon : CloseIcon; + + return ( + + {children} + {onClose ? ( + + + + ) : null} + + ); +}); + +export const DialogContent = styled(MuiDialogContent)((theme) => ({ + root: { + padding: theme.spacing(2) + } +})); + +export const DialogActions = styled(MuiDialogActions)((theme) => ({ + root: { + margin: 0, + padding: theme.spacing(1) + } +})); + +const useDialogStyles = makeStyles({ + root: { + // @ts-ignore-line + zIndex: '5005 !important', + width: '100%' + }, + paper: { + borderRadius: 6, + maxWidth: 'inherit' + } +}); + +export const Dialog = (props: any) => { + const { root, paper } = useDialogStyles(props); + + return ; +}; diff --git a/apps/client/src/components/dropdown/Dropdown.scss b/apps/client/src/components/dropdown/Dropdown.scss new file mode 100644 index 000000000..9deb9690c --- /dev/null +++ b/apps/client/src/components/dropdown/Dropdown.scss @@ -0,0 +1,5 @@ +@use '../../styles/variables' as c; + +.error > div { + border: 1px solid c.$error !important; +} diff --git a/apps/client/src/components/dropdown/Dropdown.scss.d.ts b/apps/client/src/components/dropdown/Dropdown.scss.d.ts new file mode 100644 index 000000000..946c30fdf --- /dev/null +++ b/apps/client/src/components/dropdown/Dropdown.scss.d.ts @@ -0,0 +1,12 @@ +declare namespace DropdownScssNamespace { + export interface IDropdownScss { + error: string; + } +} + +declare const DropdownScssModule: DropdownScssNamespace.IDropdownScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: DropdownScssNamespace.IDropdownScss; +}; + +export = DropdownScssModule; diff --git a/apps/client/src/components/dropdown/Dropdown.tsx b/apps/client/src/components/dropdown/Dropdown.tsx new file mode 100644 index 000000000..6fcf3ef8b --- /dev/null +++ b/apps/client/src/components/dropdown/Dropdown.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import Select from 'react-select'; +import { getStrings } from '@generatedata/utils/lang'; +import C from '@generatedata/config/constants'; +import * as styles from './Dropdown.scss'; + +export type DropdownOption = { + value: string; + label: string; +}; + +const selectStyles = { + control: (provided: any): any => ({ + ...provided, + minHeight: 20, + boxShadow: 'none' + }), + indicatorsContainer: (provided: any): any => ({ + ...provided, + height: 28 + }), + indicatorContainer: (provided: any): any => ({ + ...provided, + padding: 5 + }), + menuPortal: (base: any): any => ({ ...base, zIndex: C.ZINDEXES.DIALOG }) +}; + +const Dropdown = ({ value, isGrouped, options, hasError, placeholder, ...props }: any) => { + const i18n = getStrings(); + + // react-select has a terrible API. You need to pass the entire selected object as the `value` prop to prefill it. + // That's not something you'd normally save - you just want to save a single value, because, well, duh. This makes + // thing like localization a total pain. So to make it behave like a sane component, our component here just accepts + // a single `value` prop - which is either the single value for the field, or an array if it's isMulti. This code + // converts it to an object/array of objects for react-select + let selectedValue: any = ''; + if (isGrouped) { + if (props.isMulti) { + selectedValue = []; + options.filter(() => { + // group: any + // TODO + return true; + }); + } else { + options.find((group: any) => { + const found = group.options.find((row: any) => row.value === value); + if (found && found !== -1) { + selectedValue = found; + return true; + } + return false; + }); + } + } else { + if (props.isMulti) { + selectedValue = options.filter((row: any): any => value.indexOf(row.value) !== -1); + } else { + selectedValue = options.find((row: any): any => row.value === value); + } + } + + const placeholderStr = placeholder ? placeholder : i18n.core.selectEllipsis; + + // for debugging: menuIsOpen={true} + + let className = props.className || ''; + if (hasError) { + className += ` ${styles.error}`; + } + + return ( + {}} /> + {label} + + ); + + if (tooltip) { + return ( + } + arrow + disableHoverListener={disabled} + disableFocusListener={disabled} + > + {button} + + ); + } + + return button; +}; + +BasePill.defaultProps = { + disabled: false, + style: {} +}; + +export default BasePill; diff --git a/apps/client/src/components/pills/CheckboxPill.tsx b/apps/client/src/components/pills/CheckboxPill.tsx new file mode 100644 index 000000000..366762088 --- /dev/null +++ b/apps/client/src/components/pills/CheckboxPill.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import BasePill, { PillType } from './BasePill'; + +type CheckboxPillProps = { + label: string; + onClick: () => void; + name: string; + checked: boolean; + disabled?: boolean; + tooltip?: string; + style?: any; +}; + +const CheckboxPill = ({ label, onClick, name, checked, disabled, tooltip, style }: CheckboxPillProps) => ( + +); + +export default CheckboxPill; diff --git a/apps/client/src/components/pills/RadioPill.tsx b/apps/client/src/components/pills/RadioPill.tsx new file mode 100644 index 000000000..a9e19a4d4 --- /dev/null +++ b/apps/client/src/components/pills/RadioPill.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import BasePill, { PillType } from './BasePill'; + +export { PillRow as RadioPillRow } from './BasePill'; + +type RadioPillProps = { + label: string; + onClick: () => void; + name: string; + checked: boolean; + disabled?: boolean; + tooltip?: string; + style?: any; +}; + +const RadioPill = ({ label, onClick, name, checked, disabled, tooltip, style }: RadioPillProps) => ( + +); + +export default RadioPill; diff --git a/apps/client/src/components/pills/__tests__/RadioPill.test.tsx b/apps/client/src/components/pills/__tests__/RadioPill.test.tsx new file mode 100644 index 000000000..ee012e137 --- /dev/null +++ b/apps/client/src/components/pills/__tests__/RadioPill.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import RadioPill, { RadioPillRow } from '../RadioPill'; +import { render } from '@testing-library/react'; + +describe('RadioPillRow', () => { + it('renders children', () => { + const { baseElement } = render( + +
Children!
+
+ ); + + expect(baseElement.innerHTML).toContain('Children!'); + }); + + it('includes the classname passed', () => { + const { baseElement } = render( + +
Children!
+
+ ); + + expect(baseElement.querySelector('.this-is-a-test')).toBeTruthy(); + }); +}); + +describe('RadioPill', () => { + it('renders label', () => { + const { container } = render( + {}} name="name123" checked={false} disabled={false} tooltip="Yo yo yo" /> + ); + + expect(container.innerHTML).toContain('Label!'); + }); + + it('includes name field', () => { + const { container } = render( + {}} name="name123" checked={false} disabled={false} /> + ); + + expect(container.querySelector('[name=name123]')).toBeTruthy(); + }); +}); diff --git a/apps/client/src/components/tables/TableHeader.component.tsx b/apps/client/src/components/tables/TableHeader.component.tsx new file mode 100644 index 000000000..9340df805 --- /dev/null +++ b/apps/client/src/components/tables/TableHeader.component.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import ArrowDropUp from '@mui/icons-material/ArrowDropUp'; +import ArrowDropDown from '@mui/icons-material/ArrowDropDown'; +import * as styles from './TableHeader.scss'; + +export type TableCol = { + label?: string; + field?: string; + className?: string; + sortable?: boolean; +}; + +export const enum ColSortDir { + asc = 'ASC', + desc = 'DESC' +} + +export type TableHeaderProps = { + cols: TableCol[]; + sortCol?: string; + sortDir?: ColSortDir; + onSort?: (col: string, dir: ColSortDir) => void; +}; + +const TableHeader = ({ cols, sortCol, sortDir, onSort }: TableHeaderProps) => { + const columns = cols.map((col: TableCol, index: number) => { + let colClasses = styles.colHeader; + if (col.className) { + colClasses += ` ${col.className}`; + } + if (col.sortable) { + colClasses += ` ${styles.sortable}`; + } + + const colProps: any = { + className: colClasses + }; + + let sorter: any = null; + let colSortDir = sortDir === ColSortDir.asc ? ColSortDir.desc : ColSortDir.asc; + + if (col.field === sortCol) { + if (sortDir === ColSortDir.asc) { + sorter = ; + colSortDir = ColSortDir.desc; + } else { + sorter = ; + colSortDir = ColSortDir.asc; + } + } + + if (col.sortable) { + colProps.onClick = (): void => onSort!(col.field!, colSortDir); + } + + return ( +
+ {col.label || ''} + {sorter} +
+ ); + }); + + return
{columns}
; +}; + +export default TableHeader; diff --git a/apps/client/src/components/tables/TableHeader.scss b/apps/client/src/components/tables/TableHeader.scss new file mode 100644 index 000000000..8ccbf5f97 --- /dev/null +++ b/apps/client/src/components/tables/TableHeader.scss @@ -0,0 +1,39 @@ +.header { + font-weight: bold; + font-size: 13px; + color: #666666; + padding-top: 2px; + border-bottom: 1px solid #333333; +} + +.row { + display: flex; + width: 100%; + padding: 6px; + align-items: center; + font-size: 13px; +} + +.colHeader { + display: flex; + align-items: center; + + &.sortable { + cursor: pointer; + + svg { + font-size: 20px; + margin-right: 6px; + } + + &:hover { + svg { + fill: #111111; + } + } + } + + span { + flex: 1; + } +} diff --git a/apps/client/src/components/tables/TableHeader.scss.d.ts b/apps/client/src/components/tables/TableHeader.scss.d.ts new file mode 100644 index 000000000..4df8af32c --- /dev/null +++ b/apps/client/src/components/tables/TableHeader.scss.d.ts @@ -0,0 +1,15 @@ +declare namespace TableHeaderScssNamespace { + export interface ITableHeaderScss { + colHeader: string; + header: string; + row: string; + sortable: string; + } +} + +declare const TableHeaderScssModule: TableHeaderScssNamespace.ITableHeaderScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: TableHeaderScssNamespace.ITableHeaderScss; +}; + +export = TableHeaderScssModule; diff --git a/apps/client/src/components/toast/Toast.component.tsx b/apps/client/src/components/toast/Toast.component.tsx new file mode 100644 index 000000000..0217393e6 --- /dev/null +++ b/apps/client/src/components/toast/Toast.component.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useImperativeHandle, useRef } from 'react'; +import MuiAlert, { AlertProps, Color } from '@mui/material/Alert'; +import Snackbar, { SnackbarOrigin } from '@mui/material/Snackbar'; +import Portal from '~components/Portal'; +import { initToast, ToastType } from '@generatedata/utils/general'; +import './Toast.scss'; + +const defaultMessage: ToastType = { + type: 'success' as Color, + message: '', + verticalPosition: 'top' as SnackbarOrigin['vertical'], + horizontalPosition: 'center' as SnackbarOrigin['horizontal'], + autoHideDuration: 5000 +}; + +const Alert = (props: AlertProps) => ; + +let timeout: any; +const Toast = () => { + const snackbarRef = useRef(); + const [open, setOpen] = React.useState(false); + const [payload, setPayload] = React.useState(defaultMessage); + + useEffect(() => { + initToast(snackbarRef.current); + }, []); + + // @ts-ignore-line + useImperativeHandle(snackbarRef, () => ({ + add: (message: ToastType): void => { + if (timeout) { + clearTimeout(timeout); + } + + const payload = { + ...defaultMessage, + ...message + } as ToastType; + setOpen(true); + setPayload(payload); + + timeout = setTimeout(() => { + setOpen(false); + clearTimeout(timeout); + }, payload.autoHideDuration); + } + })); + + const handleClose = (): void => { + setOpen(false); + }; + + return ( + + + + {payload.message} + + + + ); +}; + +Toast.defaultProps = { + type: 'success', + autoHideDuration: 5000 +}; + +export default Toast; diff --git a/apps/client/src/components/toast/Toast.scss b/apps/client/src/components/toast/Toast.scss new file mode 100644 index 000000000..63e0da394 --- /dev/null +++ b/apps/client/src/components/toast/Toast.scss @@ -0,0 +1,3 @@ +:global(#gd-toast) :global(.MuiSnackbar-root) { + z-index: 5010; +} diff --git a/apps/client/src/components/toast/Toast.scss.d.ts b/apps/client/src/components/toast/Toast.scss.d.ts new file mode 100644 index 000000000..e39605711 --- /dev/null +++ b/apps/client/src/components/toast/Toast.scss.d.ts @@ -0,0 +1,12 @@ +declare namespace ToastScssNamespace { + export interface IToastScss { + toast: string; + } +} + +declare const ToastScssModule: ToastScssNamespace.IToastScss & { + /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ + locals: ToastScssNamespace.IToastScss; +}; + +export = ToastScssModule; diff --git a/apps/client/src/components/toast/__tests__/Toast.test.tsx b/apps/client/src/components/toast/__tests__/Toast.test.tsx new file mode 100644 index 000000000..dd3d210ab --- /dev/null +++ b/apps/client/src/components/toast/__tests__/Toast.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import Toast from '../Toast.component'; +import { render, act } from '@testing-library/react'; +import { addToast } from '@generatedata/utils/general'; + +describe('Toast', () => { + it('renders', () => { + const { baseElement } = render(); + expect(baseElement.querySelector('#gd-toast')).toBeTruthy(); + }); + + it('shows a toast', () => { + const { baseElement } = render(); + + act(() => { + addToast({ + message: 'Hello world!', + type: 'success' + }); + }); + + expect(baseElement.innerHTML).toContain('Hello world!'); + }); +}); diff --git a/apps/client/src/components/tooltips.tsx b/apps/client/src/components/tooltips.tsx new file mode 100644 index 000000000..ba411dc75 --- /dev/null +++ b/apps/client/src/components/tooltips.tsx @@ -0,0 +1,43 @@ +import MuiTooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; + +export const HtmlTooltip = styled(MuiTooltip)(() => ({ + tooltip: { + backgroundColor: '#ffffff', + color: 'rgba(0, 0, 0, 0.87)', + fontSize: 12, + padding: 0, + boxShadow: '3px 3px 6px #999999' + }, + arrow: { + color: '#ffffff' + } +})); + +export const Tooltip = styled(MuiTooltip)(() => ({ + tooltip: { + backgroundColor: '#333333', + maxWidth: 220, + color: '#dddddd', + lineHeight: '16px', + fontSize: 11, + padding: 10 + }, + arrow: { + color: '#333333' + } +})); + +export const ErrorTooltip = styled(MuiTooltip)(() => ({ + tooltip: { + backgroundColor: '#D80000', + maxWidth: 220, + color: '#ffffff', + lineHeight: '16px', + fontSize: 11, + padding: 10 + }, + arrow: { + color: '#D80000' + } +})); diff --git a/apps/client/src/core/ErrorBoundary.component.tsx b/apps/client/src/core/ErrorBoundary.component.tsx new file mode 100644 index 000000000..028217bc1 --- /dev/null +++ b/apps/client/src/core/ErrorBoundary.component.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { persistor } from './store'; +import Button from '@mui/material/Button'; +import { Cockroach } from '~components/icons'; +import Header from './header/Header.container'; +import Footer from './footer/Footer.container'; + +class ErrorBoundary extends React.Component { + constructor(props: any) { + super(props); + this.state = { + hasError: false, + error: '' + }; + this.onClear = this.onClear.bind(this); + } + + static getDerivedStateFromError(error: string): any { + return { + hasError: true, + error + }; + } + + // componentDidCatch(error: any, errorInfo: any): any { + // // logErrorToMyService(error, errorInfo); + // } + + onClear(): void { + persistor.purge().then(() => { + this.setState({ + hasError: false, + error: '' + }); + }); + } + + render(): any { + if (this.state.hasError) { + return ( + <> +
+
+

Something went terribly, terribly wrong.

+ +
+
+ +
+
+

+ Sorry! Some sort of error occurred. This project is still in alpha so you may see this page a little more than you'd like. + Feel free to complain about it via a{' '} + + github issue + {' '} + — but we will get to it! +

+ + + +
{this.state.error}
+
+
+
+