├── .ackrc ├── .circleci └── config.yml ├── .dockerignore ├── .env.example ├── .gitignore ├── .jshintignore ├── .jshintrc ├── .node-version ├── .nvmrc ├── .prettierrc.json ├── .storybook ├── addons.js ├── config.js ├── preview-head.html └── webpack.config.js ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── Procfile ├── README.md ├── __generated__ └── globalTypes.ts ├── __mocks__ ├── fileMock.js ├── react.js └── styleMock.js ├── acceptance.jestrc.json ├── app.json ├── codegen.yml ├── config ├── default.js ├── development.js ├── production.js └── test.js ├── db ├── helpers.ts ├── migrate-and-seed.ts └── migrations │ ├── .gitkeep │ ├── 20181203090529_sketch-out-db.ts │ ├── 20190110233521_create-event-log.ts │ └── 20190127135006_add-effect-to-eventlog.ts ├── docker-compose.yml ├── docker ├── Dockerfile ├── docker.env ├── entrypoint.sh └── supervisord.conf ├── entry ├── client.tsx ├── index.html ├── scripts │ ├── .gitkeep │ ├── job-worker.ts │ ├── main-queue-processor.ts │ ├── migrate-and-seed.ts │ └── unit-test-before-all.ts └── server.ts ├── jest.config.js ├── knexfile.ts ├── modules ├── __tests__ │ ├── db-helpers.ts │ └── setup-enzyme.js ├── atomic-object │ ├── blueprints │ │ ├── blueprint.ts │ │ ├── canvas.ts │ │ └── index.ts │ ├── cache │ │ ├── index.ts │ │ ├── stores │ │ │ ├── memory.ts │ │ │ ├── null.ts │ │ │ └── redis.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── cqrs │ │ ├── __test__ │ │ │ ├── actions.test.ts │ │ │ └── background-actions.test.ts │ │ ├── actions.ts │ │ └── dispatch.ts │ ├── error-notifier.ts │ ├── forms │ │ ├── core.ts │ │ ├── index.tsx │ │ ├── mutation-form.tsx │ │ ├── use-mutation-form.tsx │ │ ├── use-query-for-initial-form.ts │ │ └── use-translated-validation-schema.ts │ ├── i18n │ │ ├── __tests__ │ │ │ └── index.test.ts │ │ ├── component.tsx │ │ └── index.ts │ ├── jobs │ │ ├── __tests__ │ │ │ └── job-runner.test.ts │ │ ├── index.ts │ │ ├── mapping.ts │ │ └── processing-function.ts │ ├── logger.ts │ ├── option.ts │ ├── readme.md │ ├── records.ts │ └── result.ts ├── blueprints │ ├── __tests__ │ │ └── builder.test.ts │ └── index.ts ├── client │ ├── __tests__ │ │ ├── storybook-helper.ts │ │ └── translations.test.tsx │ ├── actions │ │ └── index.ts │ ├── analytics │ │ ├── index.ts │ │ └── load-ga.js │ ├── bootstrap-mui.ts │ ├── components │ │ ├── app-header │ │ │ └── index.tsx │ │ ├── app │ │ │ └── index.tsx │ │ ├── button-link │ │ │ ├── button-link-stories.tsx │ │ │ └── index.tsx │ │ ├── copy │ │ │ └── index.tsx │ │ ├── error-boundary │ │ │ ├── error-boundary.stories.tsx │ │ │ └── index.tsx │ │ ├── error │ │ │ ├── error.stories.tsx │ │ │ └── index.tsx │ │ ├── form │ │ │ ├── autosave.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── error-list.tsx │ │ │ ├── select.tsx │ │ │ ├── show-form-data.tsx │ │ │ ├── text-field.tsx │ │ │ └── time-select.tsx │ │ ├── loading-dialog │ │ │ ├── index.tsx │ │ │ └── loading-dialog.stories.tsx │ │ └── section-provider │ │ │ └── index.tsx │ ├── core │ │ └── index.tsx │ ├── en-US.json │ ├── express.d.ts │ ├── ga.d.ts │ ├── graphql-types.ts │ ├── graphql │ │ ├── __tests__ │ │ │ ├── local-date.test.ts │ │ │ └── local-name.test.ts │ │ ├── client-context.ts │ │ ├── client.ts │ │ ├── core.ts │ │ ├── error-link.ts │ │ ├── fragments │ │ │ ├── .gitkeep │ │ │ └── UserInfo.graphql │ │ ├── hooks.ts │ │ ├── mutations │ │ │ └── ChangeLocalName.graphql │ │ ├── queries │ │ │ ├── GetLocalName.graphql │ │ │ ├── GetLoggedInUser.graphql │ │ │ └── LocalDate.graphql │ │ ├── resolvers │ │ │ ├── index.ts │ │ │ ├── mutation.ts │ │ │ └── query.ts │ │ ├── schema.graphql │ │ └── state-link.ts │ ├── index.tsx │ ├── material-ui-core-styles-create-mui-theme.d.ts │ ├── material-ui-styles.d.ts │ ├── pages │ │ ├── error │ │ │ └── error-loaders.tsx │ │ └── home │ │ │ ├── __tests__ │ │ │ └── home-page.test.tsx │ │ │ ├── home-page-ui.stories.tsx │ │ │ ├── home-page-ui.tsx │ │ │ └── index.tsx │ ├── react.d.ts │ ├── routes │ │ └── authentication-routes.tsx │ ├── stories.ts │ ├── storybook-addon-viewport.d.ts │ ├── storybook-decorators.tsx │ ├── styles │ │ ├── index.tsx │ │ └── mui-theme.tsx │ ├── test-helpers │ │ └── mock-provider.tsx │ └── translations.ts ├── core │ ├── .gitkeep │ ├── __tests__ │ │ ├── date-iso.test.ts │ │ └── time-iso.test.ts │ ├── date-iso.ts │ ├── env.d.ts │ ├── index.ts │ ├── permissions.ts │ ├── schemas │ │ ├── date-iso.schema.json │ │ ├── email-address.schema.json │ │ ├── index.ts │ │ └── time-iso.schema.json │ └── time-iso.ts ├── db │ ├── __tests__ │ │ └── db.test.ts │ ├── index.ts │ └── redis.ts ├── fixtures │ ├── index.ts │ └── scenarios │ │ └── default.ts ├── graphql-api │ ├── __tests__ │ │ └── caching.test.ts │ ├── context.ts │ ├── index.ts │ ├── resolvers │ │ ├── __tests__ │ │ │ └── query.test.ts │ │ ├── index.ts │ │ ├── mutation │ │ │ └── index.ts │ │ ├── query.ts │ │ └── user.ts │ ├── schema-base.ts │ └── schema.graphql ├── helpers │ ├── assert-assignable.ts │ ├── dataloader.ts │ ├── index.ts │ └── json.ts ├── records │ ├── __tests__ │ │ └── event-log.test.ts │ ├── event-log.ts │ ├── impl │ │ ├── base.ts │ │ └── core.ts │ ├── index.ts │ └── user.ts ├── server │ ├── authentication.ts │ ├── context.ts │ ├── core │ │ └── index.ts │ ├── index.ts │ └── middleware.ts └── services │ ├── core │ └── index.ts │ ├── index.ts │ └── permissions │ ├── index.ts │ └── permissions.test.ts ├── package.json ├── package.md ├── scripts ├── codegen-type-constants.js └── json-schema-types.js ├── styleguide.config.js ├── tests └── acceptance │ ├── example.test.ts │ ├── helpers.ts │ └── nightmare.d.ts ├── tsconfig.base.json ├── tsconfig.client.json ├── tsconfig.json ├── tsconfig.server.json ├── tslint.json ├── webpack ├── client.config.js ├── loaders.js ├── server.config.js ├── storybook.config.js └── webpack-dev-server.js ├── yarn-bash-completion └── yarn.lock /.ackrc: -------------------------------------------------------------------------------- 1 | --ignore-dir=__generated__ 2 | --ignore-dir=__generated__ 3 | --ignore-dir=dist 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Validate from CLI with circleci config process ./.circleci/config.yml 2 | 3 | version: 2.1 4 | 5 | # To continuously deploy your feature branch to a review server, 6 | # fill in the 'review_branch' and 'review_server' fields below. 7 | aliases: 8 | - &review_branch feature/some-feature 9 | - &review_server my-app-dev-1 10 | - &staging_branch master 11 | - &staging_server my-app-staging 12 | 13 | jobs: 14 | build: 15 | environment: 16 | NODE_ENV: test 17 | docker: 18 | # specify the version you desire here 19 | - image: circleci/node:10-browsers 20 | - image: circleci/redis:4 21 | - image: circleci/postgres:10.5 22 | environment: 23 | POSTGRES_USER: root 24 | POSTGRES_DB: test 25 | 26 | working_directory: ~/repo 27 | 28 | steps: 29 | - checkout 30 | 31 | # Download and cache dependencies 32 | - restore_cache: 33 | keys: 34 | - node-{{ .Branch }}-{{ checksum "yarn.lock" }} 35 | # fallback to using the latest cache if no exact match is found 36 | - node- 37 | 38 | - run: 39 | name: "Install deps" 40 | command: yarn install 41 | 42 | - save_cache: 43 | paths: 44 | - node_modules 45 | key: node-{{ .Branch }}-{{ checksum "yarn.lock" }} 46 | 47 | - run: 48 | command: yarn db:migrate:latest 49 | environment: 50 | DATABASE_URL: postgres://root@127.0.0.1:5432/test 51 | - run: 52 | name: "Build" 53 | command: yarn test:acceptance:build 54 | environment: 55 | DATABASE_URL: postgres://root@127.0.0.1:5432/test 56 | REDIS_URL: redis://localhost:6379 57 | 58 | - run: 59 | name: "Unit tests" 60 | command: yarn test:unit:ci 61 | environment: 62 | DATABASE_URL: postgres://root@127.0.0.1:5432/test 63 | REDIS_URL: redis://localhost:6379 64 | JEST_JUNIT_OUTPUT: "test-results/unit/unit-test-results.xml" 65 | 66 | - run: 67 | name: "Acceptance tests" 68 | command: yarn test:acceptance 69 | environment: 70 | DATABASE_URL: postgres://root@127.0.0.1:5432/test 71 | REDIS_URL: redis://localhost:6379 72 | JEST_JUNIT_OUTPUT: "test-results/acceptance/acceptance-test-results.xml" 73 | 74 | - run: 75 | name: "Lint" 76 | command: yarn lint 77 | 78 | - store_test_results: 79 | path: test-results 80 | 81 | deploy_heroku: 82 | description: "Deploy current branch to specified heroku app" 83 | parameters: 84 | heroku_app: 85 | description: "Where to deploy" 86 | type: string 87 | extra_git_push_args: 88 | description: "More git push flags (e.g. -f)" 89 | default: "" 90 | type: string 91 | docker: 92 | - image: circleci/node:10-browsers 93 | working_directory: ~/repo 94 | steps: 95 | - checkout 96 | - run: 97 | name: Deploy branch to Heroku 98 | command: git push << parameters.extra_git_push_args >> https://heroku:$HEROKU_API_KEY@git.heroku.com/<< parameters.heroku_app >>.git $CIRCLE_BRANCH:master 99 | 100 | workflows: 101 | version: 2 102 | build-and-deploy: 103 | jobs: 104 | - build 105 | - deploy_heroku: 106 | heroku_app: *review_server 107 | extra_git_push_args: -f 108 | requires: 109 | - build 110 | filters: 111 | branches: 112 | only: *review_branch 113 | - deploy_heroku: 114 | heroku_app: *staging_server 115 | requires: 116 | - build 117 | filters: 118 | branches: 119 | only: *staging_branch -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | log 3 | tmp 4 | vendor 5 | bin/deploy 6 | docker/Dockerfile 7 | .env 8 | dist 9 | node_modules 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | DEV_DATABASE_URL=postgres://root@127.0.0.1:5432/development 3 | TEST_DATABASE_URL=postgres://root@127.0.0.1:5432/test 4 | PORT=3001 5 | DEV_SERVER_DISABLE_HOST_CHECK=0 6 | REDIS_URL=redis://localhost:6379/0 7 | USE_FAKE_DATA=true 8 | enableDeveloperLogin=true 9 | __TEST__=false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # IntelliJ 61 | .idea/ 62 | msl-website.iml 63 | 64 | # dist 65 | dist/*.js 66 | dist/**/*.* 67 | 68 | # shared 69 | shared/**/*.js 70 | 71 | # ignore generated js files under server/src 72 | server/src/**/*.js 73 | server/tests/**/*.js 74 | 75 | # ignore generated js files under server/src 76 | client/src/**/*.js 77 | client/src/**/*.jsx 78 | client/tests/**/*.js 79 | client/tests/**/*.jsx 80 | 81 | # DS_Store 82 | *.DS_Store 83 | 84 | # .env 85 | .env 86 | 87 | # Generated schema used in typescript type generation 88 | modules/graphql-api/schema.json 89 | 90 | # Add any directories, files, or patterns you don't want to be tracked by version control 91 | **/.idea 92 | .cache 93 | 94 | junit.xml 95 | 96 | # Storybook Snapshots. We're relying on manual testing to verify that these look good. 97 | /modules/client/__tests__/__snapshots__/stories.test.ts.snap 98 | **/*.gen.ts 99 | **/*.gen.tsx 100 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | 4 | "curly": true, 5 | "latedef": true, 6 | "quotmark": true, 7 | "undef": true, 8 | "unused": true, 9 | "trailing": true 10 | } 11 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 10.11.0 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v10 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | require("@storybook/addon-actions/register"); 2 | require("@storybook/addon-viewport/register"); 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import "client/bootstrap-mui"; // this must be the first import 2 | import { 3 | withTheme 4 | } from "../modules/client/storybook-decorators"; 5 | const { 6 | configure, 7 | addDecorator 8 | } = require("@storybook/react"); 9 | 10 | addDecorator(withTheme); 11 | 12 | function loadStories() { 13 | require("../modules/client/stories.ts"); 14 | // You can require as many stories as you need. 15 | } 16 | 17 | configure(loadStories, module); -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const loaders = require("../webpack/loaders"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | var HappyPack = require("happypack"); 5 | var ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); 6 | const os = require("os"); 7 | const webpack = require("webpack"); 8 | 9 | // Export a function. Accept the base config as the only param. 10 | module.exports = (storybookBaseConfig, configType) => { 11 | // configType has a value of 'DEVELOPMENT' or 'PRODUCTION' 12 | // You can change the configuration based on that. 13 | // 'PRODUCTION' is used when building the static version of storybook. 14 | 15 | // Make whatever fine-grained changes you need 16 | storybookBaseConfig.module.rules.push( 17 | { 18 | test: /\.s?css$/, 19 | loaders: [ 20 | "style-loader", 21 | "css-loader", 22 | { 23 | loader: "sass-loader", 24 | options: { includePaths: [path.resolve(__dirname, "../modules")] }, 25 | }, 26 | ], 27 | include: path.resolve(__dirname, "../"), 28 | }, 29 | loaders.mjs, 30 | loaders.clientSideTypeScript, 31 | loaders.graphql, 32 | ...loaders.allImagesAndFontsArray 33 | ); 34 | 35 | storybookBaseConfig.resolve.extensions.push(".ts", ".tsx"); 36 | storybookBaseConfig.resolve.modules.unshift( 37 | path.resolve(__dirname, "../modules") 38 | ); 39 | 40 | storybookBaseConfig.plugins.push( 41 | // new HappyPack({ 42 | // id: "ts", 43 | // threads: Math.max(1, os.cpus().length / 2 - 1), 44 | // loaders: [ 45 | // { 46 | // path: "ts-loader", 47 | // query: { happyPackMode: true, configFile: "tsconfig.client.json" } 48 | // } 49 | // ] 50 | // }), 51 | new ForkTsCheckerWebpackPlugin({ 52 | // https://github.com/Realytics/fork-ts-checker-webpack-plugin#options 53 | useTypescriptIncrementalApi: true, 54 | // checkSyntacticErrors: true 55 | }), 56 | new webpack.DefinePlugin({ 57 | // Flag to detect non-production 58 | __TEST__: "false", 59 | }) 60 | ); 61 | // Return the altered config 62 | return storybookBaseConfig; 63 | }; 64 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "mikestead.dotenv", 4 | "kumar-harsh.graphql-for-vscode", 5 | "eamodio.gitlens", 6 | "christian-kohler.npm-intellisense", 7 | "esbenp.prettier-vscode", 8 | "ms-vscode.vscode-typescript-tslint-plugin", 9 | "tobermory.es6-string-html", 10 | "tberman.json-schema-validator" 11 | ] 12 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [{ 7 | "type": "node", 8 | "request": "attach", 9 | "name": "Attach", 10 | "port": 9229, 11 | "smartStep": true 12 | }] 13 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "search.exclude": { 4 | "**/dist": true, 5 | "**/node_modules": true, 6 | "**/bower_components": true 7 | }, 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "editor.tabSize": 2 10 | } 11 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node --optimize_for_size --gc_interval=100 --max_old_space_size=${NODE_MAX_OLD_SIZE:-460} ./dist/server.js 2 | worker: node --optimize_for_size --gc_interval=100 --max_old_space_size=${NODE_MAX_OLD_SIZE:-460} ./dist/scripts/job-worker.js 3 | release: yarn heroku-release -------------------------------------------------------------------------------- /__generated__/globalTypes.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | // This file was automatically generated and should not be edited. 3 | 4 | //============================================================== 5 | // START Enums and Input Objects 6 | //============================================================== 7 | 8 | //============================================================== 9 | // END Enums and Input Objects 10 | //============================================================== 11 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /__mocks__/react.js: -------------------------------------------------------------------------------- 1 | // There is uncertainty around enzyme's support of React.memo 2 | // todo remove later when Enzyme supports this. 3 | const react = require("react"); 4 | module.exports = { ...react, memo: x => x }; -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /acceptance.jestrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "\\.(gql|graphql)$": "jest-transform-graphql", 4 | "\\.(js|jsx|ts|tsx)$": "ts-jest" 5 | }, 6 | "transformIgnorePatterns": ["/node_modules/(?!lodash-es)"], 7 | "testRegex": "(/tests/acceptance/.*\\.(test|spec))\\.(ts|tsx)$", 8 | "moduleFileExtensions": ["js", "jsx", "ts", "tsx", "json"], 9 | "moduleNameMapper": { 10 | "\\.(css|less|scss)$": "identity-obj-proxy", 11 | "\\.(gif|ttf|eot|svg)$": "/__mocks__/fileMock.js" 12 | }, 13 | "moduleDirectories": ["modules", "node_modules"], 14 | "reporters": ["default", "jest-junit"] 15 | } -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "scripts": {}, 4 | "env": {}, 5 | "formation": {}, 6 | "addons": ["heroku-postgresql", "heroku-redis"] 7 | } 8 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | generates: 2 | modules/client/graphql/types.gen.tsx: 3 | config: 4 | # Todo: Enable this setting once it doesn't cause type errors. 5 | noGraphqlTag: false 6 | schema: 7 | - "modules/graphql-api/schema.graphql" 8 | - "modules/client/graphql/schema.graphql" 9 | documents: 10 | - "modules/client/graphql/*/*.graphql" 11 | plugins: 12 | - add: 13 | - 'import * as DateIso from "core/date-iso";' 14 | - 'import * as TimeIso from "core/time-iso";' 15 | - "typescript-common": 16 | scalars: 17 | IsoDate: DateIso.Type 18 | IsoTime: TimeIso.Type 19 | - "typescript-client" 20 | - "typescript-server" 21 | - "typescript-react-apollo" 22 | - "./scripts/codegen-type-constants.js" 23 | 24 | modules/client/graphql/client-types.gen.ts: 25 | schema: 26 | - "modules/client/graphql/schema.graphql" 27 | plugins: 28 | - add: 29 | - 'import * as DateIso from "core/date-iso";' 30 | - 'import * as TimeIso from "core/time-iso";' 31 | - "typescript-common": 32 | scalars: 33 | IsoDate: DateIso.Type 34 | IsoTime: TimeIso.Type 35 | - "typescript-resolvers": 36 | contextType: client/graphql/client-context#ClientContext 37 | - "typescript-server" 38 | 39 | modules/graphql-api/server-types.gen.ts: 40 | schema: 41 | - "modules/graphql-api/schema.graphql" 42 | plugins: 43 | - add: 44 | - 'import * as DateIso from "core/date-iso";' 45 | - 'import * as TimeIso from "core/time-iso";' 46 | - "typescript-common": 47 | scalars: 48 | IsoDate: DateIso.Type 49 | IsoTime: TimeIso.Type 50 | - "typescript-resolvers": 51 | contextType: graphql-api/context#ApiContext 52 | defaultMapper: any 53 | mappers: 54 | User: "./resolvers/user#MinimalUser" 55 | - "typescript-server" 56 | -------------------------------------------------------------------------------- /config/default.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV !== "production") { 2 | require("dotenv").config({ 3 | silent: false 4 | }); 5 | } 6 | 7 | const CONCURRENCY = parseInt(process.env.WEB_CONCURRENCY, 10) || 1; 8 | const WORKER_CONCURRENCY = 9 | parseInt(process.env.WORKER_CONCURRENCY, 10) || CONCURRENCY; 10 | 11 | module.exports = { 12 | environment: process.env.NODE_ENV, 13 | databaseUrl: process.env.DATABASE_URL, 14 | minify: process.env.MINIFY === "true", 15 | 16 | production: process.env.NODE_ENV === "production", 17 | development: process.env.NODE_ENV === "development", 18 | test: process.env.NODE_ENV === "test", 19 | 20 | redis: { 21 | url: process.env.REDIS_URL, 22 | prefix: process.env.REDIS_PREFIX || "placement:", 23 | }, 24 | 25 | rollbar: { 26 | serverAccessToken: process.env.ROLLBAR_ACCESS_TOKEN || null, 27 | clientAccessToken: process.env.ROLLBAR_CLIENT_ACCESS_TOKEN || null, 28 | }, 29 | 30 | devServer: { 31 | url: "http://localhost", 32 | port: 3000, 33 | hot: true, 34 | inline: true, 35 | noInfo: true, 36 | disableHostCheck: ["1", "true"].includes( 37 | process.env.DEV_SERVER_DISABLE_HOST_CHECK 38 | ), 39 | }, 40 | 41 | server: { 42 | port: process.env.PORT || 3001, 43 | apiHost: process.env.API_HOST || "localhost:3001", 44 | basicAuthPassword: process.env.BASIC_AUTH_PASSWORD || null, 45 | enableDeveloperLogin: process.env.ENABLE_DEVELOPER_LOGIN || false, 46 | secret: process.env.SERVER_SECRET, 47 | apiKey: process.env.API_KEY, 48 | 49 | publicHost: process.env.PUBLIC_HOST || "localhost:3000", 50 | requireSsl: process.env.REQUIRE_SSL !== "false", 51 | protocol: process.env.REQUIRE_SSL ? "https" : "http", 52 | 53 | graphiql: false, 54 | workers: CONCURRENCY, 55 | cluster: CONCURRENCY > 1, 56 | }, 57 | jobs: { 58 | workers: WORKER_CONCURRENCY, 59 | }, 60 | 61 | }; -------------------------------------------------------------------------------- /config/development.js: -------------------------------------------------------------------------------- 1 | // todo: put this in one place and import it 2 | function envVarOrBust(s) { 3 | if (!s) { 4 | var e = 5 | "\n\n" + 6 | "========== CONFIGURATION ERROR! ==========\n" + 7 | " Database env var is absent. \n" + 8 | " Did you symlink .env.example to .env? \n" + 9 | "==========================================\n\n"; 10 | console.log(e); 11 | throw e; 12 | } 13 | return s; 14 | } 15 | 16 | module.exports = { 17 | databaseUrl: envVarOrBust( 18 | process.env.DEV_DATABASE_URL || process.env.DATABASE_URL 19 | ), 20 | server: { 21 | requireSsl: false, 22 | enableDeveloperLogin: true, 23 | secret: "cat", 24 | graphiql: true, 25 | apiKey: "thisIsTheTestApiKey" 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /config/production.js: -------------------------------------------------------------------------------- 1 | const host = /herokuapp.com/.test(process.env.PUBLIC_HOST) 2 | ? `${process.env.HEROKU_APP_NAME}.herokuapp.com` 3 | : process.env.PUBLIC_HOST; 4 | 5 | module.exports = { 6 | minify: true, 7 | server: { 8 | cluster: true, 9 | }, 10 | }; 11 | 12 | if (host) { 13 | module.exports.server.publicHost = host; 14 | } 15 | -------------------------------------------------------------------------------- /config/test.js: -------------------------------------------------------------------------------- 1 | function envVarOrBust(s) { 2 | if (!s) { 3 | var e = 4 | "\n\n" + 5 | "========== CONFIGURATION ERROR! ==========\n" + 6 | " Database env var is absent. \n" + 7 | " Did you symlink .env.example to .env? \n" + 8 | "==========================================\n\n"; 9 | console.log(e); 10 | throw e; 11 | } 12 | return s; 13 | } 14 | 15 | module.exports = { 16 | databaseUrl: envVarOrBust( 17 | process.env.TEST_DATABASE_URL || process.env.DATABASE_URL 18 | ), 19 | server: { 20 | publicHost: "localhost:3002", 21 | port: 3002, 22 | requireAuth: false, 23 | requireSsl: false, 24 | cluster: false, 25 | secret: "cats", 26 | apiKey: "thisIsTheTestApiKey", 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /db/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from "knex"; 2 | 3 | type OnDelete = 4 | | "cascade" 5 | | "set null" 6 | | "set default" 7 | | "restrict" 8 | | "no action"; 9 | 10 | type ForeignKeyOpts = { 11 | /** default: false */ 12 | nullable: boolean; 13 | foreignKey: string; 14 | /** 15 | * Ignored if unique is true. 16 | * default: true */ 17 | index: boolean; 18 | /** default: false */ 19 | unique: boolean; 20 | /** default cascade */ 21 | onDelete?: OnDelete; 22 | }; 23 | 24 | /** 25 | * Create a new foreign key column and constraints 26 | * @param t the Knex table builder (e.g., from knex.schema.createTable) 27 | * @param column the name of the column on your table 28 | * @param foreignTable the foreign table 29 | * @param options additional options - foreign ID and constraints 30 | */ 31 | export function addForeignKeyColumn( 32 | t: Knex.CreateTableBuilder, 33 | column: string, 34 | foreignTable: string, 35 | options: Partial = {} 36 | ) { 37 | const opts: ForeignKeyOpts = { 38 | nullable: false, 39 | foreignKey: "id", 40 | index: true, 41 | unique: false, 42 | ...options, 43 | }; 44 | const col = t.integer(column); 45 | if (!opts.nullable) { 46 | col.notNullable(); 47 | } 48 | t.foreign(column) 49 | .references(opts.foreignKey) 50 | .inTable(foreignTable) 51 | .onDelete(opts.onDelete || "cascade"); 52 | if (opts.unique) { 53 | t.unique([column]); 54 | } else if (opts.index) { 55 | t.index([column]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /db/migrate-and-seed.ts: -------------------------------------------------------------------------------- 1 | const spawn = require("cross-spawn"); 2 | import * as fixtures from "fixtures"; 3 | import * as db from "db"; 4 | 5 | export const migrateAndSeed = async () => { 6 | if (process.env.USE_FAKE_DATA) { 7 | // && process.env.NODE_ENV == "production") { 8 | const knex = db.getConnection(); 9 | try { 10 | console.log("Truncating tables"); 11 | await db.truncateAll(knex); 12 | } catch (e) { 13 | console.log("Error truncating tables", e); 14 | await db.destroyConnection(); 15 | } 16 | } 17 | 18 | spawn.sync("yarn", ["run", "db:migrate:latest:dev"], { stdio: "inherit" }); 19 | 20 | if (process.env.USE_FAKE_DATA) { 21 | // && process.env.NODE_ENV == "production") { 22 | try { 23 | await fixtures.seedScenarios(db.getConnection()); 24 | } catch (e) { 25 | console.log("Error generating data", e); 26 | } finally { 27 | await db.destroyConnection(); 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /db/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicobject/ts-react-graphql-starter-kit/802d1a25533858085a8f95d8eff5c05826f65a9a/db/migrations/.gitkeep -------------------------------------------------------------------------------- /db/migrations/20181203090529_sketch-out-db.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from "knex"; 2 | import { addForeignKeyColumn } from "../helpers"; 3 | 4 | exports.up = async function(knex: Knex): Promise { 5 | await knex.schema.createTable("User", table => { 6 | table.increments("id"); 7 | table.string("firstName"); 8 | table.string("lastName"); 9 | }); 10 | }; 11 | 12 | exports.down = async function(knex: Knex): Promise { 13 | await knex.schema.dropTable("User"); 14 | }; 15 | -------------------------------------------------------------------------------- /db/migrations/20190110233521_create-event-log.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from "knex"; 2 | 3 | exports.up = async function(knex: Knex): Promise { 4 | await knex.schema.createTable("EventLog", table => { 5 | table 6 | .timestamp("timestamp") 7 | .defaultTo(knex.raw("NOW()")) 8 | .notNullable(); 9 | table.specificType("index", "bigserial").notNullable(); 10 | table.text("type").notNullable(); 11 | table.jsonb("payload").notNullable(); 12 | table.primary(["timestamp", "index"]); 13 | }); 14 | }; 15 | 16 | exports.down = async function(knex: Knex): Promise { 17 | await knex.schema.dropTable("EventLog"); 18 | }; 19 | -------------------------------------------------------------------------------- /db/migrations/20190127135006_add-effect-to-eventlog.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from "knex"; 2 | 3 | exports.up = async function(knex: Knex): Promise { 4 | await knex.schema.alterTable("EventLog", table => { 5 | table.jsonb("effect"); 6 | table.index(["payload", "effect"], "eventLogIndex", "GIN"); 7 | }); 8 | }; 9 | 10 | exports.down = async function(knex: Knex): Promise { 11 | await knex.raw('drop index "eventLogIndex";'); 12 | await knex.schema.alterTable("EventLog", table => { 13 | table.dropColumn("effect"); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | postgres: 4 | image: postgres:10.5 5 | env_file: "./docker/docker.env" 6 | ports: 7 | - "5432:5432" 8 | command: -c log_statement='all' 9 | 10 | redis: 11 | image: redis:4 12 | ports: 13 | - "6379:6379" 14 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | RUN apk update && apk add git supervisor && rm -rf /var/run/apk/* 4 | 5 | ADD package.json /usr/src/app/ 6 | ADD yarn.lock /usr/src/app/ 7 | 8 | RUN mkdir -p /usr/src/app 9 | RUN chown -R node: /usr/src/app 10 | 11 | USER node 12 | WORKDIR /usr/src/app/ 13 | 14 | RUN yarn install 15 | 16 | ADD . /usr/src/app/ 17 | 18 | RUN NODE_ENV=production yarn build 19 | 20 | USER root 21 | -------------------------------------------------------------------------------- /docker/docker.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=root 2 | POSTGRES_PASSWORD= -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | chown -R node: /usr/src/app 3 | supervisord -c /usr/src/app/docker/supervisord.conf 4 | -------------------------------------------------------------------------------- /docker/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:express_app] 5 | user=node 6 | command=/usr/local/bin/yarn run start 7 | directory=/usr/src/app 8 | stdout_logfile=/dev/stdout 9 | stdout_logfile_maxbytes=0 10 | stderr_logfile=/dev/stderr 11 | stderr_logfile_maxbytes=0 12 | autostart=true 13 | autorestart=true 14 | environment=HOME=/home/node 15 | -------------------------------------------------------------------------------- /entry/client.tsx: -------------------------------------------------------------------------------- 1 | import { AnalyticsProvider } from "client/analytics"; 2 | import * as GALoader from "client/analytics/load-ga"; 3 | import "client/bootstrap-mui"; // this must be the first import 4 | import { buildGraphqlClient } from "client/graphql/client"; 5 | import * as ErrorNotifier from "atomic-object/error-notifier"; 6 | import { createBrowserHistory } from "history"; 7 | import * as React from "react"; 8 | import { ApolloProvider } from "react-apollo"; 9 | import { ApolloProvider as ApolloHooksProvider } from "react-apollo-hooks"; 10 | import * as ReactDom from "react-dom"; 11 | import { Router } from "react-router-dom"; 12 | import { App } from "../modules/client"; 13 | 14 | const history = createBrowserHistory(); 15 | history.listen((location, action) => { 16 | if (process.env.TRACKING_ID) { 17 | window.ga("send", "pageview", location.pathname + location.search); 18 | } 19 | }); 20 | 21 | const bootstrapClient = () => { 22 | if (process.env.TRACKING_ID) { 23 | GALoader.loadGA(); 24 | } 25 | 26 | ErrorNotifier.setup(process.env.ROLLBAR_CLIENT_ACCESS_TOKEN, { 27 | captureUncaught: false, 28 | captureUnhandledRejections: false, 29 | }); 30 | 31 | window.onerror = (message, filename?, lineno?, colno?, error?) => { 32 | console.error("OnError: ", message, error); 33 | ErrorNotifier.error(message, error); 34 | history.push("/error"); 35 | }; 36 | 37 | window.onunhandledrejection = event => { 38 | const error = event.reason; 39 | console.error("OnUnhandledRejection: ", error); 40 | ErrorNotifier.error(error); 41 | history.push("/error"); 42 | }; 43 | 44 | const graphqlClient = buildGraphqlClient(history); 45 | 46 | const rootEl = ( 47 | 48 | 49 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | ReactDom.render( 64 | (rootEl as any) as React.ReactElement, 65 | document.getElementById("app") 66 | ); 67 | }; 68 | 69 | bootstrapClient(); 70 | -------------------------------------------------------------------------------- /entry/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 14 | 15 | 16 |
Loading...
17 | 18 | 19 | -------------------------------------------------------------------------------- /entry/scripts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicobject/ts-react-graphql-starter-kit/802d1a25533858085a8f95d8eff5c05826f65a9a/entry/scripts/.gitkeep -------------------------------------------------------------------------------- /entry/scripts/job-worker.ts: -------------------------------------------------------------------------------- 1 | import * as Bull from "bull"; 2 | import * as config from "config"; 3 | 4 | const mainQueue = new Bull("main", { 5 | redis: config.get("redis.url"), 6 | prefix: config.get("redis.prefix"), 7 | }); 8 | 9 | // console.log({ 10 | // redis: config.get("redis.url"), 11 | // prefix: config.get("redis.prefix"), 12 | // // func: require(`./main-queue-processor.js`), 13 | // }); 14 | console.log(`Starting ${config.get("jobs.workers")} workers`); 15 | 16 | mainQueue.process( 17 | config.get("jobs.workers"), 18 | `${process.cwd()}/dist/scripts/main-queue-processor.js` 19 | ); 20 | 21 | void mainQueue.resume(); 22 | -------------------------------------------------------------------------------- /entry/scripts/main-queue-processor.ts: -------------------------------------------------------------------------------- 1 | // This file is JS becaule we can't export the worker function correctly 2 | // when using ES modules 3 | 4 | const { ALL_JOBS } = require("services"); 5 | const { Context } = require("graphql-api/context"); 6 | const { 7 | makeJobProcessorFunction, 8 | } = require("atomic-object/jobs/processing-function"); 9 | 10 | console.log("Loaded main queue worker"); 11 | module.exports = makeJobProcessorFunction({ 12 | buildContext: () => new Context(), 13 | jobs: ALL_JOBS.filter((job: any) => job.queue === "main"), 14 | }); 15 | -------------------------------------------------------------------------------- /entry/scripts/migrate-and-seed.ts: -------------------------------------------------------------------------------- 1 | import { migrateAndSeed } from "../../db/migrate-and-seed"; 2 | migrateAndSeed(); 3 | -------------------------------------------------------------------------------- /entry/scripts/unit-test-before-all.ts: -------------------------------------------------------------------------------- 1 | const { getConnection, destroyConnection, truncateAll } = require("db"); 2 | const cp = require("child_process"); 3 | const redis = require("db/redis"); 4 | 5 | module.exports = async () => { 6 | if (process.env.NODE_ENV !== "test") { 7 | console.log("Refusing to truncate non-test db"); 8 | return; 9 | } 10 | 11 | await truncateAll(getConnection()); 12 | // await cp.exec("./node_modules/.bin/knex db:seed:run"); 13 | await destroyConnection(); 14 | 15 | const keys = await redis.getRedisConnection().keys("test:*"); 16 | if (keys.length > 0) await redis.getRedisConnection().del(...keys); 17 | await redis.destroyConnection(); 18 | }; 19 | -------------------------------------------------------------------------------- /entry/server.ts: -------------------------------------------------------------------------------- 1 | import { startServer } from "server"; 2 | import * as throng from "throng"; 3 | import * as config from "config"; 4 | 5 | if (config.get("server.cluster")) { 6 | console.log(`Starting ${config.get("server.workers")} workers`); 7 | throng(config.get("server.workers"), startServer); 8 | } else { 9 | startServer(); 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "\\.(js|jsx|ts|tsx)$": "ts-jest", 4 | "\\.(gql|graphql)$": "jest-transform-graphql", 5 | }, 6 | transformIgnorePatterns: ["node_modules\\/(?!(lodash-es|react-apollo)\\/)"], 7 | testMatch: null, // override the testMatch inherited from ts-jest, in order to avoid conflicting with testRegex: https://kulshekhar.github.io/ts-jest/user/config/#basic-usage 8 | testRegex: "modules/.*\\.(test|spec)\\.(ts|tsx)$", 9 | moduleFileExtensions: ["js", "jsx", "ts", "tsx", "json"], 10 | moduleNameMapper: { 11 | "\\.(css|less|scss)$": "identity-obj-proxy", 12 | "\\.(gif|ttf|eot|svg)$": "/__mocks__/fileMock.js", 13 | }, 14 | moduleDirectories: ["modules", "node_modules"], 15 | // TODO: figure out a better way to reference global setup/teardown. 16 | // (reading files out of dist means that we have to explicitly build before we can test) 17 | // https://github.com/facebook/jest/issues/5164#issuecomment-376006851 18 | globalSetup: "./dist/scripts/unit-test-before-all.js", 19 | globals: { 20 | __DEV__: true, 21 | __TEST__: true, 22 | "ts-jest": { 23 | isolatedModules: true, 24 | babelConfig: false, 25 | diagnostics: { 26 | ignoreCodes: [151001], 27 | }, 28 | }, 29 | }, 30 | setupFilesAfterEnv: ["/modules/__tests__/setup-enzyme.js"], 31 | reporters: [ 32 | "default", 33 | [ 34 | "jest-junit", 35 | { 36 | classNameTemplate: "{classname}", 37 | titleTemplate: "{title}", 38 | addFileAttribute: "true", 39 | }, 40 | ], 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /knexfile.ts: -------------------------------------------------------------------------------- 1 | // Update with your config settings. 2 | const config = require("config"); 3 | // import * as config from config; 4 | 5 | module.exports = { 6 | test: { 7 | client: "pg", 8 | connection: config.get("databaseUrl"), 9 | migrations: { 10 | directory: __dirname + "/db/migrations", 11 | }, 12 | seeds: { 13 | directory: __dirname + "/db/seeds/test", 14 | }, 15 | }, 16 | development: { 17 | client: "pg", 18 | connection: config.get("databaseUrl"), 19 | migrations: { 20 | directory: __dirname + "/db/migrations", 21 | }, 22 | seeds: { 23 | directory: __dirname + "/db/seeds/development", 24 | }, 25 | }, 26 | production: { 27 | client: "pg", 28 | connection: config.get("databaseUrl"), 29 | migrations: { 30 | directory: __dirname + "/db/migrations", 31 | }, 32 | seeds: { 33 | directory: __dirname + "/db/seeds/production", 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /modules/__tests__/db-helpers.ts: -------------------------------------------------------------------------------- 1 | import { ClientState, DEFAULTS } from "client/graphql/state-link"; 2 | import * as db from "db"; 3 | import { Context } from "graphql-api/context"; 4 | 5 | import * as uuid from "uuid"; 6 | import { getRedisConnection } from "db/redis"; 7 | import * as Blueprints from "atomic-object/blueprints"; 8 | import { SavedUser } from "records/user"; 9 | import { JobRunner } from "atomic-object/jobs/mapping"; 10 | 11 | export function withTransactionalConnection( 12 | fn: (knex: db.Knex) => Promise 13 | ) { 14 | return async () => { 15 | const knex = db.getConnection(); 16 | try { 17 | await knex.transaction(async trx => { 18 | // await trx.raw("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"); 19 | db._setConnection(trx); 20 | // await truncateAll(context); 21 | await fn(trx); 22 | throw new Error("abort transaction"); 23 | }); 24 | } catch (e) { 25 | if (e.message !== "abort transaction") { 26 | throw e; 27 | } 28 | } finally { 29 | db._setConnection(knex); 30 | } 31 | }; 32 | } 33 | 34 | type ContextFn = ( 35 | context: Context, 36 | extra: { universe: Blueprints.Universe; user: SavedUser | null } 37 | ) => void | any; 38 | type WithContextArgs = { 39 | initialState?: Partial; 40 | userScenario?: (universe: Blueprints.Universe) => Promise; 41 | run: ContextFn; 42 | }; 43 | 44 | export function withContext( 45 | fnOrObj: WithContextArgs | ContextFn 46 | ): () => Promise { 47 | return withTransactionalConnection(async db => { 48 | const args: WithContextArgs = 49 | typeof fnOrObj === "function" ? { run: fnOrObj } : fnOrObj; 50 | 51 | const initialState = args.initialState; 52 | const fullInitialState = Object.assign({}, DEFAULTS, initialState); 53 | 54 | const redisPrefix = `test:${uuid()}:`; 55 | 56 | let context: Context; 57 | const jobs = new JobRunner(redisPrefix, () => context.clone()); 58 | context = new Context({ 59 | db, 60 | initialState: fullInitialState, 61 | redisPrefix, 62 | jobs, 63 | }); 64 | 65 | const universe = new Blueprints.Universe(context); 66 | 67 | let user: SavedUser | null = null; 68 | if (args.userScenario) { 69 | user = await args.userScenario(universe); 70 | context.userId = user.id; 71 | } 72 | 73 | try { 74 | await args.run(context, { universe, user }); 75 | } finally { 76 | await context.destroy(); 77 | await jobs.close(); 78 | const redis = getRedisConnection(); 79 | const keys = await redis.keys(`${redisPrefix}*`); 80 | if (keys.length > 0) { 81 | await redis.del(...keys); 82 | } 83 | } 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /modules/__tests__/setup-enzyme.js: -------------------------------------------------------------------------------- 1 | const Enzyme = require("enzyme"); 2 | const Adapter = require("enzyme-adapter-react-16"); 3 | const db = require("db"); 4 | const redis = require("db/redis"); 5 | 6 | Enzyme.configure({ adapter: new Adapter() }); 7 | 8 | afterAll(async () => { 9 | await db.destroyConnection(); 10 | await redis.destroyConnection(); 11 | }); 12 | -------------------------------------------------------------------------------- /modules/atomic-object/blueprints/blueprint.ts: -------------------------------------------------------------------------------- 1 | type DesignFn = (x: number) => PromiseLike | T[P]; 2 | type BlueprintDesign = { 3 | [P in keyof T]: T[P] | Promise | DesignFn 4 | }; 5 | type PartialBlueprint = { [P in keyof T]?: PartialBlueprint }; 6 | 7 | export class Blueprint { 8 | private seqNum: number; 9 | constructor(public readonly design: BlueprintDesign) { 10 | this.seqNum = 0; 11 | } 12 | 13 | public async build( 14 | item?: PartialBlueprint, 15 | counter?: { counter: number } 16 | ): Promise { 17 | const seqNum = counter ? counter.counter++ : this.seqNum++; 18 | const built = await this._buildOverrides(item, seqNum); 19 | const keysForGeneration = Object.getOwnPropertyNames(this.design).reduce( 20 | (memo, prop) => { 21 | if (Object.getOwnPropertyNames(built).indexOf(prop) < 0) { 22 | memo.push(prop); 23 | } 24 | return memo; 25 | }, 26 | new Array() 27 | ); 28 | 29 | for (const key of keysForGeneration) { 30 | const v = (this.design as any)[key]; 31 | let value = v; 32 | try { 33 | if ( 34 | value !== null && 35 | value !== undefined && 36 | typeof (value as any)["then"] === "function" 37 | ) { 38 | value = await v(seqNum); 39 | } else if (typeof value === "function") { 40 | value = await Promise.resolve(v(seqNum)); 41 | } else { 42 | value = v; 43 | } 44 | } catch (e) { 45 | console.log( 46 | "Error building key", 47 | key, 48 | "for blueprint item", 49 | this.design, 50 | e 51 | ); 52 | } 53 | (built as any)[key] = value; 54 | } 55 | 56 | return (built as any) as T; 57 | } 58 | 59 | async _buildOverrides( 60 | item: PartialBlueprint | undefined, 61 | seqNum: number 62 | ) { 63 | if (item === undefined || item === null) { 64 | return {}; 65 | } 66 | const base: { [key: string]: any } = {}; 67 | for (const key of Object.getOwnPropertyNames(item)) { 68 | const v = (item as any)[key]; 69 | let value = v; 70 | if (!value) { 71 | value = v; 72 | } else if (typeof (value as any)["then"] === "function") { 73 | value = await v(seqNum); 74 | } else if (typeof value === "function") { 75 | value = await Promise.resolve(v(seqNum)); 76 | } else { 77 | value = v; 78 | } 79 | base[key] = value; 80 | } 81 | 82 | return base as PartialBlueprint; 83 | } 84 | } 85 | export function design( 86 | design: BlueprintDesign 87 | ): Blueprint { 88 | return new Blueprint(design); 89 | } 90 | -------------------------------------------------------------------------------- /modules/atomic-object/blueprints/canvas.ts: -------------------------------------------------------------------------------- 1 | import { RecordBlueprint } from "atomic-object/blueprints"; 2 | import { first as firstElement, last as lastElement, sample } from "lodash-es"; 3 | 4 | export type BlueprintCanvas = { 5 | parent: BlueprintCanvas | null; 6 | currentSet: Map, any[]>; 7 | }; 8 | 9 | export namespace BlueprintCanvas { 10 | export function create(parent?: BlueprintCanvas): BlueprintCanvas { 11 | return { 12 | parent: parent || null, 13 | currentSet: new Map(), 14 | }; 15 | } 16 | 17 | export function getAll( 18 | bcs: BlueprintCanvas, 19 | k: RecordBlueprint 20 | ): Saved[] { 21 | return bcs.currentSet.get(k) || []; 22 | } 23 | 24 | export function first( 25 | bcs: BlueprintCanvas, 26 | k: RecordBlueprint 27 | ): Saved | null { 28 | return firstElement(getAll(bcs, k)) || null; 29 | } 30 | 31 | export function last( 32 | bcs: BlueprintCanvas, 33 | k: RecordBlueprint 34 | ): Saved | null { 35 | return lastElement(getAll(bcs, k)) || null; 36 | } 37 | 38 | export function put( 39 | bcs: BlueprintCanvas, 40 | k: RecordBlueprint, 41 | v: Saved 42 | ) { 43 | if (!bcs.currentSet.has(k)) { 44 | bcs.currentSet.set(k, []); 45 | } 46 | const collection = bcs.currentSet.get(k)!; 47 | collection.push(v); 48 | 49 | if (bcs.parent) { 50 | put(bcs.parent, k, v); 51 | } 52 | } 53 | 54 | export function pick( 55 | bcs: BlueprintCanvas, 56 | k: RecordBlueprint 57 | ): Saved { 58 | const rec = sample(getAll(bcs, k)); 59 | return rec!; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /modules/atomic-object/cache/stores/memory.ts: -------------------------------------------------------------------------------- 1 | import { sleep, addMsToDate } from "../utils"; 2 | 3 | export const LOCAL_CACHE: LocalCache = { 4 | map: new Map(), 5 | 6 | reset(this: LocalCache) { 7 | this.map = new Map(); 8 | }, 9 | 10 | expirationTime(this: LocalCache, key: string): Date | null { 11 | const entry = this.map.get(key); 12 | return entry ? entry.expiration : null; 13 | }, 14 | 15 | async get(this: LocalCache, key: string): Promise { 16 | await sleep(Math.random() * 15); 17 | const cacheValue = this.map.get(key); 18 | if (cacheValue && new Date() < cacheValue.expiration) { 19 | return cacheValue.value; 20 | } else { 21 | return null; 22 | } 23 | }, 24 | 25 | async set( 26 | this: LocalCache, 27 | key: string, 28 | value: string, 29 | ttlMs: number 30 | ): Promise { 31 | await sleep(Math.random() * 15); 32 | 33 | this.map.set(key, { 34 | value, 35 | expiration: addMsToDate(new Date(), ttlMs), 36 | }); 37 | }, 38 | 39 | async clearValues(pattern: string): Promise { 40 | const allKeys = Array.from(this.map.keys()); 41 | const noWildcardPattern = pattern.replace("*", ""); 42 | const matchingKeys = allKeys.filter(k => k.startsWith(noWildcardPattern)); 43 | matchingKeys.forEach(k => this.map.delete(k)); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /modules/atomic-object/cache/stores/null.ts: -------------------------------------------------------------------------------- 1 | export const NO_CACHE: CacheStore = { 2 | async get(this: LocalCache, key: string): Promise { 3 | return null; 4 | }, 5 | 6 | async set( 7 | this: LocalCache, 8 | key: string, 9 | value: string, 10 | ttlMs: number 11 | ): Promise { 12 | return; 13 | }, 14 | 15 | async clearValues(pattern: string): Promise { 16 | return; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /modules/atomic-object/cache/stores/redis.ts: -------------------------------------------------------------------------------- 1 | import * as zlib from "zlib"; 2 | import { getRedisConnection } from "db/redis"; 3 | 4 | export class RedisCacheStore implements CacheStore { 5 | constructor(public readonly keyPrefix: string) {} 6 | 7 | async get(key: string): Promise { 8 | key = `${this.keyPrefix}${key}`; 9 | const redis = getRedisConnection(); 10 | const cacheString: Buffer | null = await redis.getBuffer(key); 11 | if (!cacheString) { 12 | return cacheString; 13 | } 14 | return new Promise((resolve, reject) => { 15 | zlib.gunzip(cacheString, (err, res) => { 16 | if (err) { 17 | resolve(null); 18 | } else { 19 | resolve(res.toString("utf8")); 20 | } 21 | }); 22 | }); 23 | } 24 | 25 | async set(key: string, value: string, ttlMs: number): Promise { 26 | key = `${this.keyPrefix}${key}`; 27 | const redis = getRedisConnection(); 28 | return new Promise((resolve, reject) => { 29 | zlib.gzip(value, async (err, compressed) => { 30 | if (err) { 31 | reject(err); 32 | return; 33 | } 34 | 35 | try { 36 | await redis.set(key, compressed, "px", ttlMs); 37 | resolve(); 38 | } catch (e) { 39 | reject(e); 40 | return; 41 | } 42 | }); 43 | }); 44 | } 45 | 46 | async clearValues(pattern: string): Promise { 47 | pattern = `${this.keyPrefix}${pattern}`; 48 | const redis = getRedisConnection(); 49 | const keys = await redis.keys(pattern); 50 | if (keys.length > 0) { 51 | await redis.del(...keys); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /modules/atomic-object/cache/types.ts: -------------------------------------------------------------------------------- 1 | interface CacheStore { 2 | get(key: string): Promise; 3 | set(key: string, value: string, ttlMs: number): Promise; 4 | clearValues(pattern: string): Promise; 5 | } 6 | 7 | interface LocalCache extends CacheStore { 8 | map: Map< 9 | string, 10 | { 11 | expiration: Date; 12 | value: string; 13 | } 14 | >; 15 | 16 | reset(): void; 17 | expirationTime(key: string): Date | null; 18 | } 19 | -------------------------------------------------------------------------------- /modules/atomic-object/cache/utils.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, ms); 4 | }); 5 | } 6 | 7 | export function addMsToDate(date: Date, ms: number) { 8 | return new Date(date.valueOf() + ms); 9 | } 10 | export function msBetween(date1: Date, date2: Date): number { 11 | return Math.round(date2.valueOf() - date1.valueOf()); 12 | } 13 | -------------------------------------------------------------------------------- /modules/atomic-object/cqrs/__test__/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { withContext } from "__tests__/db-helpers"; 2 | import { Actions, declareAction } from "../actions"; 3 | import { Dispatcher } from "../dispatch"; 4 | 5 | /***********************************************************************************/ 6 | 7 | const action1 = declareAction({ 8 | type: "TestAction", 9 | schema: { type: "object" }, 10 | isolationLevel: "READ COMMITTED", 11 | async handler(payload: { name: string }, { repos }) { 12 | const { name } = payload; 13 | return await repos.users.insert({ 14 | firstName: name, 15 | lastName: "foo", 16 | }); 17 | }, 18 | }); 19 | 20 | const brokenAction = declareAction({ 21 | type: "BrokenAction", 22 | schema: { type: "object" }, 23 | isolationLevel: "READ COMMITTED", 24 | async handler(payload: { message: string }, { repos }) { 25 | throw new Error(payload.message); 26 | }, 27 | }); 28 | 29 | const nonValidatingAction = declareAction({ 30 | type: "NonValidatingAction", 31 | schema: { type: "number" }, 32 | isolationLevel: "READ COMMITTED", 33 | async handler(payload: { message: string }, { repos }) {}, 34 | }); 35 | 36 | const capturingAction = declareAction({ 37 | type: "CapturingAction", 38 | schema: { 39 | type: "object", 40 | definitions: { 41 | effect: { 42 | type: "object", 43 | properties: { 44 | foo: { type: "number" }, 45 | }, 46 | required: ["foo"], 47 | }, 48 | }, 49 | }, 50 | isolationLevel: "READ COMMITTED", 51 | async handleAndCaptureEffect(payload: { foo: number }) { 52 | return [{}, payload]; 53 | }, 54 | }); 55 | 56 | const actions = new Actions() 57 | .with(action1) 58 | .with(brokenAction) 59 | .with(nonValidatingAction) 60 | .with(capturingAction); 61 | 62 | describe("Dispatching actions", () => { 63 | it( 64 | "works", 65 | withContext(async ctx => { 66 | const dispatch = new Dispatcher(ctx, actions).valueOrThrow; 67 | 68 | const result = await dispatch({ 69 | type: "TestAction", 70 | payload: { name: "Fort ctx" }, 71 | }); 72 | 73 | expect(result.id).toBeGreaterThan(0); 74 | expect(result.firstName).toEqual("Fort ctx"); 75 | 76 | const [event] = await ctx.repos.eventLog.table(); 77 | expect(event.type).toEqual("TestAction"); 78 | expect(event.payload).toEqual({ name: "Fort ctx" }); 79 | }) 80 | ); 81 | 82 | it( 83 | "Does not log on failure", 84 | withContext(async ctx => { 85 | const dispatch = new Dispatcher(ctx, actions).valueOrThrow; 86 | await expect( 87 | dispatch({ 88 | type: "BrokenAction", 89 | payload: { message: "Foo" }, 90 | }) 91 | ).rejects.toThrow("Foo"); 92 | expect(await ctx.repos.eventLog.count()).toEqual(0); 93 | }) 94 | ); 95 | 96 | it( 97 | "Does not log on failure", 98 | withContext(async ctx => { 99 | const dispatch = new Dispatcher(ctx, actions).valueOrThrow; 100 | await expect( 101 | dispatch({ 102 | type: "NonValidatingAction", 103 | payload: { message: "Foo" }, 104 | }) 105 | ).rejects.toThrow(Error); 106 | 107 | expect(await ctx.repos.eventLog.count()).toEqual(0); 108 | }) 109 | ); 110 | it( 111 | "Validates result for capturing actions", 112 | withContext(async ctx => { 113 | const dispatch = new Dispatcher(ctx, actions).valueOrThrow; 114 | await expect( 115 | dispatch({ 116 | type: "CapturingAction", 117 | payload: { NOFOO: 1 } as any, 118 | }) 119 | ).rejects.toThrow(Error); 120 | 121 | await expect( 122 | dispatch({ 123 | type: "CapturingAction", 124 | payload: { foo: 1 }, 125 | }) 126 | ).resolves.toEqual({}); 127 | }) 128 | ); 129 | }); 130 | -------------------------------------------------------------------------------- /modules/atomic-object/cqrs/__test__/background-actions.test.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "graphql-api/context"; 2 | import { withContext } from "__tests__/db-helpers"; 3 | import { Actions, declareAction } from "../actions"; 4 | import { Dispatcher, DispatcherFn } from "../dispatch"; 5 | import * as Jobs from "atomic-object/jobs"; 6 | 7 | /***********************************************************************************/ 8 | type Payload = { name: string }; 9 | 10 | const theJob = Jobs.declare({ 11 | identifier: "TestAction", 12 | process: Jobs.processWithContext(async ({ ctx, job }) => { 13 | const data = job.data; 14 | await ctx.repos.eventLog.insert({ 15 | type: "foo", 16 | payload: {}, 17 | effect: {}, 18 | }); 19 | }), 20 | }); 21 | 22 | const action1 = declareAction({ 23 | type: "TestAction", 24 | schema: { type: "object" }, 25 | backgroundJob: theJob, 26 | }); 27 | 28 | const actions = new Actions().with(action1); 29 | 30 | describe("Dispatching actions", () => { 31 | it( 32 | "works", 33 | withContext(async ctx => { 34 | ctx.jobs.register(...actions.backgroundJobs); 35 | const dispatch: DispatcherFn = new Dispatcher( 36 | ctx, 37 | actions 38 | ).orThrow; 39 | 40 | const result = await dispatch({ 41 | type: "TestAction", 42 | payload: { name: "Fort ctx" }, 43 | }); 44 | expect(await ctx.repos.eventLog.count()).toEqual(1); 45 | 46 | expect(result.value).toBeFalsy(); 47 | 48 | await ctx.jobs.runAll(); 49 | 50 | const [savedEvent] = await ctx.repos.eventLog.allWithType(action1); 51 | expect(result.event).toEqual(savedEvent); 52 | 53 | expect(savedEvent.effect!.backgroundJobId).toMatch(/\w/); 54 | expect(savedEvent.effect!.backgroundJobId).toEqual( 55 | result.backgroundJobId 56 | ); 57 | 58 | expect(await ctx.repos.eventLog.count()).toEqual(2); 59 | // console.log( 60 | // await ctx.jobs.getJob(action1.backgroundJob!, savedEvent.effect! 61 | // .backgroundJobId as any) 62 | // ); 63 | }) 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /modules/atomic-object/cqrs/dispatch.ts: -------------------------------------------------------------------------------- 1 | import { Actions, Action, UnwrapActions } from "./actions"; 2 | import { Context } from "graphql-api/context"; 3 | import * as config from "config"; 4 | 5 | import * as Result from "atomic-object/result"; 6 | 7 | import * as Ajv from "ajv"; 8 | import { UnsavedEventLog, SavedEventLog } from "records/event-log"; 9 | import { JobId } from "atomic-object/jobs"; 10 | import { buildAjv, SchemaError } from "core/schemas"; 11 | 12 | type DispatchResult = { 13 | value: T; 14 | backgroundJobId: null | JobId; 15 | event: SavedEventLog; 16 | }; 17 | /** Wrapper for AJV errors that subclass Error to work with result type */ 18 | 19 | export class Dispatcher> { 20 | schemaCache: Map, Ajv.ValidateFunction>; 21 | ajv: Ajv.Ajv; 22 | constructor( 23 | private rootContext: Context, 24 | private actions: Actions 25 | ) { 26 | this.ajv = buildAjv({ jsonPointers: true, allErrors: false }); 27 | this.schemaCache = new Map, Ajv.ValidateFunction>(); 28 | for (const action of this.actions.actions) { 29 | this.ajv.addSchema(action.schema, action.type); 30 | } 31 | } 32 | 33 | private _validatorFor(action: TActions): Ajv.ValidateFunction { 34 | const validator = this.ajv.getSchema(action.type); 35 | return validator; 36 | } 37 | 38 | private _captureValidatorFor(action: TActions): Ajv.ValidateFunction { 39 | let validator = this.schemaCache.get(action); 40 | if (validator) { 41 | return validator; 42 | } 43 | 44 | validator = this.ajv.compile({ 45 | $ref: `${action.type}#/definitions/effect`, 46 | }); 47 | this.schemaCache.set(action, validator); 48 | return validator; 49 | } 50 | 51 | orThrow = async (arg: { 52 | type: K; 53 | payload: TActions extends Action ? P : never; 54 | }): Promise< 55 | TActions extends Action ? DispatchResult : never 56 | > => { 57 | let payload = arg.payload; 58 | 59 | if (config.get("test")) { 60 | payload = JSON.parse(JSON.stringify(payload)); 61 | } 62 | 63 | const action = this.actions.forType(arg.type); 64 | const validate = this._validatorFor(action); 65 | 66 | if (!validate(payload)) { 67 | throw new SchemaError(validate.errors![0]); 68 | } 69 | 70 | if (!action) { 71 | throw new Error(`Bad action type ${arg.type}`); 72 | } 73 | 74 | return this.rootContext.repos.transaction(async repos => { 75 | let record: UnsavedEventLog; 76 | let result: any; 77 | if (action.handler) { 78 | let captured: any = null; 79 | const capture = async (a: any) => { 80 | const validate = this._captureValidatorFor(action); 81 | if (validate(a)) { 82 | captured = a; 83 | } else { 84 | throw new SchemaError(validate.errors![0]); 85 | } 86 | }; 87 | result = await action.handler(arg.payload, { repos, capture }); 88 | record = { 89 | ...arg, 90 | payload: { 91 | ...arg.payload, 92 | }, 93 | effect: captured, 94 | }; 95 | } else { 96 | record = { ...arg, effect: null }; 97 | result = undefined; 98 | } 99 | 100 | let jobId: JobId | null = null; 101 | if (action.backgroundJob) { 102 | jobId = await this.rootContext.jobs.enqueue( 103 | action.backgroundJob, 104 | arg.payload 105 | ); 106 | record.effect = record.effect || {}; 107 | record.effect.backgroundJobId = jobId; 108 | } 109 | const event = await repos.eventLog.insert(record); 110 | const ret: DispatchResult = { 111 | backgroundJobId: jobId, 112 | event, 113 | value: result, 114 | }; 115 | return ret; 116 | }); 117 | }; 118 | 119 | valueOrThrow = async (arg: { 120 | type: K; 121 | payload: TActions extends Action ? P : never; 122 | }): Promise ? R : never> => { 123 | const res = await this.orThrow(arg as any); 124 | return res.value; 125 | }; 126 | 127 | toResult = async (arg: { 128 | type: K; 129 | payload: TActions extends Action ? P : never; 130 | }): Promise< 131 | TActions extends Action 132 | ? Result.Type> 133 | : never 134 | > => { 135 | try { 136 | return (await this.orThrow(arg as any)) as any; 137 | } catch (e) { 138 | return e; 139 | } 140 | }; 141 | } 142 | 143 | export type DispatcherFn> = Dispatcher< 144 | UnwrapActions 145 | >["orThrow"]; 146 | -------------------------------------------------------------------------------- /modules/atomic-object/error-notifier.ts: -------------------------------------------------------------------------------- 1 | var Rollbar = require("rollbar"); 2 | 3 | /** The rollbar access token. In the client, this will be ROLLBAR_CLIENT_ACCESS_TOKEN. See client.config.js */ 4 | let ACCESS_TOKEN: string | null = null; 5 | export let ROLLBAR_INSTANCE: any = null; 6 | 7 | type SetupOptions = { 8 | captureUncaught: boolean; 9 | captureUnhandledRejections: boolean; 10 | }; 11 | export function setup( 12 | accessToken: string | undefined, 13 | opts?: Partial 14 | ) { 15 | const settings: SetupOptions = { 16 | captureUncaught: true, 17 | captureUnhandledRejections: true, 18 | ...opts, 19 | }; 20 | ACCESS_TOKEN = accessToken || null; 21 | if (ACCESS_TOKEN) { 22 | ROLLBAR_INSTANCE = new Rollbar({ 23 | accessToken, 24 | ...settings, 25 | }); 26 | } 27 | } 28 | 29 | export enum ErrorLevel { 30 | critical = "critical", 31 | error = "error", 32 | warning = "warning", 33 | info = "info", 34 | debug = "debug", 35 | } 36 | 37 | export function makeWrapper(level: ErrorLevel): (...args: any[]) => void { 38 | if (__TEST__) { 39 | return () => {}; 40 | } else { 41 | return (...args: any[]) => { 42 | if (ROLLBAR_INSTANCE) { 43 | ROLLBAR_INSTANCE[level].apply(ROLLBAR_INSTANCE, args); 44 | } 45 | }; 46 | } 47 | } 48 | 49 | export const critical = makeWrapper(ErrorLevel.critical); 50 | export const error = makeWrapper(ErrorLevel.error); 51 | export const warning = makeWrapper(ErrorLevel.warning); 52 | export const info = makeWrapper(ErrorLevel.info); 53 | export const debug = makeWrapper(ErrorLevel.debug); 54 | -------------------------------------------------------------------------------- /modules/atomic-object/forms/core.ts: -------------------------------------------------------------------------------- 1 | import { Isomorphism } from "@atomic-object/lenses"; 2 | import { FormikContext, FormikHelpers } from "formik"; 3 | import * as yup from "yup"; 4 | import { Translator } from "client/translations"; 5 | import { DocumentNode } from "graphql"; 6 | 7 | export const IDENTITY_ISO: Isomorphism = { 8 | from: (a: any) => a, 9 | to: (b: any) => b, 10 | }; 11 | 12 | export type SubmitFn = ( 13 | values: Values, 14 | formikHelpers: FormikHelpers 15 | ) => void; 16 | export type SchemaBuilder = (arg: { 17 | translator: Translator; 18 | }) => yup.ObjectSchema; 19 | 20 | export type RedirectState = { key: "REDIRECT"; to: string }; 21 | -------------------------------------------------------------------------------- /modules/atomic-object/forms/index.tsx: -------------------------------------------------------------------------------- 1 | export { SubmitFn, SchemaBuilder } from "./core"; 2 | // export { IsoForm, IsoFormProps } from "./iso-form"; 3 | 4 | // export { 5 | // MutationWrapper, 6 | // MutationWrapperProps as MutationFormWrapperProps, 7 | // } from "./mutation-form-wrapper"; 8 | 9 | export { TimeSelect } from "../../client/components/form/time-select"; 10 | export { 11 | Select, 12 | SelectOption, 13 | SelectProps, 14 | } from "../../client/components/form/select"; 15 | export { 16 | TextField, 17 | TextFieldProps, 18 | } from "../../client/components/form/text-field"; 19 | export { CheckBox, CheckBoxProps } from "../../client/components/form/checkbox"; 20 | export { ShowFormData } from "../../client/components/form/show-form-data"; 21 | -------------------------------------------------------------------------------- /modules/atomic-object/forms/mutation-form.tsx: -------------------------------------------------------------------------------- 1 | // import { Omit } from "lodash"; 2 | 3 | // import { IsoFormProps, IsoForm } from "."; 4 | 5 | // import { MutationWrapperProps, MutationWrapper } from "./mutation-form-wrapper"; 6 | 7 | // import * as React from "react"; 8 | 9 | // export type MutationFormProps = Omit< 10 | // IsoFormProps, 11 | // "onSubmit" 12 | // > & 13 | // Omit, "children">; 14 | 15 | // // Don't require vars for the common case where vars is just {data: TData} 16 | // export function MutationForm( 17 | // props: MutationFormProps 18 | // ): JSX.Element; 19 | 20 | // // Require a vars input if vars can't be derived from data. 21 | // export function MutationForm( 22 | // props: MutationFormProps & { 23 | // /** Compute mutation variables from the form state */ 24 | // vars: (state: TData) => TVars; 25 | // } 26 | // ): JSX.Element; 27 | 28 | // /** A Form for submitting to a mutation function */ 29 | // export function MutationForm< 30 | // TData, 31 | // TVars = TData, 32 | // TFormData = TData, 33 | // TResult = any 34 | // >( 35 | // props: MutationFormProps & { 36 | // vars?: (state: TData) => TVars; 37 | // } 38 | // ) { 39 | // return ( 40 | // 41 | // {({ onSubmit, state }) => { 42 | // switch (state.key) { 43 | // case "INITIAL": 44 | // return ( 45 | // 46 | // {props.children} 47 | // 48 | // ); 49 | // case "ERROR": 50 | // return ( 51 | // 52 | // {state.errors.map(e => e.message).join(", ")} 53 | // {props.children} 54 | // 55 | // ); 56 | // } 57 | // }} 58 | // 59 | // ); 60 | // } 61 | -------------------------------------------------------------------------------- /modules/atomic-object/forms/use-mutation-form.tsx: -------------------------------------------------------------------------------- 1 | import { Isomorphism } from "@atomic-object/lenses"; 2 | import { PureQueryOptions } from "apollo-client"; 3 | import { GraphQLError } from "graphql"; 4 | import { useCallback, useState } from "react"; 5 | import { RefetchQueriesProviderFn } from "react-apollo"; 6 | import { useMutation } from "react-apollo-hooks"; 7 | import { SubmitFn } from "./core"; 8 | import { GraphqlBundle } from "client/graphql/core"; 9 | 10 | export type MutationFormState = 11 | | { key: "INITIAL" } 12 | | { key: "SUBMITTING" } 13 | | { key: "SUBMITTED" } 14 | | { key: "ERROR"; errors: ReadonlyArray }; 15 | 16 | export type MutationWrapperProps = { 17 | /** The mutation function to submit to */ 18 | mutation: GraphqlBundle; 19 | /** Called with the mutation result after submitting */ 20 | onMutate?: (result: TResult) => void; 21 | 22 | /** UNUSED parameter. Passing this in is just to inform TypeScript of intended types. */ 23 | valuesFormDataIso?: Isomorphism; 24 | 25 | /** Error handler */ 26 | onError?: (result: ReadonlyArray) => void; 27 | formDataToVars: (state: TState) => TVars; 28 | 29 | // Apollo options 30 | refetchQueries?: Array | RefetchQueriesProviderFn; 31 | awaitRefetchQueries?: boolean; 32 | }; 33 | 34 | /** Produces input props for an IsoForm that invoke a mutation on submit. */ 35 | export function useMutationForm( 36 | props: MutationWrapperProps & {} 37 | ) { 38 | const [state, setState] = useState({ 39 | key: "INITIAL", 40 | }); 41 | 42 | const { formDataToVars, refetchQueries, awaitRefetchQueries } = props; 43 | 44 | const mutate = useMutation(props.mutation.Document); 45 | 46 | const onSubmit = useCallback, any>( 47 | async (data, formik) => { 48 | try { 49 | setState({ key: "SUBMITTING" }); 50 | 51 | const result = await mutate({ 52 | variables: formDataToVars(data), 53 | refetchQueries, 54 | awaitRefetchQueries, 55 | }); 56 | 57 | setState({ key: "SUBMITTED" }); 58 | 59 | if (result.errors && result.errors.length > 0) { 60 | setState({ 61 | key: "ERROR", 62 | errors: result.errors, 63 | }); 64 | if (props.onError) { 65 | props.onError(result.errors); 66 | } 67 | return; 68 | } 69 | 70 | if (!result.data) { 71 | throw new Error("Didn't get data?"); 72 | } 73 | 74 | if (props.onMutate) await props.onMutate(result.data); 75 | } catch (e) { 76 | if (props.onError) { 77 | props.onError(e.graphQLErrors || [e]); 78 | } else { 79 | setState({ 80 | key: "ERROR", 81 | errors: e.graphQLErrors || [e], 82 | }); 83 | formik.setSubmitting(false); 84 | } 85 | 86 | return; 87 | } 88 | }, 89 | [ 90 | formDataToVars, 91 | props.onMutate, 92 | props.mutation, 93 | refetchQueries, 94 | awaitRefetchQueries, 95 | ] 96 | ); 97 | 98 | return { onSubmit, state }; 99 | } 100 | -------------------------------------------------------------------------------- /modules/atomic-object/forms/use-query-for-initial-form.ts: -------------------------------------------------------------------------------- 1 | import { useApolloClient } from "react-apollo-hooks"; 2 | 3 | import * as React from "react"; 4 | import { FetchPolicy, ErrorPolicy } from "apollo-client"; 5 | import { GraphqlBundle } from "client/graphql/core"; 6 | 7 | export interface QueryBaseOptions { 8 | variables?: TVariables; 9 | fetchPolicy?: FetchPolicy; 10 | errorPolicy?: ErrorPolicy; 11 | fetchResults?: boolean; 12 | } 13 | 14 | interface QueryInitialFormProps 15 | extends QueryBaseOptions { 16 | query: GraphqlBundle; 17 | queryToFormData: (queryResult: TResult) => TData; 18 | } 19 | export function useQueryForInitialForm< 20 | TData, 21 | TVars, 22 | TResult = any, 23 | TFormData = any 24 | >( 25 | props: QueryInitialFormProps 26 | ): 27 | | { loading: true; initialValues: null } 28 | | { loading: false; initialValues: TData } { 29 | const { query, variables, queryToFormData: toFormData } = props; 30 | const apollo = useApolloClient()!; 31 | const [initial, setInitial] = React.useState(null); 32 | const fetchInitial = React.useCallback(async () => { 33 | const result = await apollo.query({ 34 | fetchPolicy: "network-only", 35 | ...props, 36 | query: query.Document, 37 | variables, 38 | }); 39 | 40 | if (!result.data) { 41 | throw new Error("Couldn't fetch initial form data from query!"); 42 | } 43 | const newInitial = toFormData(result.data); 44 | setInitial(newInitial); 45 | }, []); 46 | 47 | React.useEffect(() => { 48 | void fetchInitial(); 49 | }, []); 50 | 51 | if (initial === null) { 52 | return { loading: true, initialValues: null }; 53 | } else { 54 | return { 55 | loading: false, 56 | initialValues: initial, 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /modules/atomic-object/forms/use-translated-validation-schema.ts: -------------------------------------------------------------------------------- 1 | import { useTranslator } from "client/translations"; 2 | 3 | import * as React from "react"; 4 | import { SchemaBuilder } from "./core"; 5 | 6 | export function useTranslatedValidationSchema( 7 | buildSchema: SchemaBuilder 8 | ) { 9 | const translator = useTranslator(); 10 | 11 | return React.useMemo(() => buildSchema({ translator }), [ 12 | translator, 13 | buildSchema, 14 | ]); 15 | } 16 | -------------------------------------------------------------------------------- /modules/atomic-object/i18n/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { I18nProfile, getTranslation } from ".."; 2 | 3 | describe("isValidI18nMap", () => { 4 | it("detects a missing entry on a flat profile", () => { 5 | const flatProfile = I18nProfile.with("foo").with("bar"); 6 | expect( 7 | flatProfile.isValidI18nMap({ 8 | foo: "asdf", 9 | }) 10 | ).toBe(false); 11 | }); 12 | 13 | it("detects a missing entry on a nested profile", () => { 14 | const nestedProfile = I18nProfile.with("foo.bar").with("foo.baz"); 15 | expect( 16 | nestedProfile.isValidI18nMap({ 17 | foo: { 18 | baz: "value", 19 | }, 20 | }) 21 | ).toBe(false); 22 | }); 23 | 24 | it("detects undeclared variables", () => { 25 | const nestedProfile = I18nProfile.with("foo.baz"); 26 | expect( 27 | nestedProfile.isValidI18nMap({ 28 | foo: { 29 | baz: "value {var}", 30 | }, 31 | }) 32 | ).toBe(false); 33 | }); 34 | 35 | it("detects undeclared variables when some are declared", () => { 36 | const nestedProfile = I18nProfile.with("foo.baz", "x"); 37 | expect( 38 | nestedProfile.isValidI18nMap({ 39 | foo: { 40 | baz: "value {var} {x}", 41 | }, 42 | }) 43 | ).toBe(false); 44 | }); 45 | 46 | it("does not flag valid use with one variable", () => { 47 | const nestedProfile = I18nProfile.with("foo.baz", "x"); 48 | expect( 49 | nestedProfile.isValidI18nMap({ 50 | foo: { 51 | baz: "value {x}", 52 | }, 53 | }) 54 | ).toBe(true); 55 | }); 56 | 57 | it("does not flag valid use with multiple variables", () => { 58 | const nestedProfile = I18nProfile.with("foo.baz", ["x", "y"]); 59 | expect( 60 | nestedProfile.isValidI18nMap({ 61 | foo: { 62 | baz: "value {x} {y}", 63 | }, 64 | }) 65 | ).toBe(true); 66 | }); 67 | }); 68 | 69 | describe("getTranslation", () => { 70 | it("gets the translation for a single tag", () => { 71 | const translations: any = { 72 | welcome: "words", 73 | }; 74 | const translation = getTranslation(translations, "welcome", {}); 75 | expect(translation).toBe("words"); 76 | }); 77 | 78 | it("gets the translation for a tag and a var", () => { 79 | const translations: any = { 80 | welcome: "hello {name}!", 81 | }; 82 | const translation = getTranslation(translations, "welcome", { 83 | name: "Drew", 84 | }); 85 | expect(translation).toBe("hello Drew!"); 86 | }); 87 | 88 | it("gets the translation for a tag and several vars", () => { 89 | const translations: any = { 90 | welcome: "hello {name} and {other}!", 91 | }; 92 | const translation = getTranslation(translations, "welcome", { 93 | name: "Drew", 94 | other: "Rachael", 95 | }); 96 | expect(translation).toBe("hello Drew and Rachael!"); 97 | }); 98 | 99 | it("throws an exception if there is a missing var", () => { 100 | const translations: any = { 101 | welcome: "hello {name}!", 102 | }; 103 | expect(() => 104 | getTranslation(translations, "welcome", { wizard: "Drew" }) 105 | ).toThrow(); 106 | }); 107 | 108 | it("throws an exception if there is a missing tag", () => { 109 | const translations: any = { 110 | welcome: "hello {name}!", 111 | }; 112 | expect(() => 113 | getTranslation(translations, "nonsense", { name: "Drew" }) 114 | ).toThrow(); 115 | }); 116 | 117 | it("supports nested definitions", () => { 118 | const translations: any = { 119 | all: { 120 | messages: { 121 | welcome: "hello {name}!", 122 | }, 123 | something: "else", 124 | }, 125 | }; 126 | const translation = getTranslation(translations, "all.messages.welcome", { 127 | name: "Drew", 128 | }); 129 | expect(translation).toBe("hello Drew!"); 130 | }); 131 | }); 132 | 133 | // describe('parse', () => { 134 | // it('gets the translation map out of a yaml file', () => { 135 | // const translationMap = parse(); 136 | // // expect 137 | // }) 138 | // it('throws an error if the translations in the file are not in the translation profile', () => { 139 | // ?? 140 | // expect(parse(??)).toThrow(); 141 | // }) 142 | // }); 143 | -------------------------------------------------------------------------------- /modules/atomic-object/i18n/component.tsx: -------------------------------------------------------------------------------- 1 | import { getTranslation, I18nMap } from "atomic-object/i18n"; 2 | import * as React from "react"; 3 | 4 | export function buildComponents(defaultValue: I18nMap) { 5 | const TranslationContext = React.createContext>(defaultValue); 6 | 7 | const TranslationProvider = TranslationContext.Provider; 8 | 9 | function useTranslator() { 10 | const context = React.useContext(TranslationContext); 11 | 12 | return React.useCallback( 13 | (props: Props) => 14 | getTranslation(context, (props as any).tag, (props as any).vars), 15 | [context] 16 | ); 17 | } 18 | 19 | const Translation: React.SFC = (props: any) => { 20 | const translator = useTranslator(); 21 | return <>{translator(props)}; 22 | }; 23 | 24 | return { 25 | Translation, 26 | TranslationProvider, 27 | useTranslator, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /modules/atomic-object/jobs/__tests__/job-runner.test.ts: -------------------------------------------------------------------------------- 1 | import { withContext } from "__tests__/db-helpers"; 2 | import * as redis from "db/redis"; 3 | 4 | import * as Job from ".."; 5 | interface Data { 6 | info: string; 7 | } 8 | 9 | export const UpdateIndex = Job.declare({ 10 | identifier: "subrank/updateIndex", 11 | process: Job.processWithContext(async context => { 12 | await redis.getRedisConnection().set("foo", "bar"); 13 | }), 14 | }); 15 | 16 | describe("Running jobs in a test", () => { 17 | it( 18 | "Can run the job", 19 | withContext(async ctx => { 20 | ctx.jobs.register(UpdateIndex); 21 | const id = await ctx.jobs.enqueue(UpdateIndex, { 22 | info: "Hello!", 23 | }); 24 | await ctx.jobs.runAll(); 25 | 26 | const job = await ctx.jobs.getJob(UpdateIndex, id); 27 | expect(await job.getState()).toEqual("completed"); 28 | const foo = await redis.getRedisConnection().get("foo"); 29 | expect(foo).toEqual("bar"); 30 | await redis.getRedisConnection().del("foo"); 31 | }) 32 | ); 33 | }); 34 | -------------------------------------------------------------------------------- /modules/atomic-object/jobs/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "graphql-api/context"; 2 | import { Flavor } from "helpers"; 3 | import * as bull from "bull"; 4 | 5 | export type Queues = "main"; // | 'more' | 'queues' 6 | export type JobProcessFunction = (args: { 7 | ctx: Context; 8 | payload: TData; 9 | job: bull.Job>; 10 | }) => Promise; 11 | 12 | export type StandardJobData = { type: string; payload: TData }; 13 | export type StandardJob = bull.Job>; 14 | 15 | export interface JobSpec { 16 | readonly identifier: TIdentifier; 17 | process: JobProcessFunction; 18 | queue: Queues; 19 | } 20 | 21 | type JobArgs = { 22 | identifier: TIdentifier; 23 | process: JobProcessFunction; 24 | queue?: Queues; 25 | }; 26 | export function declare( 27 | args: JobArgs 28 | ): JobSpec { 29 | return { 30 | identifier: args.identifier, 31 | process: args.process, 32 | queue: args.queue || "main", 33 | }; 34 | } 35 | 36 | export function processWithContext( 37 | process: JobProcessFunction 38 | ): JobProcessFunction { 39 | return process; 40 | } 41 | 42 | export type JobId = Flavor; 43 | -------------------------------------------------------------------------------- /modules/atomic-object/jobs/processing-function.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "graphql-api/context"; 2 | import { Job } from "bull"; 3 | import { JobSpec } from "atomic-object/jobs"; 4 | 5 | export function makeJobProcessorFunction(args: { 6 | buildContext: () => Context; 7 | jobs: JobSpec[]; 8 | }): (job: Job) => Promise { 9 | const { buildContext, jobs } = args; 10 | const jobMap: Map = new Map(); 11 | for (const job of jobs) { 12 | jobMap.set(job.identifier, job); 13 | } 14 | return async (job: Job) => { 15 | const ctx = buildContext(); 16 | try { 17 | const spec = jobMap.get(job.data.type); 18 | if (!spec) { 19 | throw new Error( 20 | `Didn't know how to process job of type ${job.data.type}` 21 | ); 22 | } 23 | await spec.process({ payload: job.data.payload, ctx, job }); 24 | } finally { 25 | await ctx.destroy(); 26 | } 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /modules/atomic-object/logger.ts: -------------------------------------------------------------------------------- 1 | import * as ErrorNotifier from "atomic-object/error-notifier"; 2 | 3 | let notifyRollbarThreshold = ErrorNotifier.ErrorLevel.error; 4 | 5 | export enum ErrorLevel { 6 | critical = "critical", 7 | error = "error", 8 | warning = "warning", 9 | info = "info", 10 | debug = "debug", 11 | } 12 | 13 | const rankedErrorLevels: ErrorLevel[] = [ 14 | ErrorLevel.debug, 15 | ErrorLevel.info, 16 | ErrorLevel.warning, 17 | ErrorLevel.error, 18 | ErrorLevel.critical, 19 | ]; 20 | export function makeWrapper(level: ErrorLevel): (...messages: any[]) => void { 21 | return (...messages) => { 22 | if (!__TEST__) { 23 | logByLevel(level, ...messages); 24 | } 25 | if ( 26 | rankedErrorLevels.indexOf(level) >= 27 | rankedErrorLevels.indexOf(notifyRollbarThreshold) 28 | ) { 29 | ErrorNotifier[level].apply(ErrorNotifier, messages); 30 | } 31 | }; 32 | } 33 | 34 | function logByLevel(level: ErrorLevel, ...message: any[]) { 35 | switch (level) { 36 | case ErrorLevel.debug: 37 | if (process.env.NODE_ENV !== "production") { 38 | console.log(...message); 39 | } 40 | break; 41 | case ErrorLevel.info: 42 | console.log(...message); 43 | break; 44 | case ErrorLevel.warning: 45 | console.warn(...message); 46 | break; 47 | case ErrorLevel.error: 48 | case ErrorLevel.critical: 49 | console.error(...message); 50 | break; 51 | } 52 | } 53 | 54 | export const critical = makeWrapper(ErrorLevel.critical); 55 | export const error = makeWrapper(ErrorLevel.error); 56 | export const warning = makeWrapper(ErrorLevel.warning); 57 | export const info = makeWrapper(ErrorLevel.info); 58 | export const debug = makeWrapper(ErrorLevel.debug); 59 | -------------------------------------------------------------------------------- /modules/atomic-object/option.ts: -------------------------------------------------------------------------------- 1 | export type Option = T | null | undefined; 2 | export function isSome(x: Option): x is T { 3 | return x != null; 4 | } 5 | export function isNone(x: Option): x is null | undefined { 6 | return x == null; 7 | } 8 | export function optionMap(x: Option, fn: ((_: T) => Y)): Option { 9 | if (isNone(x)) { 10 | return null; 11 | } 12 | return fn(x); 13 | } 14 | -------------------------------------------------------------------------------- /modules/atomic-object/readme.md: -------------------------------------------------------------------------------- 1 | This module is for housing Atomic Object TypeScript Stack generic modules that benefit all AO projects using the stack. Eventually we plan to have dedicated NPM packages for these, but currently embed them in our project starter kit for maximum flexibility to adapt these modules to the needs of the project. 2 | 3 | https://github.com/atomicobject/ts-stack/ 4 | 5 | https://www.npmjs.com/package/@atomic-object/records 6 | -------------------------------------------------------------------------------- /modules/atomic-object/result.ts: -------------------------------------------------------------------------------- 1 | type Result = T | E; 2 | 3 | export type Type = Result; 4 | 5 | export function isError(result: Result): result is E { 6 | return result instanceof Error; 7 | } 8 | 9 | export function isResult( 10 | result: Result 11 | ): result is T { 12 | return !isError(result); 13 | } 14 | 15 | export function toException(result: Result): T { 16 | if (isError(result)) { 17 | throw result; 18 | } 19 | return result; 20 | } 21 | -------------------------------------------------------------------------------- /modules/blueprints/__tests__/builder.test.ts: -------------------------------------------------------------------------------- 1 | import * as Factory from "atomic-object/blueprints/blueprint"; 2 | describe("FactoryBuilder", () => { 3 | it("builds an object if no values are provided", async () => { 4 | type Thing = { 5 | name: string; 6 | id: number; 7 | }; 8 | 9 | const factory = Factory.design({ 10 | name: "Thing One", 11 | id: i => Promise.resolve(i), 12 | }); 13 | 14 | let result = await factory.build(); 15 | expect(result.id).toEqual(0); 16 | expect(result.name).toEqual("Thing One"); 17 | }); 18 | 19 | it("increments the sequence number on subsequent build calls", async () => { 20 | type Thing = { 21 | name: string; 22 | id: number; 23 | }; 24 | 25 | const factory = Factory.design({ 26 | name: "Thing One", 27 | id: i => Promise.resolve(i), 28 | }); 29 | 30 | let result = await factory.build(); 31 | expect(result.id).toEqual(0); 32 | expect(result.name).toEqual("Thing One"); 33 | 34 | result = await factory.build(); 35 | expect(result.id).toEqual(1); 36 | expect(result.name).toEqual("Thing One"); 37 | }); 38 | 39 | it("accepts a value from the partial", async () => { 40 | type Thing = { 41 | name: string; 42 | id: number; 43 | }; 44 | 45 | const factory = Factory.design({ 46 | name: "Thing One", 47 | id: i => Promise.resolve(i), 48 | }); 49 | 50 | let result = await factory.build({ name: "Thing Two" }); 51 | expect(result.id).toEqual(0); 52 | expect(result.name).toEqual("Thing Two"); 53 | }); 54 | 55 | it("accepts a value that is just a function", async () => { 56 | type Thing = { 57 | name: string; 58 | id: number; 59 | }; 60 | 61 | const factory = Factory.design({ 62 | name: "Thing One", 63 | id: i => i, 64 | }); 65 | 66 | let result = await factory.build({ name: "Thing Two" }); 67 | expect(result.id).toEqual(0); 68 | expect(result.name).toEqual("Thing Two"); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /modules/blueprints/index.ts: -------------------------------------------------------------------------------- 1 | import { declareBlueprint, Universe } from "atomic-object/blueprints"; 2 | import * as Blueprint from "atomic-object/blueprints/blueprint"; 3 | import * as DateIso from "core/date-iso"; 4 | import { padStart } from "lodash-es"; 5 | import { UnsavedUser } from "records/user"; 6 | import * as uuid from "uuid"; 7 | 8 | const padToTwoDigits = (n: number) => padStart(n.toString(), 2, "0"); 9 | 10 | let addDays = (d: Date, numDays: number) => { 11 | const dd = new Date(+d + numDays * 24 * 60 * 60 * 1000); 12 | return DateIso.toIsoDate(dd); 13 | }; 14 | 15 | let plusMinus = (n: number) => Math.floor(Math.random() * (n * 2) - n); 16 | let nextWeekPlusOrMinus = (n: number) => addDays(new Date(), 7 + plusMinus(n)); 17 | 18 | export const user = declareBlueprint({ 19 | getRepo: ctx => ctx.repos.users, 20 | buildBlueprint: () => 21 | Blueprint.design({ 22 | firstName: "Ned", 23 | lastName: "Flanders", 24 | }), 25 | }); 26 | 27 | export { Universe }; 28 | -------------------------------------------------------------------------------- /modules/client/__tests__/storybook-helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | INITIAL_VIEWPORTS, 3 | ViewportKeys, 4 | withViewport, 5 | } from "@storybook/addon-viewport"; 6 | import { Renderable, storiesOf } from "@storybook/react"; 7 | 8 | /**Takes a description, renderable, and device sizes and renders the renderable on each device size 9 | * There are no type addons for the addon-viewport, so in order to use add-on viewport API, story is typed as any 10 | */ 11 | export function storiesWithScreenSizes( 12 | description: string, 13 | render: () => Renderable, 14 | devices: ViewportKeys[] 15 | ): void { 16 | const storyWithDecorator = storiesOf(description, module).addDecorator( 17 | withViewport() 18 | ).add as any; 19 | devices.map((device: ViewportKeys) => 20 | storyWithDecorator(INITIAL_VIEWPORTS[device].name, render, { 21 | viewport: device, 22 | }) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /modules/client/__tests__/translations.test.tsx: -------------------------------------------------------------------------------- 1 | import { mount } from "enzyme"; 2 | import * as React from "react"; 3 | import { 4 | useTranslator, 5 | ENGLISH, 6 | TranslationProvider, 7 | Translation, 8 | } from "client/translations"; 9 | 10 | describe("useTranslation", () => { 11 | it("Should return the translated text", () => { 12 | const ShowTranslation: React.SFC = () => { 13 | const translator = useTranslator(); 14 | return <>{translator({ tag: "core.accept" })}; 15 | }; 16 | const page = mount( 17 |
18 | 19 | 20 | 21 |
22 | ); 23 | expect(page.text()).toMatch("Accept"); 24 | }); 25 | }); 26 | 27 | describe("Translation", () => { 28 | it("Should return the translated text", () => { 29 | const page = mount( 30 | // The div is to avoid a top-level fragment 31 |
32 | 33 |
34 | ); 35 | 36 | expect(page.html()).toMatch("Accept"); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /modules/client/actions/index.ts: -------------------------------------------------------------------------------- 1 | export enum ActionTypeKeys { 2 | OTHER_ACTION = "__fake_to_support_system_events__", 3 | } 4 | export type ActionTypes = OtherAction; 5 | 6 | export type OtherAction = { 7 | readonly type: ActionTypeKeys.OTHER_ACTION; 8 | }; 9 | -------------------------------------------------------------------------------- /modules/client/analytics/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const AnaltyicsContext = React.createContext<{ engine: any }>({ 4 | engine: null, 5 | }); 6 | 7 | export const AnalyticsProvider = AnaltyicsContext.Provider; 8 | 9 | type SomeEvent = { category: "SomeCategory"; action: "meow" | "boo" }; 10 | type Event = { label?: string } & (SomeEvent); 11 | 12 | export function useAnalytics() { 13 | const context = React.useContext(AnaltyicsContext); 14 | 15 | return React.useCallback( 16 | (event: Event) => { 17 | if (context.engine !== null) { 18 | context.engine( 19 | "send", 20 | "event", 21 | event.category, 22 | event.action, 23 | event.label 24 | ); 25 | } 26 | }, 27 | [context.engine] 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /modules/client/analytics/load-ga.js: -------------------------------------------------------------------------------- 1 | export function loadGA() { 2 | (function(i, s, o, g, r, a, m) { 3 | i["GoogleAnalyticsObject"] = r; 4 | (i[r] = 5 | i[r] || 6 | function() { 7 | (i[r].q = i[r].q || []).push(arguments); 8 | }), 9 | (i[r].l = 1 * new Date()); 10 | (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); 11 | a.async = 1; 12 | a.src = g; 13 | m.parentNode.insertBefore(a, m); 14 | })( 15 | window, 16 | document, 17 | "script", 18 | "https://www.google-analytics.com/analytics.js", 19 | "ga" 20 | ); 21 | window.ga("create", process.env.TRACKING_ID, "auto"); 22 | window.ga("send", "pageview"); 23 | } 24 | -------------------------------------------------------------------------------- /modules/client/bootstrap-mui.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This file is needed until @material-ui/styles is out of alpha. 4 | 5 | Until then, the package needs to patch is new styling back into the core of material-ui, 6 | and this has to happen before any of the material-ui code is loaded. 7 | 8 | Because this has to happen before loading material-ui, this is the very fist import of our 9 | client entrypoint in entry/client.tsx 10 | 11 | */ 12 | 13 | import { install as installMuiStyles } from "@material-ui/styles"; 14 | installMuiStyles(); 15 | -------------------------------------------------------------------------------- /modules/client/components/app-header/index.tsx: -------------------------------------------------------------------------------- 1 | import AppBar from "@material-ui/core/AppBar"; 2 | import Toolbar from "@material-ui/core/Toolbar"; 3 | import Typography from "@material-ui/core/Typography"; 4 | import { makeStyles } from "client/styles"; 5 | import { Translation } from "client/translations"; 6 | import * as React from "react"; 7 | import { Link } from "react-router-dom"; 8 | 9 | export function AppHeader() { 10 | const classes = useStyles(); 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | ); 25 | } 26 | 27 | const useStyles = makeStyles(theme => ({ 28 | appBarSpacerClass: theme.mixins.toolbar, 29 | header: { 30 | textDecoration: "inherit", 31 | color: "inherit", 32 | flexGrow: 1, 33 | }, 34 | })); 35 | -------------------------------------------------------------------------------- /modules/client/components/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline } from "@material-ui/core"; 2 | import { ThemeProvider } from "@material-ui/styles"; 3 | import { SectionProvider } from "client/components/section-provider"; 4 | import { Section } from "client/core"; 5 | import { ENGLISH, TranslationProvider } from "client/translations"; 6 | import * as React from "react"; 7 | import { PlacementTheme } from "../../styles/mui-theme"; 8 | 9 | export function AppShell(props: { children: JSX.Element }) { 10 | const [select, setSelect] = React.useState(null as Section); 11 | const { children } = props; 12 | return ( 13 | 14 | 15 | 16 | 22 | {children} 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /modules/client/components/button-link/button-link-stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react"; 2 | import * as React from "react"; 3 | import { ButtonLink } from "."; 4 | import { mockProvider } from "client/test-helpers/mock-provider"; 5 | 6 | const Provider = mockProvider({}); 7 | 8 | storiesOf("Components/Button Link", module) 9 | .add("Button Link no button props", () => { 10 | return ( 11 | 12 | This is a button 13 | 14 | ); 15 | }) 16 | .add("Button Link with replace", () => { 17 | return ( 18 | 19 | 20 | This is a button 21 | 22 | 23 | ); 24 | }) 25 | .add("Button Link with button props", () => { 26 | return ( 27 | 28 | 29 | This is a button 30 | 31 | 32 | ); 33 | }); 34 | -------------------------------------------------------------------------------- /modules/client/components/button-link/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@material-ui/core"; 2 | import { ButtonProps } from "@material-ui/core/Button"; 3 | import { LocationDescriptor } from "history"; 4 | import * as React from "react"; 5 | import { Link } from "react-router-dom"; 6 | 7 | export interface Props { 8 | buttonProps?: ButtonProps; 9 | children: React.ReactNode; 10 | to: LocationDescriptor; 11 | replace?: boolean; 12 | } 13 | 14 | export function ButtonLink(props: Props) { 15 | return ( 16 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /modules/client/components/copy/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslator, I18nProps } from "client/translations"; 2 | import { Typography } from "@material-ui/core"; 3 | import { TypographyProps } from "@material-ui/core/Typography"; 4 | import { keys, pick, omit } from "lodash-es"; 5 | import * as React from "react"; 6 | 7 | const i18nkeys = ["tag", "vars"]; 8 | 9 | export function Copy(props: I18nProps & TypographyProps) { 10 | const translateProps = pick(props, i18nkeys); 11 | const typoProps = omit(props, i18nkeys); 12 | const translate = useTranslator(); 13 | return ( 14 | {translate(translateProps as any)} 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /modules/client/components/error-boundary/error-boundary.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { mockProvider } from "client/test-helpers/mock-provider"; 4 | import { ErrorBoundary } from "."; 5 | 6 | const Provider = mockProvider({}); 7 | 8 | storiesOf("Components/Error Boundary", module).add( 9 | "Default Error Boundary", 10 | () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | ); 20 | 21 | function ErrorComponent() { 22 | throw new Error("error"); 23 | return <>; 24 | } 25 | -------------------------------------------------------------------------------- /modules/client/components/error-boundary/index.tsx: -------------------------------------------------------------------------------- 1 | import * as Logger from "atomic-object/logger"; 2 | import * as React from "react"; 3 | import { ErrorComponent } from "../error"; 4 | 5 | export type Props = { 6 | onError?: (error: Error, info: React.ErrorInfo) => void; 7 | children: JSX.Element; 8 | }; 9 | 10 | const initialState = { hasError: false }; 11 | type State = Readonly; 12 | 13 | export class ErrorBoundary extends React.Component { 14 | readonly state: State = initialState; 15 | 16 | static getDerivedStateFromError(error: Error) { 17 | // Update state so the next render will show the fallback UI. 18 | return { hasError: true }; 19 | } 20 | 21 | componentDidCatch(error: Error, info: React.ErrorInfo) { 22 | Logger.error(error, info); 23 | 24 | if (this.props.onError) { 25 | this.props.onError(error, info); 26 | } 27 | } 28 | 29 | render() { 30 | if (this.state.hasError) { 31 | return ; 32 | } 33 | 34 | return this.props.children; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /modules/client/components/error/error.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { mockProvider } from "client/test-helpers/mock-provider"; 4 | import { ErrorComponent } from "."; 5 | 6 | const Provider = mockProvider({}); 7 | 8 | storiesOf("Components/Error", module) 9 | .add( 10 | "404 Not Found", 11 | () => { 12 | return ( 13 | 14 | ); 15 | } 16 | ) 17 | .add( 18 | "Server Error", 19 | () => { 20 | return ( 21 | 22 | ); 23 | } 24 | ) 25 | .add( 26 | "Unknown User", 27 | () => { 28 | return ( 29 | 30 | ); 31 | } 32 | ); 33 | -------------------------------------------------------------------------------- /modules/client/components/error/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useTranslator, I18nProps } from "client/translations"; 3 | import { Grid, Button } from "@material-ui/core"; 4 | import { Copy } from "../copy"; 5 | import { makeStyles } from "client/styles"; 6 | 7 | export type ErrorType = "notFound" | "unknownUser" | "serverError"; 8 | export type Props = { 9 | errorType: ErrorType; 10 | }; 11 | 12 | export function ErrorComponent(props: Props) { 13 | const classes = useStyles(); 14 | const translate = useTranslator(); 15 | const errorInfo = errorPackage(props.errorType); 16 | const buttons = errorInfo.buttons.map(b => ( 17 | 20 | )); 21 | return ( 22 | 23 | (something went wrong - translate me) 24 | {...buttons} 25 | 26 | ); 27 | } 28 | 29 | const useStyles = makeStyles(theme => ({ 30 | copy: { 31 | fontSize: theme.typography.pxToRem(16), 32 | color: theme.palette.common.white, 33 | }, 34 | })); 35 | 36 | type ErrorPackage = { 37 | altText: I18nProps; 38 | buttons: ButtonPackage[]; 39 | }; 40 | 41 | type ButtonPackage = { 42 | buttonHref: string; 43 | buttonText: I18nProps; 44 | }; 45 | function errorPackage(errorType: ErrorType): ErrorPackage { 46 | const translate = useTranslator(); 47 | switch (errorType) { 48 | case "notFound": 49 | return { 50 | altText: { tag: "core.404NotFound" }, 51 | buttons: [{ buttonHref: "/", buttonText: { tag: "core.takeMeBack" } }], 52 | }; 53 | case "serverError": 54 | return { 55 | altText: { tag: "core.somethingWentWrong" }, 56 | buttons: [ 57 | { buttonHref: "/", buttonText: { tag: "core.getMeOutOfHere" } }, 58 | ], 59 | }; 60 | case "unknownUser": 61 | return { 62 | altText: { tag: "core.unknownUser" }, 63 | buttons: [ 64 | { 65 | buttonHref: process.env.IDENTITY_PROVIDER_HOST || "/", 66 | buttonText: { tag: "core.takeMeBack" }, 67 | }, 68 | ], 69 | }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /modules/client/components/form/autosave.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { useFormikContext } from "formik"; 4 | 5 | export function useAutosave(delay: number = 3000) { 6 | delay = __TEST__ ? 0 : delay || 3000; 7 | 8 | const form = useFormikContext(); 9 | 10 | const [autosubmitTime, setNextAutosubmitTime] = React.useState( 11 | null 12 | ); 13 | 14 | // Can we submit? 15 | const canAutosubmit = 16 | !form.isSubmitting && form.dirty && form.isValid && !form.isValidating; 17 | 18 | React.useDebugValue( 19 | autosubmitTime 20 | ? `Next autosubmit at ${autosubmitTime}` 21 | : `No autosubmit scheduled` 22 | ); 23 | 24 | // Perform the submission if we can currently submit. 25 | const submit = React.useCallback(async () => { 26 | if (canAutosubmit) { 27 | form.handleSubmit(); 28 | form.resetForm(form.values); 29 | } 30 | setNextAutosubmitTime(null); 31 | }, [canAutosubmit, form.values]); 32 | 33 | // Mutable holder for the current submit function 34 | // Used on unmount. The effect can't just depend on submit, 35 | // or it submits constantly - every time the form state changes. 36 | const submitHolder = React.useMemo(() => ({ submit }), []); 37 | submitHolder.submit = submit; 38 | 39 | // Debounced save effect for periodic save when dirty. 40 | React.useEffect(() => { 41 | const handler = setTimeout(submit, delay); 42 | setNextAutosubmitTime(new Date(new Date().valueOf() + delay)); 43 | return () => { 44 | clearTimeout(handler); 45 | setNextAutosubmitTime(null); 46 | }; 47 | }, [delay, submit]); // Only re-call effect if value or delay changes 48 | 49 | // Force save on unmount 50 | React.useEffect(() => { 51 | return () => submitHolder.submit(); 52 | }, []); 53 | } 54 | 55 | export const Autosave: React.FunctionComponent<{ delay?: number }> = props => { 56 | useAutosave(props.delay); 57 | return null; 58 | }; 59 | -------------------------------------------------------------------------------- /modules/client/components/form/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { I18nProps, useTranslator } from "client/translations"; 2 | import { Field, FieldAttributes, useField } from "formik"; 3 | import * as FMUI from "formik-material-ui"; 4 | 5 | import * as React from "react"; 6 | 7 | export type CheckBoxProps = { 8 | name: string; 9 | translation?: I18nProps; 10 | } & FieldAttributes<{}>; 11 | /** A thin wrapper around Material UI TextField supporting Formik and translations. */ 12 | export const CheckBox: React.SFC = props => { 13 | const i18n = useTranslator(); 14 | 15 | return props.translation ? ( 16 | 21 | ) : ( 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /modules/client/components/form/error-list.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, Fragment } from "react"; 2 | import * as React from "react"; 3 | import { Typography } from "@material-ui/core"; 4 | import { Copy } from "client/components/copy"; 5 | import { MutationFormState } from "atomic-object/forms/use-mutation-form"; 6 | import { I18nProps } from "client/translations"; 7 | 8 | export const MutationFormSubmissionErrors: FunctionComponent<{ 9 | state: MutationFormState; 10 | message: I18nProps; 11 | }> = props => { 12 | const { state } = props; 13 | if (state.key !== "ERROR") { 14 | return ; 15 | } 16 | return ( 17 |
18 |

19 | 20 |

    21 | {state.errors.map((e, i) => ( 22 |
  • 23 | {e.message} 24 |
  • 25 | ))} 26 |
27 |

28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /modules/client/components/form/select.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, InputLabel, MenuItem } from "@material-ui/core"; 2 | import { I18nProps, useTranslator } from "client/translations"; 3 | import { Field, FieldAttributes } from "formik"; 4 | import * as FMUI from "formik-material-ui"; 5 | import * as React from "react"; 6 | 7 | export type SelectOption = { label: string; value: any }; 8 | export type SelectProps = { 9 | name: string; 10 | translation: I18nProps; 11 | /** A list of logical options to choose from. Required if children not provided */ 12 | options?: SelectOption[]; 13 | /** Children should have MenuItem to render within the Select. Required if options not provided. */ 14 | children?: React.ReactNode; 15 | } & FieldAttributes<{}>; 16 | 17 | /** A thin wrapper around Material UI Select supporting Formik and translations. */ 18 | export const Select: React.SFC = props => { 19 | const i18n = useTranslator(); 20 | 21 | if (!props.children && !props.options) { 22 | throw new Error("Select must have options or children"); 23 | } 24 | 25 | const menuItems = 26 | props.children || 27 | props.options!.map(({ label, value }) => ( 28 | 29 | {label} 30 | 31 | )); 32 | 33 | return ( 34 | 35 | {i18n(props.translation)} 36 | 37 | {menuItems} 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /modules/client/components/form/show-form-data.tsx: -------------------------------------------------------------------------------- 1 | import { useFormikContext } from "formik"; 2 | import * as React from "react"; 3 | import { pick } from "lodash-es"; 4 | 5 | export const ShowFormData = () => { 6 | const ctx = useFormikContext(); 7 | const values = pick( 8 | ctx, 9 | "dirty", 10 | "isSubmitting", 11 | "isValid", 12 | "isValidating", 13 | "status", 14 | "submitCount" 15 | ); 16 | return ( 17 |
18 |       {JSON.stringify(ctx.values, null, 2)}
19 | 
20 |       {JSON.stringify(values, null, 2)}
21 |     
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /modules/client/components/form/text-field.tsx: -------------------------------------------------------------------------------- 1 | import { I18nProps, useTranslator } from "client/translations"; 2 | import { Field, FieldAttributes, useField } from "formik"; 3 | import * as FMUI from "formik-material-ui"; 4 | 5 | import * as React from "react"; 6 | import { makeStyles } from "client/styles"; 7 | 8 | export type TextFieldProps = { 9 | name: string; 10 | translation?: I18nProps; 11 | } & FieldAttributes<{}>; 12 | /** A thin wrapper around Material UI TextField supporting Formik and translations. */ 13 | export const TextField: React.SFC = props => { 14 | const i18n = useTranslator(); 15 | const classes = useStyles(); 16 | 17 | return ( 18 | 24 | ); 25 | }; 26 | 27 | const useStyles = makeStyles(theme => ({ 28 | alignCenter: { 29 | justifyContent: "center", 30 | }, 31 | })); 32 | -------------------------------------------------------------------------------- /modules/client/components/form/time-select.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | InputLabel, 4 | MenuItem, 5 | Select, 6 | Grid, 7 | FormHelperText, 8 | } from "@material-ui/core"; 9 | import { I18nProps, useTranslator } from "client/translations"; 10 | import * as TimeIso from "core/time-iso"; 11 | import { range } from "lodash-es"; 12 | import * as React from "react"; 13 | import { Field } from "formik"; 14 | import { SelectProps } from "@material-ui/core/Select"; 15 | import { makeStyles } from "client/styles"; 16 | 17 | export type TimeSelectProps = { 18 | translation: I18nProps; 19 | field: { 20 | name: string; 21 | value: TimeIso.Type; 22 | onChange: (e: React.ChangeEvent) => void; 23 | }; 24 | form: any; 25 | } & SelectProps; 26 | 27 | /** A select box wrapper with an hour field and a minute field */ 28 | const TimeSelectUI: React.SFC = props => { 29 | const i18n = useTranslator(); 30 | const classes = useStyles(); 31 | 32 | const nullTimes = !props.field.value; 33 | 34 | const date = TimeIso.parse(props.field.value); 35 | const minute = date.getMinutes(); 36 | const hour = date.getHours(); 37 | 38 | const handleHoursChange = React.useCallback( 39 | (event: any) => { 40 | const updatedTime = TimeIso.from(event.target.value, minute); 41 | props.form.setFieldValue(props.field.name, updatedTime); 42 | }, 43 | [props.field.name, minute] 44 | ); 45 | 46 | const handleMinutesChange = React.useCallback( 47 | (event: any) => { 48 | const updatedTime = TimeIso.from(hour, event.target.value); 49 | props.form.setFieldValue(props.field.name, updatedTime); 50 | }, 51 | [props.field.name, hour] 52 | ); 53 | 54 | const hours1 = range(1, 10); 55 | const hours2 = range(10, 25); 56 | const minutes1 = range(0, 10); 57 | const minutes2 = range(10, 60); 58 | 59 | return React.useMemo(() => { 60 | return ( 61 | 62 | 63 | 64 | 68 | {i18n(props.translation)} 69 | 70 | 90 | 106 | 107 | 108 | {props.form.errors[props.field.name]} 109 | 110 | ); 111 | }, [minute, hour, props.form.errors[props.field.name]]); 112 | }; 113 | 114 | export const TimeSelect: React.SFC< 115 | { 116 | name: string; 117 | translation: I18nProps; 118 | } & SelectProps 119 | > = props => { 120 | return ; 121 | }; 122 | 123 | const useStyles = makeStyles(theme => ({ 124 | readOnly: { 125 | pointerEvents: "none" as "none", 126 | }, 127 | })); 128 | -------------------------------------------------------------------------------- /modules/client/components/loading-dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CircularProgress, 3 | Dialog, 4 | DialogContent, 5 | DialogTitle, 6 | Grid, 7 | } from "@material-ui/core"; 8 | import * as React from "react"; 9 | import { Copy } from "../copy"; 10 | import { makeStyles } from "client/styles"; 11 | import { DialogProps } from "@material-ui/core/Dialog"; 12 | 13 | export interface Props extends DialogProps { 14 | header?: JSX.Element; 15 | } 16 | 17 | export const LoadingDialog = React.memo(function LoadingDialog( 18 | props: Props 19 | ) { 20 | const classes = useStyles(); 21 | return ( 22 | 27 | 28 | {props.header ? ( 29 | props.header 30 | ) : ( 31 | 32 | )} 33 | 34 | 35 | 41 | 42 | 43 | 44 | 45 | ); 46 | }); 47 | 48 | const useStyles = makeStyles(theme => ({ 49 | header: { 50 | fontSize: theme.typography.pxToRem(20), 51 | }, 52 | })); 53 | -------------------------------------------------------------------------------- /modules/client/components/loading-dialog/loading-dialog.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { LoadingDialog } from "."; 4 | 5 | storiesOf("Components/Loading Dialog", module).add( 6 | "Default loading", 7 | () => { 8 | return ; 9 | } 10 | ).add( 11 | "Loading with custom header", 12 | () => { 13 | return This is a custom header
} />; 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /modules/client/components/section-provider/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Section } from "client/core"; 3 | 4 | export const SectionContext = React.createContext({ 5 | selected: null as Section, 6 | setSelected: (url: Section) => {}, 7 | }); 8 | 9 | export const SectionProvider = SectionContext.Provider; 10 | 11 | export interface Props { 12 | selected: Section; 13 | } 14 | 15 | export function ActivatedSection(props: Props) { 16 | const context = React.useContext(SectionContext); 17 | React.useEffect(() => { 18 | context.setSelected(props.selected); 19 | return () => {}; 20 | }, []); 21 | return null; 22 | } 23 | -------------------------------------------------------------------------------- /modules/client/core/index.tsx: -------------------------------------------------------------------------------- 1 | export type Section = null; // | Section1 | Section2... 2 | -------------------------------------------------------------------------------- /modules/client/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "core": { 3 | "loading": "Loading...", 4 | "cancel": "Cancel", 5 | "accept": "Accept", 6 | "getMeOutOfHere": "Get me out of here!", 7 | "somethingWentWrong": "Something went wrong.", 8 | "takeMeBack": "Take me back!", 9 | "404NotFound": "404 Not Found", 10 | "contactUsAt": "Contact us at {email}", 11 | "unknownUser": "Unknown User" 12 | }, 13 | "header": { 14 | "productTitle": "My App" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /modules/client/express.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | namespace Express { 3 | interface Request { 4 | context?: Context; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /modules/client/ga.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | interface Window { 3 | ga: any; 4 | } 5 | } -------------------------------------------------------------------------------- /modules/client/graphql-types.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | // This file was automatically generated and should not be edited. 3 | 4 | // ==================================================== 5 | // GraphQL query operation: Foo 6 | // ==================================================== 7 | 8 | /* tslint:disable */ 9 | // This file was automatically generated and should not be edited. 10 | 11 | //============================================================== 12 | // START Enums and Input Objects 13 | //============================================================== 14 | 15 | //============================================================== 16 | // END Enums and Input Objects 17 | //============================================================== 18 | -------------------------------------------------------------------------------- /modules/client/graphql/__tests__/local-date.test.ts: -------------------------------------------------------------------------------- 1 | import { withContext } from "__tests__/db-helpers"; 2 | import { LocalDate } from "../types.gen"; 3 | import * as DateIso from "core/date-iso"; 4 | import { dateIso } from "core/date-iso"; 5 | 6 | describe("LocalDate", () => { 7 | it( 8 | "gets the local date when run LocalDate query", 9 | withContext({ 10 | initialState: { localDate: dateIso`1970-01-01` }, 11 | 12 | async run(ctx) { 13 | let queryResult = await ctx.apolloClient.query({ 14 | query: LocalDate.Document, 15 | fetchPolicy: "no-cache", 16 | }); 17 | 18 | const todayDate = DateIso.toIsoDate(new Date()); 19 | 20 | expect(queryResult.data!.localDate).toBe(todayDate); 21 | }, 22 | }) 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /modules/client/graphql/__tests__/local-name.test.ts: -------------------------------------------------------------------------------- 1 | import { withContext } from "__tests__/db-helpers"; 2 | import { GetLocalName, ChangeLocalName } from "../types.gen"; 3 | 4 | describe("GetLocalName and ChangeLocalName", () => { 5 | it( 6 | "updates the local name when we run the ChangeLocalName mutation", 7 | withContext({ 8 | initialState: { localName: "foo" }, 9 | 10 | async run(ctx) { 11 | let queryResult = await ctx.apolloClient.query({ 12 | query: GetLocalName.Document, 13 | }); 14 | 15 | expect(queryResult.data!.client).toEqual("foo"); 16 | 17 | const mutationResult = await ctx.apolloClient.mutate< 18 | ChangeLocalName.Mutation, 19 | ChangeLocalName.Variables 20 | >({ 21 | mutation: ChangeLocalName.Document, 22 | }); 23 | 24 | expect(mutationResult.data!.setLocalName).toEqual("Foo"); 25 | 26 | queryResult = await ctx.apolloClient.query({ 27 | query: GetLocalName.Document, 28 | }); 29 | 30 | expect(queryResult.data!.client).toEqual("Foo"); 31 | }, 32 | }) 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /modules/client/graphql/client-context.ts: -------------------------------------------------------------------------------- 1 | import { ApolloCache } from "apollo-cache"; 2 | export interface ClientContext { 3 | cache: ApolloCache; 4 | } 5 | -------------------------------------------------------------------------------- /modules/client/graphql/client.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache, NormalizedCacheObject } from "apollo-cache-inmemory"; 2 | import ApolloClient from "apollo-client"; 3 | import { ApolloLink } from "apollo-link"; 4 | import { BatchHttpLink } from "apollo-link-batch-http"; 5 | import { History } from "history"; 6 | import { buildErrorLink } from "./error-link"; 7 | import { buildClientLink } from "./state-link"; 8 | 9 | const cache = new InMemoryCache(); 10 | 11 | const stateLink = buildClientLink(cache); 12 | 13 | export function buildGraphqlClient( 14 | history: History 15 | ): ApolloClient { 16 | const errorLink = buildErrorLink(history); 17 | return new ApolloClient({ 18 | cache: cache, 19 | link: ApolloLink.from([ 20 | errorLink, 21 | stateLink, 22 | new BatchHttpLink({ 23 | uri: "/graphql", 24 | batchInterval: 10, 25 | credentials: "same-origin", 26 | }), 27 | ]), 28 | defaultOptions: { 29 | watchQuery: { 30 | // this governs the default fetch policy for react-apollo-hooks' useQuery(): 31 | fetchPolicy: "cache-and-network", 32 | }, 33 | }, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /modules/client/graphql/core.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from "graphql"; 2 | 3 | export type GraphqlBundle = { 4 | _variables: TVars; 5 | _result: TResult; 6 | Document: DocumentNode; 7 | }; 8 | -------------------------------------------------------------------------------- /modules/client/graphql/error-link.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink } from "apollo-link"; 2 | import { onError } from "apollo-link-error"; 3 | import { History } from "history"; 4 | 5 | export function buildErrorLink(history: History): ApolloLink { 6 | return onError(({ graphQLErrors, networkError }) => { 7 | console.log("graphql error", graphQLErrors); 8 | console.log("network error", networkError); 9 | if (networkError) { 10 | if ((networkError as any).statusCode === 403) { 11 | window.location.assign("/auth/login"); 12 | } else { 13 | history.push("/error"); 14 | } 15 | return; 16 | } 17 | 18 | if (graphQLErrors) { 19 | graphQLErrors.forEach(({ message, locations, path }) => { 20 | console.log( 21 | `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` 22 | ); 23 | }); 24 | history.push("/error"); 25 | } 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /modules/client/graphql/fragments/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicobject/ts-react-graphql-starter-kit/802d1a25533858085a8f95d8eff5c05826f65a9a/modules/client/graphql/fragments/.gitkeep -------------------------------------------------------------------------------- /modules/client/graphql/fragments/UserInfo.graphql: -------------------------------------------------------------------------------- 1 | fragment UserInfo on User { 2 | firstName 3 | lastName 4 | } 5 | -------------------------------------------------------------------------------- /modules/client/graphql/hooks.ts: -------------------------------------------------------------------------------- 1 | import * as ApolloHooks from "react-apollo-hooks"; 2 | import { GraphqlBundle } from "./core"; 3 | import { 4 | ApolloQueryResult, 5 | ObservableQuery, 6 | FetchMoreQueryOptions, 7 | FetchMoreOptions, 8 | } from "apollo-client"; 9 | import { useMemo } from "react"; 10 | import { MutationFn } from "react-apollo"; 11 | import { Omit } from "helpers"; 12 | 13 | type NonOptional = O extends null | undefined | (infer T) ? T : O; 14 | 15 | /** Extra query stuff we get from apollo hooks. */ 16 | type QueryExtras = Pick< 17 | ObservableQuery, 18 | "refetch" | "startPolling" | "stopPolling" | "updateQuery" 19 | > & { 20 | fetchMore( 21 | fetchMoreOptions: FetchMoreQueryOptions & 22 | FetchMoreOptions 23 | ): Promise>; 24 | }; 25 | 26 | type QueryBaseResult = Omit< 27 | ApolloHooks.QueryHookResult, 28 | "data" 29 | > & { 30 | data: TData; 31 | } & QueryExtras; 32 | 33 | export type PlacementQueryHookResult = 34 | // Initial loading state. No data to show 35 | | { state: "LOADING" } & QueryExtras 36 | // Updating, but we have data to show. Usually render this. 37 | | { state: "UPDATING" } & QueryBaseResult 38 | // Loaded. We have data to show 39 | | { state: "DONE" } & QueryBaseResult; 40 | 41 | export function useQueryBundle( 42 | query: GraphqlBundle, 43 | options?: ApolloHooks.QueryHookOptions 44 | ): PlacementQueryHookResult { 45 | const rawResult = ApolloHooks.useQuery(query.Document, { 46 | suspend: false, 47 | ...options, 48 | }); 49 | 50 | const ourResult = useMemo>((): any => { 51 | if (!rawResult.data || Object.keys(rawResult.data).length == 0) { 52 | return { state: "LOADING", ...rawResult }; 53 | } else if (rawResult.loading) { 54 | return { state: "UPDATING", ...rawResult }; 55 | } else { 56 | return { state: "DONE", ...rawResult }; 57 | } 58 | }, [rawResult]); 59 | 60 | return ourResult; 61 | } 62 | 63 | export function useMutationBundle( 64 | mutation: GraphqlBundle, 65 | options?: ApolloHooks.MutationHookOptions 66 | ): MutationFn { 67 | const func = ApolloHooks.useMutation( 68 | mutation.Document, 69 | options 70 | ) as MutationFn; // using the type from react-apollo instead of react-apollo-hooks for better compatibility with remaining non-hook apollo use. (change this later?) 71 | return func; 72 | } 73 | -------------------------------------------------------------------------------- /modules/client/graphql/mutations/ChangeLocalName.graphql: -------------------------------------------------------------------------------- 1 | mutation ChangeLocalName { 2 | setLocalName(newName: "Foo") @client 3 | } 4 | -------------------------------------------------------------------------------- /modules/client/graphql/queries/GetLocalName.graphql: -------------------------------------------------------------------------------- 1 | query GetLocalName { 2 | client: localName @client 3 | } 4 | -------------------------------------------------------------------------------- /modules/client/graphql/queries/GetLoggedInUser.graphql: -------------------------------------------------------------------------------- 1 | query GetLoggedInUser { 2 | loggedInUser { 3 | id 4 | firstName 5 | lastName 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /modules/client/graphql/queries/LocalDate.graphql: -------------------------------------------------------------------------------- 1 | query LocalDate { 2 | localDate: localDate @client 3 | } 4 | -------------------------------------------------------------------------------- /modules/client/graphql/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import Mutation from "./mutation"; 2 | import Query from "./query"; 3 | 4 | export const ClientSideResolvers = { 5 | Mutation, 6 | Query, 7 | }; 8 | -------------------------------------------------------------------------------- /modules/client/graphql/resolvers/mutation.ts: -------------------------------------------------------------------------------- 1 | import { MutationResolvers } from "../client-types.gen"; 2 | 3 | const setLocalName: MutationResolvers.SetLocalNameResolver = async function( 4 | parent, 5 | args, 6 | context, 7 | info 8 | ) { 9 | context.cache.writeData({ data: { localName: args.newName } }); 10 | return args.newName; 11 | }; 12 | 13 | export default { 14 | setLocalName, 15 | }; 16 | -------------------------------------------------------------------------------- /modules/client/graphql/resolvers/query.ts: -------------------------------------------------------------------------------- 1 | import { QueryResolvers } from "../client-types.gen"; 2 | import { ApolloCache } from "apollo-cache"; 3 | import * as DateIso from "core/date-iso"; 4 | 5 | const localDate: QueryResolvers.LocalDateResolver< 6 | DateIso.Type 7 | > = async function(parent, args, context, info) { 8 | return DateIso.toIsoDate(new Date()); 9 | }; 10 | 11 | export default { 12 | localDate, 13 | }; 14 | -------------------------------------------------------------------------------- /modules/client/graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar IsoDate 2 | scalar IsoTime 3 | 4 | type Query { 5 | localName: String! 6 | localDate: IsoDate! 7 | } 8 | 9 | type Mutation { 10 | # TODO: delete me: 11 | setLocalName(newName: String!): String! 12 | } 13 | -------------------------------------------------------------------------------- /modules/client/graphql/state-link.ts: -------------------------------------------------------------------------------- 1 | import { ApolloCache } from "apollo-cache"; 2 | import { withClientState } from "apollo-link-state"; 3 | import { ClientSideResolvers } from "./resolvers"; 4 | import { ApolloLink } from "apollo-link"; 5 | 6 | import { Query as ClientSideQuery } from "./types.gen"; 7 | import { Query as ServerSideQuery } from "graphql-api/server-types.gen"; 8 | import * as DateIso from "core/date-iso"; 9 | type ClientSideProps = Exclude; 10 | 11 | export type ClientState = Pick; 12 | 13 | export const DEFAULTS: ClientState = { 14 | localName: "friend", 15 | localDate: DateIso.toIsoDate(new Date()), 16 | }; 17 | 18 | export function buildClientLink( 19 | cache: ApolloCache, 20 | defaults: ClientState = DEFAULTS 21 | ): ApolloLink { 22 | const link = withClientState({ 23 | cache, 24 | resolvers: ClientSideResolvers, 25 | defaults: defaults, 26 | }); 27 | return link; 28 | } 29 | -------------------------------------------------------------------------------- /modules/client/index.tsx: -------------------------------------------------------------------------------- 1 | import { AppShell } from "client/components/app"; 2 | import * as React from "react"; 3 | import { asyncComponent } from "react-async-component"; 4 | import { Route, Switch } from "react-router-dom"; 5 | import { AppHeader } from "./components/app-header"; 6 | import { ErrorBoundary } from "./components/error-boundary"; 7 | import { 8 | NotFoundErrorPageRouteLoader, 9 | ServerErrorPageRouteLoader, 10 | UnknownUserErrorPageRouteLoader, 11 | } from "./pages/error/error-loaders"; 12 | import * as AuthRoutes from "./routes/authentication-routes"; 13 | /** Build the core app store with middlewares and reducer. Used to bootstrap the app to run and to test. */ 14 | 15 | export function App(props: {}) { 16 | return ( 17 | 18 | 19 | 20 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | /* 33 | Most routes in the app should live here. 34 | Currently, the only routes without a header are the routes that need to be rendered without making graphql requests. 35 | RoutesWithHeader will either match with one of the routes in our app or return an 404 not found page. 36 | */ 37 | function RoutesWithHeader() { 38 | return ( 39 | <> 40 | 41 | 42 | (await import("client/pages/home")).HomePage, 47 | name: "Home Page", 48 | })} 49 | /> 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /modules/client/material-ui-core-styles-create-mui-theme.d.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "@material-ui/core/styles/createMuiTheme"; 2 | 3 | declare module "@material-ui/core/styles/createMuiTheme" { 4 | export interface Theme { 5 | customColors: { 6 | white: string; 7 | grayWhite: string; 8 | black: string; 9 | blue: string; 10 | darkBlue: string; 11 | gray: string; 12 | darkGray: string; 13 | medLightGray: string; 14 | lightGray: string; 15 | 16 | raspberry: string; 17 | pumpkin: string; 18 | mustard: string; 19 | ocean: string; 20 | grape: string; 21 | twilight: string; 22 | sky: string; 23 | slate: string; 24 | tomato: string; 25 | grass: string; 26 | 27 | lightTomato: string; 28 | lightRaspberry: string; 29 | lightPumpkin: string; 30 | lightMustard: string; 31 | lightOcean: string; 32 | lightGrape: string; 33 | lightTwilight: string; 34 | lightSlate: string; 35 | }; 36 | } 37 | // allow configuration using `createMuiTheme` 38 | export interface ThemeOptions { 39 | customColors?: { 40 | white?: string; 41 | grayWhite?: string; 42 | black?: string; 43 | blue?: string; 44 | darkBlue?: string; 45 | gray?: string; 46 | darkGray?: string; 47 | medLightGray?: string; 48 | lightGray?: string; 49 | raspberry?: string; 50 | pumpkin?: string; 51 | mustard?: string; 52 | ocean?: string; 53 | grape?: string; 54 | twilight?: string; 55 | sky?: string; 56 | slate?: string; 57 | tomato?: string; 58 | grass?: string; 59 | 60 | lightTomato?: string; 61 | lightRaspberry?: string; 62 | lightMustard?: string; 63 | lightOcean?: string; 64 | lightGrape?: string; 65 | lightTwilight?: string; 66 | lightSlate?: string; 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /modules/client/material-ui-styles.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@material-ui/styles" { 2 | function install(); 3 | declare const ThemeProvider: React.ComponentType<{ theme: Theme }>; 4 | } 5 | 6 | declare module "@material-ui/core/useMediaQuery" { 7 | export interface Options { 8 | defaultMatches?: boolean; 9 | matchMedia?: (query: string) => MuiMediaQueryList; 10 | } 11 | declare function unstable_useMediaQuery( 12 | query: string, 13 | options?: Options 14 | ): boolean; 15 | } 16 | -------------------------------------------------------------------------------- /modules/client/pages/error/error-loaders.tsx: -------------------------------------------------------------------------------- 1 | import { asyncComponent } from "react-async-component"; 2 | import * as React from "react"; 3 | import { ErrorComponent } from "client/components/error"; 4 | 5 | export const NotFoundErrorPageRouteLoader = asyncComponent({ 6 | resolve: async () => { 7 | return () => ; 8 | }, 9 | name: "NotFoundErrorPage", 10 | }); 11 | export const ServerErrorPageRouteLoader = asyncComponent({ 12 | resolve: async () => { 13 | return () => ; 14 | }, 15 | name: "ServerErrorPage", 16 | }); 17 | export const UnknownUserErrorPageRouteLoader = asyncComponent({ 18 | resolve: async () => { 19 | return () => ; 20 | }, 21 | name: "UnknownUserErrorPage", 22 | }); 23 | -------------------------------------------------------------------------------- /modules/client/pages/home/__tests__/home-page.test.tsx: -------------------------------------------------------------------------------- 1 | import { HomePage } from "client/pages/home"; 2 | import { mockProvider } from "client/test-helpers/mock-provider"; 3 | import { mount } from "enzyme"; 4 | import { sleep } from "helpers"; 5 | import * as React from "react"; 6 | 7 | describe("Home page", () => { 8 | it("Begins in a loading state", async () => { 9 | const Provider = mockProvider({ 10 | mocks: { 11 | Query: () => ({ 12 | localName: () => "Liz", 13 | testFirstEmployee: () => ({ id: 1 }), 14 | testFirstSubstitute: () => ({ id: 1 }), 15 | }), 16 | }, 17 | }); 18 | 19 | const page = mount( 20 | 21 | 22 | 23 | ); 24 | 25 | await sleep(0); 26 | page.update(); 27 | 28 | expect(page.text()).toContain("This page will eventually be replaced"); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /modules/client/pages/home/home-page-ui.stories.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from "@storybook/react"; 2 | import * as React from "react"; 3 | import { HomePageUI } from "./home-page-ui"; 4 | import { mockProvider } from "client/test-helpers/mock-provider"; 5 | 6 | storiesOf("Page – Home", module).add("Example", () => { 7 | const Provider = mockProvider({}); 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /modules/client/pages/home/home-page-ui.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Paper, Typography } from "@material-ui/core"; 2 | import { ButtonLink } from "client/components/button-link"; 3 | import { makeStyles } from "client/styles"; 4 | import * as React from "react"; 5 | 6 | export interface HomePageUIProps { 7 | name: string; 8 | currentCount: number; 9 | onClick?: () => void; 10 | } 11 | 12 | export function HomePageUI(props: HomePageUIProps) { 13 | const [resetting, setResetting] = React.useState(false); 14 | const classes = useStyles(); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | This page will eventually be replaced. 22 | See these links: 23 |
    24 |
  • 25 | Error Page 26 |
  • 27 |
28 |
29 |
30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | const useStyles = makeStyles(theme => ({ 37 | paper: { 38 | padding: theme.spacing.unit * 2, 39 | margin: theme.spacing.unit * 2, 40 | }, 41 | })); 42 | -------------------------------------------------------------------------------- /modules/client/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { HomePageUI } from "client/pages/home/home-page-ui"; 2 | import * as React from "react"; 3 | 4 | const HomePageWithClick: React.SFC<{ 5 | name: string; 6 | }> = props => { 7 | const [count, setCount] = React.useState(0); 8 | 9 | const { name } = props; 10 | 11 | const handleButtonClick = () => { 12 | setCount(count + 1); 13 | }; 14 | 15 | return ( 16 | 17 | ); 18 | }; 19 | 20 | /** TODO: Wrap with higher-order-components */ 21 | export const HomePage: React.SFC<{}> = () => { 22 | return ; 23 | }; 24 | -------------------------------------------------------------------------------- /modules/client/react.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | declare module "react" { 4 | // React 16.6 5 | 6 | function memo( 7 | component: React.StatelessComponent 8 | ): React.StatelessComponent; 9 | 10 | function lazy>( 11 | importFn: () => Promise 12 | ): Component; 13 | 14 | const Suspense: React.ComponentType<{ fallback?: React.ReactNode }>; 15 | 16 | function useDebugValue(debugValue: any): void; 17 | 18 | // React 16.7 19 | 20 | type StateUpdateFunction = ( 21 | newState: State | ((oldState: State) => State) 22 | ) => void; 23 | 24 | function useState( 25 | initialState: State | (() => State) 26 | ): [State, StateUpdateFunction]; 27 | 28 | function useEffect( 29 | f: () => void | Promise | (() => void | Promise), 30 | keys?: any[] 31 | ): void; 32 | function useMutationEffect( 33 | f: () => void | Promise | (() => void | Promise), 34 | keys?: any[] 35 | ): void; 36 | function useLayoutEffect( 37 | f: () => void | Promise | (() => void | Promise), 38 | keys?: any[] 39 | ): void; 40 | 41 | function useContext(context: React.Context): Context; 42 | 43 | type Reducer = (state: State, action: Action) => State; 44 | function useReducer( 45 | reducer: Reducer, 46 | initialState: State, 47 | initialAction?: Action 48 | ): [State, (action: Action) => void]; 49 | 50 | function useCallback( 51 | f: Callback, 52 | keys?: any[] 53 | ): Callback; 54 | function useMemo(f: () => Value, keys?: any[]): Value; 55 | 56 | function useRef(): { current: T | null }; 57 | function useRef(initial: T): { current: T }; 58 | 59 | function useImperativeMethods( 60 | ref: React.Ref | undefined, 61 | f: () => ImperativeMethods, 62 | keys?: any[] 63 | ): void; 64 | } 65 | -------------------------------------------------------------------------------- /modules/client/routes/authentication-routes.tsx: -------------------------------------------------------------------------------- 1 | export const LOGIN = "/auth/login"; 2 | export const LOGOUT = "/auth/logout"; 3 | export const DEVELOPMENT_LOGIN = "/auth/development-login"; 4 | export const DEVELOPMENT_LOGOUT = "/auth/development-logout"; 5 | export const SAML_CALLBACK = "/auth/saml-callback"; 6 | export const SAML_LOGOUT_CALLBACK = "/auth/saml-logout-callback"; 7 | export const USER_NOT_FOUND = "/auth/user-not-found"; 8 | -------------------------------------------------------------------------------- /modules/client/stories.ts: -------------------------------------------------------------------------------- 1 | import { addDecorator } from "@storybook/react"; 2 | import "./components/button-link/button-link-stories"; 3 | import "./components/error/error.stories"; 4 | import "./components/error-boundary/error-boundary.stories"; 5 | import "./components/loading-dialog/loading-dialog.stories.tsx"; 6 | import "./pages/home/home-page-ui.stories"; 7 | import { withI18n } from "./storybook-decorators"; 8 | 9 | // Fill in type definition for info addon 10 | declare module "@storybook/react" { 11 | interface Story { 12 | addWithInfo( 13 | storyName: string, 14 | storyDesc: string, 15 | story: RenderFunction 16 | ): Story; 17 | } 18 | } 19 | 20 | addDecorator(withI18n); 21 | -------------------------------------------------------------------------------- /modules/client/storybook-addon-viewport.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@storybook/addon-viewport" { 2 | const viewPortTypes = 3 | string | 4 | { 5 | onViewportChange({}) {}, 6 | }; 7 | function withViewport(param?: viewPortTypes): StorybookDecorator; 8 | 9 | type ViewportKeys = 10 | | "responsive" 11 | | "iphone5" 12 | | "iphone6" 13 | | "iphone6p" 14 | | "iphone8p" 15 | | "iphonex" 16 | | "iphonexr" 17 | | "iphonexsmax" 18 | | "ipad" 19 | | "ipad10p" 20 | | "ipad12p" 21 | | "galaxys5" 22 | | "galaxys9" 23 | | "nexus5x" 24 | | "nexus6p" 25 | | "pixel" 26 | | "pixelxl"; 27 | 28 | const INITIAL_VIEWPORTS: { 29 | [k in ViewportKeys]: { 30 | name: string; 31 | styles: any; 32 | type: "desktop" | "mobile"; 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /modules/client/storybook-decorators.tsx: -------------------------------------------------------------------------------- 1 | import { MuiThemeProvider } from "@material-ui/core"; 2 | import { ThemeProvider } from "@material-ui/styles"; 3 | import { StoryDecorator } from "@storybook/react"; 4 | import { ENGLISH, TranslationProvider } from "client/translations"; 5 | import * as React from "react"; 6 | import { BrowserRouter } from "react-router-dom"; 7 | import { PlacementTheme } from "./styles/mui-theme"; 8 | 9 | export const withTheme: StoryDecorator = s => ( 10 | 11 | {s()} 12 | 13 | ); 14 | 15 | export const withI18n: StoryDecorator = s => ( 16 | {s()} 17 | ); 18 | -------------------------------------------------------------------------------- /modules/client/styles/index.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from "@material-ui/core"; 2 | import * as MUIStyles from "@material-ui/styles"; 3 | import { CSSProperties } from "jss/css"; 4 | import { WithStylesOptions } from "@material-ui/styles/withStyles"; 5 | 6 | /* 7 | 8 | I have some problems with material-ui's types for their styles library. 9 | 10 | 1. I want to patch it to force use of our custom theme type 11 | 2. Their type definitions force your code to provide an empty object to 12 | useStyles in cases where you have no props. E.g., useStyles({}); 13 | 3. Their types let too many anys through, so TypeScript can't check your 14 | CSS properties as thoroughly. 15 | 16 | To address these issues, I've defined our own makeStyles() hook here. 17 | 18 | */ 19 | 20 | export type StyleRules< 21 | Props extends object | void, 22 | ClassKey extends string = string 23 | > = Record CSSProperties)>; 24 | 25 | export type StyleRulesCallback< 26 | Props extends object | void, 27 | ClassKey extends string = string 28 | > = (theme: Theme) => StyleRules; 29 | 30 | export type StyleSheet = 31 | | StyleRules 32 | | StyleRulesCallback; 33 | 34 | export type StyleRulesWithProps = Record< 35 | ClassNames, 36 | ((p: Props) => CSSProperties) | CSSProperties 37 | >; 38 | 39 | export type StyleRulesCallbackWithProps = ( 40 | theme: Theme 41 | ) => StyleRulesWithProps; 42 | 43 | export type StyleSheetWithProps = 44 | | StyleRulesWithProps 45 | | StyleRulesCallbackWithProps; 46 | 47 | export function makeStyles( 48 | styles: StyleSheet, 49 | options?: WithStylesOptions 50 | ): () => { [k in ClassKeys]: string }; 51 | 52 | export function makeStyles( 53 | styles: StyleSheetWithProps, 54 | options?: WithStylesOptions 55 | ): (props: Props) => { [k in ClassKeys]: string }; 56 | 57 | export function makeStyles(s: any, t: any): any { 58 | /* 59 | Jest tests use jsdom internally, which has a weak CSS parser that can choke 60 | on modern CSS features. In particular we ran into this with media queries: 61 | 62 | > Error: Could not parse CSS stylesheet 63 | 64 | https://github.com/jsdom/jsdom/issues/2177 65 | 66 | So, for now, we're withholding component styles when under test. 67 | */ 68 | let maybeStyles = __TEST__ ? {} : s; 69 | return MUIStyles.makeStyles(maybeStyles, t) as any; 70 | } 71 | -------------------------------------------------------------------------------- /modules/client/styles/mui-theme.tsx: -------------------------------------------------------------------------------- 1 | import createBreakpoints, { 2 | BreakpointsOptions, 3 | } from "@material-ui/core/styles/createBreakpoints"; 4 | import createMuiTheme, { Theme } from "@material-ui/core/styles/createMuiTheme"; 5 | 6 | /* 7 | Material-UI's interface for defining the theme is imperfect. 8 | Specifically, it makes it difficult for a theme definition to reference itself 9 | to avoid repetition. 10 | 11 | I've worked around this for now by breaking definitions out into constants and 12 | copying some of the internals of createMuiTheme out into this file. 13 | (Such as pxToRem) 14 | 15 | Nothing from this file should be exported except PlacementTheme. 16 | */ 17 | 18 | /** do not export this */ 19 | const pxToRem = (size: number) => { 20 | const coeff = baseFontSize / 14; 21 | return `${(size / htmlFontSize) * coeff}rem`; 22 | }; 23 | 24 | const smallFontSize = 12; 25 | const baseFontSize = 14; 26 | const htmlFontSize = 16; 27 | const fontWeightMedium = 500; 28 | 29 | const themeColors: Theme["customColors"] = { 30 | white: "#ffffff", 31 | grayWhite: "f7f7f7", 32 | black: "#262c36", 33 | blue: "#378ff6", 34 | darkBlue: "#031F3C", 35 | gray: "#b4b6b9", 36 | darkGray: "#6f6f6f", 37 | medLightGray: "#d8d8d8", 38 | lightGray: "#f4f4f4", 39 | 40 | raspberry: "#CC0079", 41 | pumpkin: "#E96B1C", 42 | mustard: "#CFAA2A", 43 | ocean: "#03A8A4", 44 | grape: "#B80FD5", 45 | twilight: "#37068F", 46 | sky: "#03a9f4", 47 | slate: "#6d6d6d", 48 | tomato: "#eb2626", 49 | grass: "#4caf50", 50 | 51 | lightTomato: "#FDE9E9", 52 | lightRaspberry: "#F9E5F1", 53 | lightPumpkin: "#FCF0E8", 54 | lightMustard: "#FAF6E9", 55 | lightOcean: "#E5F6F5", 56 | lightGrape: "#F7E7FA", 57 | lightTwilight: "#EAE6F3", 58 | lightSlate: "#F0F0F0", 59 | }; 60 | const breakpointCustomization: BreakpointsOptions = {}; 61 | const breakpoints = createBreakpoints(breakpointCustomization); 62 | 63 | export const PlacementTheme = createMuiTheme({ 64 | breakpoints: breakpointCustomization, 65 | overrides: { 66 | MuiButton: { 67 | contained: { 68 | color: themeColors.white, 69 | backgroundColor: themeColors.sky, 70 | }, 71 | outlined: { 72 | borderColor: themeColors.sky, 73 | borderWidth: pxToRem(2), 74 | }, 75 | }, 76 | MuiDialog: { 77 | paper: { 78 | [breakpoints.down("xs")]: { 79 | margin: pxToRem(20), 80 | }, 81 | }, 82 | }, 83 | MuiDialogTitle: { 84 | root: { 85 | [breakpoints.down("xs")]: { 86 | paddingTop: pxToRem(13), 87 | paddingBottom: pxToRem(10), 88 | }, 89 | }, 90 | }, 91 | MuiDialogActions: { 92 | root: { 93 | paddingRight: pxToRem(4), 94 | }, 95 | action: { 96 | // Style the typography inside of the MuiDialogAction component 97 | "& p": { 98 | fontWeight: fontWeightMedium, 99 | textTransform: "uppercase" as "uppercase", 100 | [breakpoints.down("xs")]: { 101 | fontSize: pxToRem(smallFontSize), 102 | }, 103 | }, 104 | }, 105 | }, 106 | MuiTableCell: { 107 | root: { 108 | borderBottom: `${pxToRem(2)} solid ${themeColors.black}`, 109 | }, 110 | }, 111 | MuiTooltip: { 112 | popper: { 113 | opacity: 1, 114 | }, 115 | tooltip: { 116 | backgroundColor: themeColors.white, 117 | color: themeColors.black, 118 | }, 119 | }, 120 | }, 121 | palette: { 122 | primary: { 123 | main: "#123f6e", 124 | }, 125 | }, 126 | customColors: { 127 | ...themeColors, 128 | }, 129 | typography: palette => ({ 130 | color: themeColors.black, 131 | useNextVariants: true, 132 | fontSize: baseFontSize, 133 | htmlFontSize: htmlFontSize, 134 | button: { 135 | fontSize: pxToRem(16), 136 | fontWeight: fontWeightMedium, 137 | color: palette.common.white, 138 | textTransform: "none", 139 | [breakpoints.down("xs") as any]: { 140 | fontSize: pxToRem(12), 141 | }, 142 | }, 143 | }), 144 | }); 145 | -------------------------------------------------------------------------------- /modules/client/translations.ts: -------------------------------------------------------------------------------- 1 | import { I18nProfile, MapOf, PropsOf } from "atomic-object/i18n"; 2 | import { buildComponents } from "atomic-object/i18n/component"; 3 | 4 | export const I18n = I18nProfile.with("header.productTitle") 5 | .with("core.cancel") 6 | .with("core.accept") 7 | .with("core.loading") 8 | .with("core.getMeOutOfHere") 9 | .with("core.somethingWentWrong") 10 | .with("core.takeMeBack") 11 | .with("core.404NotFound") 12 | .with("core.contactUsAt", "email") 13 | .with("core.unknownUser"); 14 | 15 | export type I18nProps = PropsOf; 16 | export type I18nMap = MapOf; 17 | 18 | export const ENGLISH = I18n.verifyI18nMap(require("./en-US.json")); 19 | 20 | export const { 21 | Translation, 22 | TranslationProvider, 23 | useTranslator, 24 | } = buildComponents(ENGLISH); 25 | 26 | export type Translator = ReturnType; 27 | -------------------------------------------------------------------------------- /modules/core/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomicobject/ts-react-graphql-starter-kit/802d1a25533858085a8f95d8eff5c05826f65a9a/modules/core/.gitkeep -------------------------------------------------------------------------------- /modules/core/__tests__/date-iso.test.ts: -------------------------------------------------------------------------------- 1 | import * as DateIso from "core/date-iso"; 2 | import { dateIso } from "core/date-iso"; 3 | 4 | describe("toIsoDate", () => { 5 | it("should format date in ISO8601 date format (YYYY-MM-DD)", () => { 6 | const date = new Date(2018, 11, 15, 6, 5); 7 | const isoDate = DateIso.toIsoDate(date); 8 | expect(isoDate).toEqual(dateIso`2018-12-15`); 9 | }); 10 | }); 11 | 12 | describe("getWeekDayFromIsoDate", () => { 13 | it("should get the week day given an iso date", () => { 14 | const isoDate = dateIso`2018-12-15`; 15 | expect(DateIso.getWeekDayFromIsoDate(isoDate)).toBe(6); 16 | }); 17 | }); 18 | 19 | describe("getMonthDayFromIsoDate", () => { 20 | it("should get the month day given an iso date", () => { 21 | const isoDate = dateIso`2018-12-15`; 22 | expect(DateIso.getMonthDayFromIsoDate(isoDate)).toBe(15); 23 | }); 24 | }); 25 | 26 | describe("toLongDay", () => { 27 | it("should format to just the day of the week", () => { 28 | const shortDate = DateIso.toLongDay( 29 | DateIso.toIsoDate(new Date(2018, 0, 1)) 30 | ); 31 | expect(shortDate).toEqual("Monday"); 32 | }); 33 | }); 34 | 35 | describe("toShortMonthAndDate", () => { 36 | it("should format to the month and dat", () => { 37 | const shortDate = DateIso.toShortMonthAndDate( 38 | DateIso.toIsoDate(new Date(2018, 0, 1)) 39 | ); 40 | expect(shortDate).toEqual("Jan 1"); 41 | }); 42 | }); 43 | 44 | describe("toShortDayDate", () => { 45 | it("should format single digit date with no zeros", () => { 46 | const shortDate = DateIso.toShortDayDate( 47 | DateIso.toIsoDate(new Date(2018, 0, 1)) 48 | ); 49 | expect(shortDate).toEqual("Mon 1-1-2018"); 50 | }); 51 | it("should format double digit date", () => { 52 | const shortDate = DateIso.toShortDayDate( 53 | DateIso.toIsoDate(new Date(2018, 11, 12)) 54 | ); 55 | expect(shortDate).toEqual("Wed 12-12-2018"); 56 | }); 57 | }); 58 | 59 | describe("formatLongForm", () => { 60 | it("should format an ISO date", () => { 61 | expect(DateIso.formatLongForm(dateIso`2019-01-17`)).toEqual( 62 | "Thu, January 17, 2019" 63 | ); 64 | }); 65 | }); 66 | 67 | describe("getDateTense", () => { 68 | it("if date is before the current date should return past", () => { 69 | const date = dateIso`2018-11-15`; 70 | const currentDate = dateIso`2018-12-15`; 71 | expect(DateIso.getDateTense(date, currentDate)).toBe("past"); 72 | }); 73 | it("if date is after the current date should return future", () => { 74 | const date = dateIso`2018-13-15`; 75 | const currentDate = dateIso`2018-12-15`; 76 | expect(DateIso.getDateTense(date, currentDate)).toBe("future"); 77 | }); 78 | it("if date is the same as the current date should return today", () => { 79 | const date = dateIso`2018-12-15`; 80 | const currentDate = dateIso`2018-12-15`; 81 | expect(DateIso.getDateTense(date, currentDate)).toBe("today"); 82 | }); 83 | }); 84 | 85 | describe("areEqual", () => { 86 | it("compares future/past/same times against today", async () => { 87 | const yesterday = dateIso`2019-01-10`; 88 | const today = dateIso`2019-01-11`; 89 | const altToday = "1-11-2019"; 90 | const tomorrow = dateIso`2019-01-12`; 91 | const aYearAgo = dateIso`2018-01-11`; 92 | 93 | expect(() => DateIso.areEqual(today, altToday as any)).toThrow(); 94 | expect(DateIso.areEqual(today, tomorrow)).toBe(false); 95 | expect(DateIso.areEqual(today, yesterday)).toBe(false); 96 | expect(DateIso.areEqual(today, aYearAgo)).toBe(false); 97 | }); 98 | }); 99 | 100 | describe("isTomorrow", () => { 101 | it("compares dates against tomorrow", async () => { 102 | const today = dateIso`2019-01-11`; 103 | const tomorrow = dateIso`2019-01-12`; 104 | const altTomorrow = "01/12/2019"; 105 | const aYearAgoTomorrow = dateIso`2018-01-12`; 106 | 107 | expect(() => DateIso.isTomorrow(today, altTomorrow as any)).toThrow(); 108 | expect(DateIso.isTomorrow(today, tomorrow)).toBe(true); 109 | expect(DateIso.isTomorrow(today, today)).toBe(false); 110 | expect(DateIso.isTomorrow(today, aYearAgoTomorrow)).toBe(false); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /modules/core/__tests__/time-iso.test.ts: -------------------------------------------------------------------------------- 1 | import * as TimeIso from "core/time-iso"; 2 | 3 | describe("isValid", () => { 4 | it("checks that a thing is an IsoTime", async () => { 5 | expect(TimeIso.isValid("22:15:00")).toBe(true); 6 | expect(TimeIso.isValid("22")).toBe(false); 7 | expect(TimeIso.isValid(null)).toBe(false); 8 | expect(TimeIso.isValid(undefined)).toBe(false); 9 | }); 10 | }); 11 | describe("toHoursMinutes", () => { 12 | it("converts from 24h to 12h time", async () => { 13 | expect(TimeIso.toHoursMinutes("07:45:00")).toEqual("7:45am"); 14 | expect(TimeIso.toHoursMinutes("10:30:00")).toEqual("10:30am"); 15 | expect(TimeIso.toHoursMinutes("13:00:00")).toEqual("1:00pm"); 16 | expect(TimeIso.toHoursMinutes("22:15:00")).toEqual("10:15pm"); 17 | }); 18 | }); 19 | describe("fromDate", () => { 20 | it("creates a time from a date", async () => { 21 | expect(TimeIso.fromDate(new Date(2000, 1, 1, 5, 15))).toEqual("05:15:00"); 22 | expect(TimeIso.fromDate(new Date(2000, 1, 1, 15, 15))).toEqual("15:15:00"); 23 | }); 24 | }); 25 | describe("from", () => { 26 | it("creates a time from a hours/minutes/seconds", async () => { 27 | expect(TimeIso.from(5, 15)).toEqual("05:15:00"); 28 | expect(TimeIso.from(15)).toEqual("15:00:00"); 29 | expect(TimeIso.from(5, 15, 34)).toEqual("05:15:34"); 30 | // check that passing a string is okay because we are 31 | // not sure what the browser will give us 32 | expect(TimeIso.from("12" as any, 15, 34)).toEqual("12:15:34"); 33 | }); 34 | }); 35 | describe("getDuration", () => { 36 | it("returns the difference between an end time and start time", () => { 37 | const times = { 38 | startTime: "07:00:00", 39 | endTime: "14:00:00", 40 | }; 41 | expect(TimeIso.getDuration(times)).toEqual(420); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /modules/core/date-iso.ts: -------------------------------------------------------------------------------- 1 | import * as DateFns from "date-fns"; 2 | import { 3 | addMonths, 4 | getYear, 5 | isBefore, 6 | isWithinRange, 7 | lastDayOfMonth, 8 | startOfMonth, 9 | } from "date-fns"; 10 | import { Brand } from "helpers"; 11 | import { groupBy, includes } from "lodash-es"; 12 | 13 | export type Type = Brand; 14 | export type DateTense = "today" | "past" | "future"; 15 | export const VALID_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/; 16 | 17 | export function _assertValid(date: Type): Type { 18 | if (process.env.NODE_ENV !== "production" && !VALID_REGEX.test(date)) { 19 | throw new Error(`Invalid IsoDate ${date}`); 20 | } 21 | return date; 22 | } 23 | 24 | export function toIsoDate(date: Date | string): Type { 25 | return DateFns.format(date, "YYYY-MM-DD") as Type; 26 | } 27 | 28 | export function toShortDayDate(date: Type): string { 29 | return DateFns.format(_assertValid(date), "ddd M-D-YYYY"); 30 | } 31 | 32 | export function toLongDay(date: Type): string { 33 | return DateFns.format(_assertValid(date), "dddd"); 34 | } 35 | 36 | export function toShortMonthAndDate(date: Type): string { 37 | return DateFns.format(_assertValid(date), "MMM D"); 38 | } 39 | 40 | export function formatLongForm(date: Type): string { 41 | return DateFns.format(_assertValid(date), "ddd, MMMM D, YYYY"); 42 | } 43 | 44 | export function getMonthAndYearFromIsoDate(date: Type): string { 45 | return DateFns.format(date, "YYYY-MM"); 46 | } 47 | 48 | export function formatLongDayMonthYear(date: Date | string): string { 49 | return DateFns.format(date, "dddd, MMMM D, YYYY"); 50 | } 51 | 52 | export function areEqual(today: Type, date: Type): boolean { 53 | return _assertValid(today) == _assertValid(date); 54 | } 55 | 56 | export function isTomorrow(today: Type, date: Type): boolean { 57 | _assertValid(date); 58 | _assertValid(today); 59 | const tomorrow = DateFns.addDays(today, 1); 60 | return DateFns.isSameDay(tomorrow, date); 61 | } 62 | 63 | /** 64 | * Sunday is 0, Saturday is 6 65 | */ 66 | export function getWeekDayFromIsoDate(date: Type): number { 67 | return DateFns.getDay(date); 68 | } 69 | 70 | export function getMonthDayFromIsoDate(date: Type): number { 71 | return DateFns.getDate(date); 72 | } 73 | 74 | export function getMonthAndDayFromIsoDate(date: Type): string { 75 | return DateFns.format(date, "MMM D"); 76 | } 77 | 78 | export function getYearFromIsoDate(date: Type): number { 79 | return parseInt(date.slice(0, 4), 10); 80 | } 81 | 82 | function getCalendarWeekNumberFromDate( 83 | date: Type, 84 | firstDayOfMonth: Type, 85 | sundayBeforeFirstDayOfMonth: Type 86 | ): number { 87 | if (DateFns.isSameMonth(date, firstDayOfMonth)) { 88 | return Math.floor( 89 | DateFns.differenceInDays(date, sundayBeforeFirstDayOfMonth) / 7 90 | ); 91 | } else if (DateFns.isAfter(date, firstDayOfMonth)) { 92 | return DateFns.differenceInCalendarWeeks(date, firstDayOfMonth); 93 | } 94 | return 0; 95 | } 96 | 97 | export function getDateTense(date: Type, currentDate: Type): DateTense { 98 | if (date == currentDate) { 99 | return "today"; 100 | } else if (date.localeCompare(currentDate) > 0) { 101 | return "future"; 102 | } 103 | return "past"; 104 | } 105 | 106 | interface DateObj { 107 | date: Type; 108 | } 109 | 110 | export function dateIso( 111 | literals: TemplateStringsArray, 112 | ...placeholders: never[] 113 | ) { 114 | if (literals.length != 1) { 115 | throw new Error("One parameter only, please."); 116 | } 117 | const date = literals[0]; 118 | if (!VALID_REGEX.test(date)) { 119 | throw new Error(`Invalid IsoDate ${date}`); 120 | } 121 | return date as Type; 122 | } 123 | -------------------------------------------------------------------------------- /modules/core/env.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean; 2 | declare const __TEST__: boolean; 3 | -------------------------------------------------------------------------------- /modules/core/index.ts: -------------------------------------------------------------------------------- 1 | import { Flavor } from "helpers"; 2 | 3 | export type Percentage = Flavor; 4 | 5 | export type HourlyRate = Flavor; 6 | export type DailyRate = Flavor; 7 | -------------------------------------------------------------------------------- /modules/core/permissions.ts: -------------------------------------------------------------------------------- 1 | import { Permission } from "services/core"; 2 | 3 | // TODO move id types into the core module 4 | export type PermissionToUpsertUser = Permission< 5 | void, 6 | "Permission to upsert user" 7 | >; 8 | -------------------------------------------------------------------------------- /modules/core/schemas/date-iso.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "string", 3 | "tsType": "DateIso.Type", 4 | "pattern": "^\\d{4}-\\d{2}-\\d{2}$" 5 | } 6 | -------------------------------------------------------------------------------- /modules/core/schemas/email-address.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "string", 3 | "minLength": 1, 4 | "pattern": "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" 5 | } 6 | -------------------------------------------------------------------------------- /modules/core/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import * as Ajv from "ajv"; 2 | import * as Result from "atomic-object/result"; 3 | 4 | if (__TEST__) { 5 | // Polyfill `require.context` in jest to simulate webpack "require all" functionality 6 | if (typeof require.context === "undefined") { 7 | const fs = require("fs"); 8 | const path = require("path"); 9 | 10 | (require as any).context = ( 11 | base: any, 12 | scanSubDirectories: any, 13 | regularExpression: any 14 | ) => { 15 | const files: any = {}; 16 | 17 | function readDirectory(directory: any) { 18 | fs.readdirSync(directory).forEach((file: any) => { 19 | const fullPath = path.resolve(directory, file); 20 | 21 | if (fs.statSync(fullPath).isDirectory()) { 22 | if (scanSubDirectories) readDirectory(fullPath); 23 | 24 | return; 25 | } 26 | 27 | if (!regularExpression.test(fullPath)) return; 28 | 29 | files[fullPath] = true; 30 | }); 31 | } 32 | 33 | readDirectory(path.resolve(__dirname, base)); 34 | 35 | function Module(file: any) { 36 | return require(file); 37 | } 38 | 39 | Module.keys = () => Object.keys(files); 40 | 41 | return Module; 42 | }; 43 | } 44 | } 45 | 46 | function addInternalSchema(ajv: Ajv.Ajv, name: string, schema: any) { 47 | ajv.addSchema(schema, `int:${name}`); 48 | } 49 | 50 | export function configureAjv(ajv: Ajv.Ajv) { 51 | // Note that the schemas are ALSO processed by the generator in `yarn build` 52 | // so if you add or change this, you probably also need to change 53 | // scripts/json-schema-types.js 54 | const ctx = require.context("./", true, /\.schema\.json$/); 55 | for (const key of ctx.keys()) { 56 | const base = key.match(/([\w_-]+).schema.json$/)![1]; 57 | addInternalSchema(ajv, base, ctx(key)); 58 | } 59 | } 60 | 61 | export class SchemaError extends Error implements Ajv.ErrorObject { 62 | schemaPath: string; 63 | propertyName?: string | undefined; 64 | schema?: any; 65 | parentSchema?: object | undefined; 66 | data?: any; 67 | dataPath: string; 68 | params: Ajv.ErrorParameters; 69 | keyword: string; 70 | 71 | constructor(ajvError: Ajv.ErrorObject) { 72 | super(ajvError.message); 73 | this.dataPath = ajvError.dataPath; 74 | this.params = ajvError.params; 75 | this.keyword = ajvError.keyword; 76 | this.schemaPath = ajvError.schemaPath; 77 | this.data = ajvError.data; 78 | this.schema = ajvError.schema; 79 | this.propertyName = ajvError.propertyName; 80 | } 81 | } 82 | export function buildAjv(opts: Ajv.Options) { 83 | const ajv = new Ajv(opts); 84 | configureAjv(ajv); 85 | return ajv; 86 | } 87 | 88 | export const DEFAULT_AJV = buildAjv({ 89 | jsonPointers: true, 90 | }); 91 | 92 | export interface Validator { 93 | isValid(o: unknown): o is T; 94 | from(o: unknown): Result.Type; 95 | validate(o: unknown): T; 96 | } 97 | 98 | export function buildValidator(args: { schema: any }): Validator { 99 | const check = DEFAULT_AJV.compile(args.schema); 100 | return { 101 | isValid(o): o is T { 102 | return check(o) as boolean; 103 | }, 104 | from(o) { 105 | return check(o) ? (o as T) : new SchemaError(check.errors![0]); 106 | }, 107 | validate(o) { 108 | if (check(o)) { 109 | return o as T; 110 | } else { 111 | throw new SchemaError(check.errors![0]); 112 | } 113 | }, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /modules/core/schemas/time-iso.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "string", 3 | "tsType": "TimeIso.Type", 4 | "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 5 | } 6 | -------------------------------------------------------------------------------- /modules/core/time-iso.ts: -------------------------------------------------------------------------------- 1 | import * as DateFns from "date-fns"; 2 | import { Flavor } from "helpers"; 3 | 4 | // todo: refactor and resolve these: 5 | // (this file's type was meant to represent time-of-day, but in practice sometimes holds (new Date()).toString()s in it.) 6 | export type Type = Flavor; 7 | // export type Type = Flavor; 8 | export const VALID_REGEX = /^\d{2}:\d{2}:\d{2}$/; 9 | 10 | export type TimeSet = { 11 | startTime: Type; 12 | endTime: Type; 13 | }; 14 | export function isValid(t: any): t is Type { 15 | return VALID_REGEX.test(t); 16 | } 17 | 18 | export function getDuration(timeSet: TimeSet): number { 19 | return DateFns.differenceInMinutes( 20 | parse(timeSet.endTime), 21 | parse(timeSet.startTime) 22 | ); 23 | } 24 | export type DurationMinutes = Flavor; 25 | 26 | export function toHoursMinutes(input: Type): string { 27 | return DateFns.format(parse(input), "h:mma"); 28 | } 29 | 30 | export function toHoursAmPm(input: Type): string { 31 | return DateFns.format(parse(input), "ha"); 32 | } 33 | export function toHours(input: Type): string { 34 | return DateFns.format(parse(input), "h"); 35 | } 36 | export function toMinutes(input: Type): string { 37 | return DateFns.format(parse(input), "mm"); 38 | } 39 | export function parse(input: Type): Date { 40 | return DateFns.parse(`2000-01-01T${input}`); 41 | } 42 | export function from( 43 | hours: number = 0, 44 | minutes: number = 0, 45 | seconds: number = 0 46 | ): Type { 47 | return fromDate(new Date(2000, 1, 1, hours, minutes, seconds)); 48 | } 49 | 50 | export function fromDate(date: Date): Type { 51 | return DateFns.format(date, "HH:mm:ss") as Type; 52 | } 53 | 54 | export function timeIso( 55 | literals: TemplateStringsArray, 56 | ...placeholders: never[] 57 | ) { 58 | if (literals.length != 1) { 59 | throw new Error("One parameter only, please."); 60 | } 61 | const time = literals[0]; 62 | if (!VALID_REGEX.test(time)) { 63 | throw new Error(`Invalid IsoTime ${time}`); 64 | } 65 | return time as Type; 66 | } 67 | -------------------------------------------------------------------------------- /modules/db/__tests__/db.test.ts: -------------------------------------------------------------------------------- 1 | import * as config from "config"; 2 | 3 | describe("the database connection", () => { 4 | it.todo("is configured by the environment", () => { 5 | expect(config.get("environment")).toEqual("test"); 6 | expect(config.get("databaseUrl")).toMatch(/^postgres:/); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /modules/db/index.ts: -------------------------------------------------------------------------------- 1 | import * as config from "config"; 2 | const env = config.get("environment"); 3 | 4 | const knexConfig: any = require("../../knexfile")[env]; 5 | 6 | import * as knexModule from "knex"; 7 | 8 | export type Knex = knexModule; 9 | const knex: typeof knexModule = require("knex"); 10 | 11 | /** The currently open connection. Set by getConnection and destroyConnection */ 12 | let $connection: knexModule | undefined = undefined; 13 | 14 | export async function destroyConnection() { 15 | if ($connection) { 16 | await $connection.destroy(); 17 | $connection = undefined; 18 | } 19 | } 20 | 21 | export function getConnection() { 22 | if (!$connection) { 23 | /* 24 | Node types for Postgres types 25 | 26 | - When the postgres driver encounters a *datetime*, it creates a JavaScript Date. Cool! 27 | - When the postgres driver encounters a *time*, it creates a JavaScript string. Cool. 28 | - When the postgres driver encounters a *date* (not implying a time of day), it creates a JavaScript Date. Boo! 29 | 30 | This customizes the behavior to pass it through as a string, reducing the risk of time zone drift, etc. 31 | 32 | https://stackoverflow.com/a/50717046/202907 33 | 34 | */ 35 | var pgTypes = require("pg").types; 36 | pgTypes.setTypeParser(1082, (val: string) => val); 37 | 38 | $connection = knex(knexConfig); 39 | } 40 | return $connection; 41 | } 42 | 43 | export function _setConnection(knex: Knex) { 44 | $connection = knex; 45 | } 46 | 47 | export async function truncateAll(knex: Knex) { 48 | const result = await knex.raw(` 49 | SELECT table_name 50 | FROM information_schema.tables 51 | WHERE table_schema='public' 52 | AND table_type='BASE TABLE'; 53 | `); 54 | const tables: string[] = result.rows.map((r: any) => r.table_name); 55 | const recordTables = tables.filter(t => !t.includes("knex")); 56 | const escapedTableNameList = recordTables.map(n => `"${n}"`).join(", "); 57 | await knex.raw(`TRUNCATE ${escapedTableNameList} CASCADE`); 58 | } 59 | -------------------------------------------------------------------------------- /modules/db/redis.ts: -------------------------------------------------------------------------------- 1 | import * as IORedis from "ioredis"; 2 | import * as config from "config"; 3 | 4 | // declare function getRedisConnection(): Redis; 5 | let _redis: IORedis.Redis | null = null; 6 | export function getRedisConnection() { 7 | if (!_redis) { 8 | _redis = new IORedis(config.get("redis.url")); 9 | } 10 | return _redis; 11 | } 12 | 13 | export async function destroyConnection() { 14 | if (_redis) { 15 | await _redis.disconnect(); 16 | _redis = null; 17 | } 18 | return _redis; 19 | } 20 | -------------------------------------------------------------------------------- /modules/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "db"; 2 | import * as defaultScenario from "fixtures/scenarios/default"; 3 | 4 | export async function seedScenarios(knex: Knex): Promise { 5 | return await defaultScenario.seed(knex); 6 | } 7 | -------------------------------------------------------------------------------- /modules/fixtures/scenarios/default.ts: -------------------------------------------------------------------------------- 1 | import { Universe } from "atomic-object/blueprints"; 2 | import * as Blueprint from "blueprints"; 3 | import { toIsoDate } from "core/date-iso"; 4 | import { addMonths } from "date-fns"; 5 | import { Knex } from "db"; 6 | import { Context } from "graphql-api/context"; 7 | 8 | export async function seed(knex: Knex): Promise { 9 | const context = new Context({ db: knex, redisDisabled: true }); 10 | const universe = new Universe(context); 11 | 12 | await universe.insert(Blueprint.user, {}); 13 | 14 | await context.destroy(); 15 | return Promise.resolve(null); 16 | } 17 | -------------------------------------------------------------------------------- /modules/graphql-api/__tests__/caching.test.ts: -------------------------------------------------------------------------------- 1 | import * as Cache from "atomic-object/cache"; 2 | import { withContext } from "__tests__/db-helpers"; 3 | 4 | describe("Caching via the Context", () => { 5 | it( 6 | "can cache operations", 7 | withContext(async ctx => { 8 | let counter = 0; 9 | const opSpec = Cache.fromStringableArg({ 10 | key: "fuhgeddaboutit", 11 | async func(arg) { 12 | counter++; 13 | return String(arg); 14 | }, 15 | settings: { 16 | minAgeMs: 900, 17 | maxAgeMs: 30000, 18 | graceMs: 300, 19 | }, 20 | }); 21 | 22 | const tenValue = await ctx.cache.get(opSpec, 10); 23 | expect(tenValue).toEqual("10"); 24 | 25 | const tenValue2 = await ctx.cache.get(opSpec, 10); 26 | expect(tenValue2).toEqual("10"); 27 | 28 | expect(counter).toBe(1); 29 | }) 30 | ); 31 | 32 | it( 33 | "can cache operations which don't need a key", 34 | withContext(async ctx => { 35 | let counter = 0; 36 | const opSpec: Cache.Singleton = { 37 | key: "fuhgeddaboutit", 38 | async func() { 39 | counter++; 40 | return "fuhgot"; 41 | }, 42 | settings: { 43 | minAgeMs: 900, 44 | maxAgeMs: 30000, 45 | graceMs: 300, 46 | }, 47 | }; 48 | 49 | const result1 = await ctx.cache.get(opSpec); 50 | expect(result1).toEqual("fuhgot"); 51 | 52 | const result2 = await ctx.cache.get(opSpec); 53 | expect(result2).toEqual("fuhgot"); 54 | 55 | expect(counter).toBe(1); 56 | }) 57 | ); 58 | }); 59 | -------------------------------------------------------------------------------- /modules/graphql-api/context.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache, NormalizedCacheObject } from "apollo-cache-inmemory"; 2 | import { ApolloClient } from "apollo-client"; 3 | import { ApolloLink } from "apollo-link"; 4 | import { SchemaLink } from "apollo-link-schema"; 5 | import { Cache } from "atomic-object/cache"; 6 | import { RedisCacheStore } from "atomic-object/cache/stores/redis"; 7 | import { buildClientLink, ClientState } from "client/graphql/state-link"; 8 | import * as config from "config"; 9 | import { GraphQLSchema } from "graphql"; 10 | import { Repositories } from "records"; 11 | import { UserId, SavedUser } from "records/user"; 12 | import { ALL_SERVICE_ACTIONS, GlobalDispatch } from "services"; 13 | import { Dispatcher } from "atomic-object/cqrs/dispatch"; 14 | import { JobQueuer, JobRunner } from "atomic-object/jobs/mapping"; 15 | import * as db from "../db"; 16 | import { executableSchema } from "./index"; 17 | export function buildLocalApollo(schema: GraphQLSchema = executableSchema) { 18 | return new Context().apolloClient; 19 | } 20 | 21 | export type ContextOpts = { 22 | db?: db.Knex; 23 | initialState?: ClientState; 24 | redisPrefix?: string; 25 | redisDisabled?: boolean; 26 | userId?: number; 27 | jobs?: JobQueuer; 28 | cacheStore?: CacheStore; 29 | }; 30 | 31 | /** The graphql context type for this app. */ 32 | export class Context { 33 | readonly cache: Cache; 34 | readonly redisPrefix: string; 35 | private _userId: number | null; 36 | readonly db: db.Knex; 37 | 38 | dispatch: GlobalDispatch; 39 | repos: Repositories; 40 | _jobs: undefined | JobQueuer; 41 | 42 | constructor(opts: ContextOpts = {}) { 43 | this.db = opts.db || db.getConnection(); 44 | 45 | const { jobs } = opts; 46 | this._jobs = jobs; 47 | 48 | const apolloCache = new InMemoryCache(); 49 | this.apolloClient = new ApolloClient({ 50 | ssrMode: true, 51 | cache: apolloCache, 52 | link: ApolloLink.from([ 53 | buildClientLink(apolloCache, opts && opts.initialState), 54 | new SchemaLink({ 55 | schema: executableSchema, 56 | context: this, 57 | }), 58 | ]), 59 | }); 60 | 61 | this.dispatch = new Dispatcher(this, ALL_SERVICE_ACTIONS); 62 | 63 | this._userId = opts.userId || null; 64 | 65 | /* 66 | You should NEVER set redisDisabled to true unless you're using a context 67 | outside of the application purely for access to repositories, etc. 68 | We added this flag to support seeding data to our dev/staging environments. 69 | */ 70 | const redisDisabled = opts && opts.redisDisabled === true; 71 | if (!redisDisabled) { 72 | this.redisPrefix = opts.redisPrefix || config.get("redis.prefix"); 73 | 74 | const cachePrefix = `${this.redisPrefix}cache:`; 75 | const cacheStore = opts.cacheStore || new RedisCacheStore(cachePrefix); 76 | this.cache = new Cache(cacheStore); 77 | } else { 78 | this.cache = null as any; 79 | this.redisPrefix = null as any; 80 | this._jobs = null as any; 81 | } 82 | this.repos = new Repositories(this.db); 83 | } 84 | 85 | get jobs(): JobQueuer { 86 | if (!this._jobs) { 87 | throw new Error("Jobs are not initialized on this Context"); 88 | } 89 | return this._jobs; 90 | } 91 | 92 | get userId(): UserId { 93 | if (!this._userId) throw new Error("No authenticated user."); 94 | return this._userId; 95 | } 96 | 97 | /* 98 | userId should only be set for testing 99 | In prod, userId should only be set when creating a new context for a user 100 | */ 101 | set userId(newUserId: UserId) { 102 | //if env does not equal test, throw an error 103 | if (!__TEST__) { 104 | throw new Error( 105 | "Do not set user id in production. User id should only be set when creating the context" 106 | ); 107 | } 108 | this._userId = newUserId; 109 | } 110 | 111 | async getCurrentUser(): Promise { 112 | if (!this._userId) throw new Error("No authenticated user."); 113 | const user = await this.repos.users.findById.load(this.userId); 114 | if (!user) throw new Error("No authenticated user."); 115 | return user; 116 | } 117 | 118 | /** An ApolloClient which can be used for local graphql queries. Does not hit the network. */ 119 | apolloClient: ApolloClient; 120 | 121 | clone = () => { 122 | return new Context({ 123 | db: this.db, 124 | redisPrefix: this.redisPrefix, 125 | jobs: this._jobs, 126 | cacheStore: this.cache.store, 127 | }); 128 | }; 129 | 130 | async destroy() { 131 | // currently a noop 132 | } 133 | } 134 | 135 | export class ApiContext extends Context {} 136 | -------------------------------------------------------------------------------- /modules/graphql-api/index.ts: -------------------------------------------------------------------------------- 1 | export { SchemaMap } from "./schema-base"; 2 | import { default as resolvers } from "./resolvers"; 3 | 4 | import { rawSchema } from "./schema-base"; 5 | import { makeExecutableSchema } from "graphql-tools"; 6 | 7 | export const executableSchema = makeExecutableSchema({ 8 | typeDefs: rawSchema, 9 | resolvers: resolvers as any, 10 | }); 11 | -------------------------------------------------------------------------------- /modules/graphql-api/resolvers/__tests__/query.test.ts: -------------------------------------------------------------------------------- 1 | import * as Blueprint from "blueprints"; 2 | import { GetLoggedInUser } from "client/graphql/types.gen"; 3 | import { withContext } from "__tests__/db-helpers"; 4 | 5 | describe("Query", () => { 6 | describe("logged in user", () => { 7 | it( 8 | "returns user logged in context", 9 | withContext({ 10 | userScenario: universe => universe.insert(Blueprint.user), 11 | async run(ctx, { user }) { 12 | let result = await ctx.apolloClient.query({ 13 | query: GetLoggedInUser.Document, 14 | }); 15 | expect(result.data.loggedInUser.id).toEqual(user!.id); 16 | }, 17 | }) 18 | ); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /modules/graphql-api/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import Mutation from "./mutation/index"; 2 | import Query from "./query"; 3 | 4 | export default { 5 | Query, 6 | Mutation, 7 | }; 8 | -------------------------------------------------------------------------------- /modules/graphql-api/resolvers/mutation/index.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /modules/graphql-api/resolvers/query.ts: -------------------------------------------------------------------------------- 1 | import { QueryResolvers } from "graphql-api/server-types.gen"; 2 | import { MinimalUser } from "./user"; 3 | 4 | const loggedInUser: QueryResolvers.LoggedInUserResolver< 5 | Promise 6 | > = async (parent, args, context, info) => { 7 | return await context.getCurrentUser(); 8 | }; 9 | 10 | export default { 11 | loggedInUser, 12 | }; 13 | -------------------------------------------------------------------------------- /modules/graphql-api/resolvers/user.ts: -------------------------------------------------------------------------------- 1 | import { User } from "client/graphql/types.gen"; 2 | 3 | export type MinimalUser = { 4 | id: User["id"]; 5 | firstName: User["firstName"]; 6 | lastName: User["lastName"]; 7 | }; 8 | -------------------------------------------------------------------------------- /modules/graphql-api/schema-base.ts: -------------------------------------------------------------------------------- 1 | import { Query, Mutation } from "client/graphql/types.gen"; 2 | 3 | export const rawSchema = require("./schema.graphql"); 4 | 5 | /** Shape of high level schema types. Used to type check mock apollo client arguments */ 6 | export interface SchemaMap { 7 | Query: Query; 8 | Mutation: Mutation; 9 | } 10 | -------------------------------------------------------------------------------- /modules/graphql-api/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar IsoDate 2 | scalar IsoTime 3 | 4 | type Query { 5 | loggedInUser: User! 6 | } 7 | 8 | type User { 9 | id: Int! 10 | firstName: String! 11 | lastName: String! 12 | } 13 | 14 | type Mutation { 15 | exampleMutation: String 16 | } 17 | -------------------------------------------------------------------------------- /modules/helpers/assert-assignable.ts: -------------------------------------------------------------------------------- 1 | /** statically assert that the second type is assignable to the first type. */ 2 | 3 | /* tslint:disable */ 4 | export interface AssertAssignable {} 5 | /* tslint:enable */ 6 | -------------------------------------------------------------------------------- /modules/helpers/dataloader.ts: -------------------------------------------------------------------------------- 1 | import * as DataLoader from "dataloader"; 2 | export async function uncachedLoad( 3 | dataloader: DataLoader, 4 | key: K 5 | ): Promise { 6 | dataloader.clear(key); 7 | return dataloader.load(key); 8 | } 9 | -------------------------------------------------------------------------------- /modules/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "@atomic-object/lenses"; 2 | 3 | export { AssertAssignable } from "./assert-assignable"; 4 | 5 | /** Used by Flavor to mark a type in a readable way. */ 6 | export interface Flavoring { 7 | _type?: FlavorT; 8 | } 9 | /** Create a "flavored" version of a type. TypeScript will disallow mixing flavors, but will allow unflavored values of that type to be passed in where a flavored version is expected. This is a less restrictive form of branding. */ 10 | export type Flavor = T & Flavoring; 11 | 12 | /** Used by Brand to mark a type in a readable way. */ 13 | export interface Branding { 14 | _type: BrandT; 15 | } 16 | /** Create a "flavored" version of a type. TypeScript will disallow mixing flavors, but will allow unflavored values of that type to be passed in where a flavored version is expected. This is a less restrictive form of branding. */ 17 | export type Brand = T & Branding; 18 | 19 | export function sleep(ms: number) { 20 | return new Promise(resolve => { 21 | setTimeout(resolve, ms); 22 | }); 23 | } 24 | 25 | export async function testWait() { 26 | await sleep(0); 27 | await sleep(0); 28 | } 29 | 30 | export function targetReducer( 31 | reducer: (arg: U, action: any) => U, 32 | lens: Lens 33 | ): (arg: T, action: any) => T { 34 | return (arg: T, action: any) => lens.set(arg, reducer(lens.get(arg), action)); 35 | } 36 | 37 | let bombSuppressions = 0; 38 | export function suppressBomb(fn: () => void) { 39 | bombSuppressions += 1; 40 | try { 41 | fn(); 42 | } finally { 43 | bombSuppressions -= 1; 44 | } 45 | } 46 | 47 | export function bomb(errorMessage: string, error?: Error): never { 48 | if (bombSuppressions === 0) { 49 | console.error(errorMessage, error || new Error()); 50 | } 51 | throw error || new Error(errorMessage); 52 | } 53 | 54 | export type Omit = Pick>; 55 | -------------------------------------------------------------------------------- /modules/helpers/json.ts: -------------------------------------------------------------------------------- 1 | export interface JsonMap { 2 | [member: string]: string | number | boolean | null | JsonArray | JsonMap; 3 | } 4 | export interface JsonArray 5 | extends Array {} 6 | 7 | export type Json = JsonMap | JsonArray | string | number | boolean | null; 8 | -------------------------------------------------------------------------------- /modules/records/__tests__/event-log.test.ts: -------------------------------------------------------------------------------- 1 | import { withContext } from "__tests__/db-helpers"; 2 | 3 | describe("EventLogRepository", () => { 4 | describe("insert", () => { 5 | it( 6 | "works", 7 | withContext(async ctx => { 8 | const result = await ctx.repos.eventLog.insert({ 9 | type: "foo", 10 | payload: { userId: 1 }, 11 | effect: null, 12 | }); 13 | expect(result.timestamp).toBeInstanceOf(Date); 14 | expect(result.index).toMatch(/^\d+$/); 15 | expect(result.type).toEqual("foo"); 16 | expect(result.payload).toEqual({ userId: 1 }); 17 | }) 18 | ); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /modules/records/event-log.ts: -------------------------------------------------------------------------------- 1 | import { Json, JsonMap } from "helpers/json"; 2 | import { Knex } from "db"; 3 | import { Repositories } from "records"; 4 | import { Action, ActionObjectTypes } from "atomic-object/cqrs/actions"; 5 | 6 | export interface UnsavedEventLog { 7 | type: string; 8 | payload: JsonMap; 9 | effect: JsonMap | null; 10 | } 11 | 12 | export interface SavedEventLog extends UnsavedEventLog { 13 | timestamp: string; 14 | index: string; 15 | } 16 | 17 | export type EventLogsForAction = ActionObjectTypes< 18 | TActions 19 | > & { 20 | effect: JsonMap | null; 21 | }; 22 | 23 | export class EventLogRepository { 24 | private db: Knex; 25 | 26 | constructor(private repos: Repositories, db?: Knex) { 27 | this.db = db || repos.pg; 28 | } 29 | 30 | table() { 31 | return this.db.table("EventLog"); 32 | } 33 | 34 | async insert(unsaved: UnsavedEventLog): Promise { 35 | const [result] = await this.table().insert(unsaved, ["timestamp", "index"]); 36 | const returning = { ...result, ...unsaved }; 37 | return returning; 38 | } 39 | 40 | async allWithType( 41 | action: Action 42 | ): Promise>[]> { 43 | return await this.table().where({ type: action.type }); 44 | } 45 | 46 | async count(): Promise { 47 | return parseInt((await this.table().count())[0].count); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /modules/records/impl/base.ts: -------------------------------------------------------------------------------- 1 | import { RecordInfo, UnboundRepositoryBase } from "atomic-object/records"; 2 | import { Repositories } from "records"; 3 | 4 | export function RepositoryBase( 5 | recordType: RecordInfo 6 | ) { 7 | return UnboundRepositoryBase(recordType); 8 | } 9 | -------------------------------------------------------------------------------- /modules/records/impl/core.ts: -------------------------------------------------------------------------------- 1 | import { recordInfo } from "atomic-object/records"; 2 | import { SavedUser, UnsavedUser } from "../user"; 3 | 4 | export const UserRecord = recordInfo("User"); 5 | -------------------------------------------------------------------------------- /modules/records/index.ts: -------------------------------------------------------------------------------- 1 | import { RepositoriesBase } from "atomic-object/records"; 2 | import { EventLogRepository } from "./event-log"; 3 | import { UserRepository } from "./user"; 4 | 5 | export class Repositories extends RepositoriesBase { 6 | users = new UserRepository(this); 7 | eventLog = new EventLogRepository(this); 8 | } 9 | -------------------------------------------------------------------------------- /modules/records/user.ts: -------------------------------------------------------------------------------- 1 | import { Flavor } from "helpers"; 2 | import { loaderOf, Knex } from "atomic-object/records"; 3 | import { RepositoryBase } from "records/impl/base"; 4 | import { UserRecord } from "records/impl/core"; 5 | 6 | export type UserId = Flavor; 7 | 8 | export interface UnsavedUser { 9 | firstName: string; 10 | lastName: string; 11 | } 12 | export interface SavedUser extends UnsavedUser { 13 | id: UserId; 14 | } 15 | 16 | export class UserRepository extends RepositoryBase(UserRecord) {} 17 | -------------------------------------------------------------------------------- /modules/server/authentication.ts: -------------------------------------------------------------------------------- 1 | import * as AuthRoutes from "client/routes/authentication-routes"; 2 | import * as config from "config"; 3 | import { Context } from "graphql-api/context"; 4 | import { buildContext } from "./context"; 5 | 6 | const baseUrl = `${config.get("server.protocol")}://${config.get< 7 | string 8 | >("server.publicHost")}`; 9 | const clientSamlUrl = baseUrl + AuthRoutes.SAML_CALLBACK; 10 | const clientSamlLogoutCallback = baseUrl + AuthRoutes.SAML_LOGOUT_CALLBACK; 11 | 12 | export function createContext(req: any, res: any, next: any) { 13 | if (req.user) { 14 | const context = buildContext({ userId: req!.user.userId }); 15 | req.context = context; 16 | } else { 17 | req.context = new Context(); 18 | } 19 | return next(); 20 | } 21 | 22 | export async function ensureAuthenticatedAndSetStatus( 23 | req: any, 24 | res: any, 25 | next: any 26 | ) { 27 | try { 28 | if (req.isAuthenticated()) { 29 | const user = await req.context!.repos.users.findById.load( 30 | req!.user.userId 31 | ); 32 | if (!user) { 33 | req.logout(); 34 | res.status(403); 35 | return res.send({ 36 | error: "User authenticated but does not exist in database", 37 | }); 38 | } 39 | 40 | return next(); 41 | } else { 42 | res.status(403); 43 | res.send({ error: "User not authenticated." }); 44 | } 45 | } catch { 46 | try { 47 | req.logout(); 48 | } catch { 49 | res.status(403); 50 | return res.send({ 51 | error: "Cannot logout", 52 | }); 53 | } 54 | res.status(403); 55 | return res.send({ 56 | error: "Unknown Error", 57 | }); 58 | } 59 | } 60 | 61 | export function ensureAuthenticatedAndRedirect(req: any, res: any, next: any) { 62 | if (req.isAuthenticated()) { 63 | // req.user is available for use here 64 | return next(); 65 | } 66 | // No user, redirect to login. 67 | return res.redirect(AuthRoutes.LOGIN); 68 | } 69 | 70 | export interface UserSession { 71 | userId: number; 72 | } 73 | -------------------------------------------------------------------------------- /modules/server/context.ts: -------------------------------------------------------------------------------- 1 | import { JobRunner } from "atomic-object/jobs/mapping"; 2 | import { Context, ContextOpts } from "graphql-api/context"; 3 | import * as config from "config"; 4 | 5 | const jobRunner = new JobRunner(config.get("redis.prefix")); 6 | 7 | export function buildContext(opts: ContextOpts = {}) { 8 | return new Context({ 9 | jobs: jobRunner, 10 | ...opts, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /modules/server/core/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from "express-serve-static-core"; 2 | import * as config from "config"; 3 | const API_KEY = config.get("server.apiKey"); 4 | export function handleExceptions(fn: core.RequestHandler): core.RequestHandler { 5 | return async ( 6 | req: core.Request, 7 | res: core.Response, 8 | next: core.NextFunction 9 | ) => { 10 | try { 11 | return await fn(req, res, next); 12 | } catch (e) { 13 | res.status(422); 14 | // todo: rollbar 15 | res.send(`error! ${e}`); 16 | } 17 | }; 18 | } 19 | 20 | export function protectWithApiKey(req: any, res: any, next: any) { 21 | const apiKeyHeader = req.header("ApiKey"); 22 | if (apiKeyHeader === undefined) { 23 | res.status(401).send("API key required."); 24 | } else if (apiKeyHeader != API_KEY) { 25 | res.status(401).send("Bad API key."); 26 | } else if (apiKeyHeader == API_KEY) { 27 | next(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /modules/server/index.ts: -------------------------------------------------------------------------------- 1 | import * as ErrorNotifier from "atomic-object/error-notifier"; 2 | import * as Logger from "atomic-object/logger"; 3 | import * as bodyParser from "body-parser"; 4 | import * as AuthRoutes from "client/routes/authentication-routes"; 5 | import * as compression from "compression"; 6 | import * as config from "config"; 7 | import * as express from "express"; 8 | import { formatError, GraphQLError } from "graphql"; 9 | import { executableSchema } from "graphql-api"; 10 | import { graphiqlExpress, graphqlExpress } from "graphql-server-express"; 11 | import { sortBy } from "lodash-es"; 12 | import * as morgan from "morgan"; 13 | import * as passport from "passport"; 14 | import { SavedUser } from "records/user"; 15 | import { migrateAndSeed } from "../../db/migrate-and-seed"; 16 | import * as db from "../db"; 17 | import { buildContext } from "./context"; 18 | import { enforcePasswordIfSpecified } from "./middleware"; 19 | import * as Authentication from "./authentication"; 20 | 21 | ErrorNotifier.setup(config.get("rollbar.serverAccessToken")); 22 | 23 | const Arena = require("bull-arena"); 24 | const knex = db.getConnection(); 25 | const knexLogger = require("knex-logger"); 26 | const enforce = require("express-sslify"); 27 | const expressStaticGzip = require("express-static-gzip"); 28 | const cookieSession = require("cookie-session"); 29 | 30 | let app = express(); 31 | 32 | export const port = config.get("server.port"); 33 | 34 | export const enableDeveloperLogin = config.get( 35 | "server.enableDeveloperLogin" 36 | ); 37 | 38 | export function startServer() { 39 | app.use(bodyParser.json()); 40 | app.use( 41 | bodyParser.urlencoded({ 42 | extended: true, 43 | }) 44 | ); 45 | 46 | app.use( 47 | cookieSession({ 48 | name: "session", 49 | secret: config.get("server.secret"), 50 | }) 51 | ); 52 | 53 | // Logging 54 | app.use(morgan("short")); 55 | app.use(knexLogger(knex)); 56 | 57 | // Force SSL. 58 | if (config.get("server.requireSsl")) { 59 | app.use( 60 | enforce.HTTPS({ 61 | trustProtoHeader: true, 62 | }) 63 | ); 64 | } 65 | 66 | if (config.get("server.basicAuthPassword")) { 67 | app.use(enforcePasswordIfSpecified(config.get("server.basicAuthPassword"))); 68 | } 69 | 70 | // Gzip support 71 | app.use(compression()); 72 | 73 | // app.use(passport.initialize()); 74 | // app.use(passport.session()); 75 | 76 | app.use( 77 | "/arena", 78 | new Arena( 79 | { 80 | queues: [ 81 | { 82 | name: "main", 83 | prefix: config.get("redis.prefix"), 84 | hostId: "redis", 85 | redis: config.get("redis.url"), 86 | }, 87 | ], 88 | }, 89 | { 90 | // Make the arena dashboard become available at {my-site.com}/arena. 91 | // basePath: "/arena", 92 | 93 | // Let express handle the listening. 94 | disableListen: true, 95 | } 96 | ) 97 | ); 98 | 99 | app.get(AuthRoutes.USER_NOT_FOUND, (req, res) => { 100 | res.sendFile(process.cwd() + "/dist/index.html"); 101 | }); 102 | 103 | // GraphQL 104 | app.use( 105 | "/graphql", 106 | bodyParser.json(), 107 | Authentication.createContext, 108 | // Authentication.ensureAuthenticatedAndSetStatus, 109 | graphqlExpress((req, res) => { 110 | return { 111 | schema: executableSchema, 112 | context: req!.context, 113 | formatError: (e: GraphQLError) => { 114 | Logger.error(e); 115 | return formatError(e); 116 | }, 117 | }; 118 | }) 119 | ); 120 | 121 | if (config.get("server.graphiql")) { 122 | // GraphQL development web IDE 123 | app.use( 124 | "/graphiql", 125 | graphiqlExpress({ 126 | endpointURL: "/graphql", 127 | }) 128 | ); 129 | } 130 | 131 | // Static assets 132 | app.use(expressStaticGzip("./dist/")); 133 | app.use(express.static("./dist/")); 134 | 135 | // Serve index.html for all unknown URLs 136 | app.get( 137 | "/*", 138 | // Authentication.ensureAuthenticatedAndRedirect, 139 | function(req, res) { 140 | res.sendFile(process.cwd() + "/dist/index.html"); 141 | } 142 | ); 143 | 144 | return app.listen(port, () => { 145 | console.log("up and running on port", port); 146 | }); 147 | } 148 | -------------------------------------------------------------------------------- /modules/server/middleware.ts: -------------------------------------------------------------------------------- 1 | const basicAuth = require("express-basic-auth"); 2 | 3 | export const enforcePasswordIfSpecified = (password: string) => 4 | basicAuth({ 5 | challenge: true, 6 | users: { 7 | "": password, 8 | admin: password, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /modules/services/core/index.ts: -------------------------------------------------------------------------------- 1 | import { Repositories } from "records"; 2 | import { UserId } from "records/user"; 3 | import { Context } from "graphql-api/context"; 4 | 5 | export type ServiceContext = Pick; 6 | 7 | /** Used by Brand to mark a type in a readable way. */ 8 | export interface PermissionMark { 9 | _permission: PermissionT; 10 | } 11 | /** Create a "flavored" version of a type. TypeScript will disallow mixing flavors, but will allow unflavored values of that type to be passed in where a flavored version is expected. This is a less restrictive form of branding. */ 12 | export type Permission = { 13 | payload: T; 14 | userId: UserId; 15 | } & PermissionMark; 16 | -------------------------------------------------------------------------------- /modules/services/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Actions, 3 | UnwrapActions, 4 | ActionsObjectTypes, 5 | } from "atomic-object/cqrs/actions"; 6 | import { Dispatcher } from "atomic-object/cqrs/dispatch"; 7 | 8 | /** Aggregate of all actions in the system. Only these actions can be dispatched. */ 9 | export const ALL_SERVICE_ACTIONS = new Actions(); 10 | 11 | /** Aggregate of all background jobs in the system - used to set up job processing */ 12 | export const ALL_JOBS = ALL_SERVICE_ACTIONS.backgroundJobs; 13 | 14 | export type GlobalActions = ActionsObjectTypes; 15 | export type GlobalDispatch = Dispatcher< 16 | UnwrapActions 17 | >; 18 | -------------------------------------------------------------------------------- /modules/services/permissions/index.ts: -------------------------------------------------------------------------------- 1 | import { AssertAssignable } from "helpers"; 2 | import { UserId } from "records/user"; 3 | import { ServiceContext } from "services/core"; 4 | -------------------------------------------------------------------------------- /modules/services/permissions/permissions.test.ts: -------------------------------------------------------------------------------- 1 | import { withContext } from "__tests__/db-helpers"; 2 | import * as Blueprint from "blueprints"; 3 | 4 | describe("Permissions", () => { 5 | it.todo("will eventually check permissions"); 6 | }); 7 | -------------------------------------------------------------------------------- /package.md: -------------------------------------------------------------------------------- 1 | ## Package.json notes 2 | 3 | Because you can't put comments in JSON. 😐 4 | 5 | ### Packages 6 | 7 | - `jest-junit` - for test reporting in CI https://circleci.com/docs/2.0/collect-test-data/#jest 8 | 9 | - `react-apollo` - this is our GraphQL client. 10 | 11 | - `react-apollo-hooks` 12 | 13 | Unfortunately react-apollo doesn't yet provide a hooks API: https://github.com/apollographql/react-apollo/issues/2539 . 14 | 15 | This is a third-party lib that does. 16 | 17 | #### Notes for future upgrades 18 | 19 | - `apollo-link-state` - upgrading 0.4.1 -> 0.4.2 causes this: 20 | 21 | ``` 22 | [build:client] ERROR in ./node_modules/graphql/index.mjs 64:0-98:42 23 | [build:client] Can't reexport the named export 'valueFromASTUntyped' from non EcmaScript module (only default export is available) 24 | [build:client] @ ./node_modules/apollo-link-state/lib/utils.js 25 | [build:client] @ ./node_modules/apollo-link-state/lib/index.js 26 | [build:client] @ ./modules/client/graphql/state-link.ts 27 | [build:client] @ ./modules/client/graphql/client.ts 28 | [build:client] @ ./entry/client.tsx 29 | [build:client] @ multi whatwg-fetch core-js/es6/object core-js/es6/array core-js/es6/symbol core-js/es6/promise core-js/es6/map core-js/es6/set ./entry/client.tsx 30 | [build:client] 31 | ``` 32 | 33 | - `knex` - knex 0.16.3 is [unable](https://github.com/tgriesser/knex/issues/3003) to automatically discover `knexfile.ts` the way previous versions could. For now we're using --knexfile all over the place; hopefully that can go away in some future version. 34 | 35 | - `react-dom` - React 16.8.0 is the stable release that supports hooks. The main react package upgraded uneventfully, but react-dom has a few problems with enzyme. Keep an eye [this issue](https://github.com/airbnb/enzyme/issues/2011). 36 | 37 | ### Scripts 38 | 39 | - `test:unit:ci` - Without specifying --maxWorkers, jest will automatically scales out to the number of hardware threads available. CircleCI is reporting the wrong number of hardware threads (os.cpus().length() in node) -- 36 instead of the ~2 we get from docker. So, for now (until we control concurrency separately across multiple Circle containers), we're specifying --maxWorkers to some number that won't starve for resources in CI. 40 | -------------------------------------------------------------------------------- /scripts/codegen-type-constants.js: -------------------------------------------------------------------------------- 1 | const camelCase = require("camelcase"); 2 | 3 | module.exports = { 4 | plugin: (schema, documents, config) => { 5 | return documents 6 | .map(doc => { 7 | if (!doc.content.definitions) return JSON.stringify(doc, null, 2); 8 | const docsNames = doc.content.definitions.map(def => def.name.value); 9 | 10 | // return `File ${doc.filePath} contains: ${docsNames.join(", ")}`; 11 | return doc.content.definitions.map(def => { 12 | try { 13 | let name = camelCase(def.name.value); 14 | name = name.charAt(0).toUpperCase() + name.slice(1); 15 | if (def.kind == "OperationDefinition") { 16 | return ` 17 | export namespace ${name} { 18 | export const _variables : Variables = null as any; 19 | export const _result : ${ 20 | def.operation == "query" ? "Query" : "Mutation" 21 | } = null as any; 22 | }` 23 | // export const _doc = ${JSON.stringify(def, null, 2)}; 24 | } else { 25 | return "" 26 | }; 27 | } catch (e) { 28 | return `Bad name: ${def.name.value}`; 29 | } 30 | }); 31 | }) 32 | .join("\n"); 33 | }, 34 | }; -------------------------------------------------------------------------------- /scripts/json-schema-types.js: -------------------------------------------------------------------------------- 1 | const cbglob = require("glob"); 2 | const { compileFromFile } = require("json-schema-to-typescript"); 3 | const fs = require("fs"); 4 | const util = require("util"); 5 | const path = require("path"); 6 | 7 | const glob = util.promisify(cbglob); 8 | const writeFile = util.promisify(fs.writeFile); 9 | 10 | // mks 2019-01-30 - the Ajv schema resolver can get the 'types' core type 11 | // definitions added to it directly. However this generator needs a bit more 12 | // help: this simplistic resolver will turn all 'int:' URLs to simply the 13 | // types.schema.json file. 14 | const internalResolver = { 15 | order: 1, 16 | canRead: file => { 17 | const isInternal = file.url.indexOf("int:") == 0; 18 | return isInternal; 19 | }, 20 | read: function(file, callback) { 21 | const fileBase = file.url.match(/int:([\w_-]+)/); 22 | if (!fileBase) { 23 | callback( 24 | new Error( 25 | `Something went wrong generating internal types for ${file.url}` 26 | ) 27 | ); 28 | return; 29 | } 30 | const base = fileBase[1]; 31 | fs.readFile( 32 | `./modules/core/schemas/${base}.schema.json`, 33 | "utf8", 34 | (err, data) => callback(err, data) 35 | ); 36 | }, 37 | }; 38 | 39 | (async () => { 40 | const files = await glob("./modules/**/*.schema.json"); 41 | const refParserOptions = { 42 | resolve: { 43 | file: { 44 | order: 50, 45 | }, 46 | http: { 47 | order: 100, 48 | }, 49 | internal: internalResolver, 50 | }, 51 | }; 52 | const promises = files.map(async schemaFile => { 53 | let code = await compileFromFile(schemaFile, { 54 | unreachableDefinitions: true, 55 | $refOptions: refParserOptions, 56 | }); 57 | code = code.replace(/\s*\[\w+:\s*string\]:.*/g, ""); 58 | 59 | const customTypeImports = ` 60 | import * as DateIso from "core/date-iso"; 61 | import * as TimeIso from "core/time-iso"; 62 | `; 63 | code = customTypeImports + code; 64 | 65 | const tsFile = schemaFile.replace(/\.schema.json$/, ".gen.ts"); 66 | await writeFile(tsFile, code); 67 | console.log(path.basename(schemaFile)); 68 | }); 69 | 70 | try { 71 | await Promise.all(promises); 72 | } catch (e) { 73 | console.log(e); 74 | } 75 | })(); 76 | -------------------------------------------------------------------------------- /styleguide.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const _ = require('lodash') 3 | const config = require('config') 4 | 5 | module.exports = { 6 | components: "modules/client/components/**/*.tsx", 7 | webpackConfig: _.merge({}, 8 | require('./webpack/client.config.js'), 9 | { 10 | devServer: { 11 | disableHostCheck: config.get('devServer.disableHostCheck'), 12 | } 13 | } 14 | ), 15 | propsParser: require('react-docgen-typescript').parse, 16 | require: [ 17 | './modules/client/styles/main.scss', 18 | ] 19 | }; -------------------------------------------------------------------------------- /tests/acceptance/example.test.ts: -------------------------------------------------------------------------------- 1 | describe("Acceptance test", () => { 2 | it("should do something", () => { 3 | // use nightmare here... 4 | expect(true).toBeTruthy(); 5 | }); 6 | }); 7 | -------------------------------------------------------------------------------- /tests/acceptance/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as Nightmare from "nightmare"; 2 | 3 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; 4 | 5 | export function withBrowser( 6 | func: (b: Nightmare) => Promise 7 | ): () => Promise { 8 | return async () => { 9 | try { 10 | const b = new Nightmare({ show: false }); 11 | 12 | try { 13 | await func(b); 14 | } finally { 15 | await b.end(); 16 | } 17 | } catch (e) { 18 | console.error("Got an error", e); 19 | throw e; 20 | } 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./modules", 5 | "jsx": "react", 6 | "lib": ["dom", "es6", "es2015", "es2016", "es2017", "es2018"], 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "rootDir": ".", 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true 16 | }, 17 | "exclude": ["./dist", "./es"] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["dom", "es6", "es2015", "esnext.asynciterable"], 6 | "downlevelIteration": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["dom", "es2018", "esnext.asynciterable"], 5 | "module": "commonjs", 6 | "target": "es2018", 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "lib": ["dom", "es6", "es2015", "esnext.asynciterable"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-eslint-rules", 3 | "rules": { 4 | "block-spacing": [true, "always"], 5 | "brace-style": [true, "1tbs"], 6 | "curly": [true, "ignore-same-line"], 7 | "handle-callback-err": true, 8 | "import-blacklist": [true, "lodash"], 9 | "indent": [true, "spaces"], 10 | "interface-name": [false], 11 | "no-consecutive-blank-lines": [true], 12 | "no-console": [false], 13 | "no-constant-condition": false, 14 | "no-floating-promises": [true, "Bluebird"], 15 | "no-irregular-whitespace": true, 16 | "no-multi-spaces": true, 17 | "no-var-requires": false, 18 | "object-literal-sort-keys": false, 19 | "ordered-imports": [false], 20 | "ter-indent": ["error", 2], 21 | "ban": [ 22 | true, 23 | { 24 | "name": "fit", 25 | "message": "don't focus tests" 26 | } 27 | ] 28 | }, 29 | "jsRules": { 30 | "curly": true 31 | }, 32 | "rulesDirectory": [] 33 | } -------------------------------------------------------------------------------- /webpack/loaders.js: -------------------------------------------------------------------------------- 1 | const config = require("config"); 2 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 3 | const path = require("path"); 4 | 5 | module.exports = { 6 | typescript: { 7 | test: /\.tsx?/, 8 | use: [{ 9 | // loader: "happypack/loader?id=ts" 10 | loader: "ts-loader", 11 | options: { 12 | // https://webpack.js.org/guides/build-performance/#typescript-loader 13 | transpileOnly: true, 14 | experimentalWatchApi: true, 15 | configFile: "tsconfig.server.json", 16 | }, 17 | }, ], 18 | }, 19 | 20 | clientSideTypeScript: { 21 | test: /\.tsx?/, 22 | use: [{ 23 | // loader: "happypack/loader?id=ts", 24 | loader: "ts-loader", 25 | options: { 26 | // https://webpack.js.org/guides/build-performance/#typescript-loader 27 | transpileOnly: true, 28 | experimentalWatchApi: true, 29 | configFile: "tsconfig.client.json", 30 | }, 31 | }, ], 32 | }, 33 | 34 | graphql: { 35 | test: /\.(graphql|gql)$/, 36 | exclude: /node_modules/, 37 | loader: "graphql-tag/loader", 38 | }, 39 | 40 | mjs: { 41 | test: /\.mjs$/, 42 | include: /node_modules/, 43 | type: "javascript/auto", 44 | }, 45 | 46 | scss: { 47 | test: /\.css$/, 48 | use: ExtractTextPlugin.extract({ 49 | fallback: "style-loader", 50 | allChunks: true, 51 | use: [{ 52 | loader: "css-loader" 53 | }, 54 | { 55 | loader: "postcss-loader", 56 | options: { 57 | plugins: [ 58 | ...(config.get("minify") ? [ 59 | require("cssnano")({ 60 | safe: true, 61 | sourcemap: true, 62 | autoprefixer: false, 63 | }), 64 | ] : []), 65 | require("autoprefixer"), 66 | ], 67 | }, 68 | }, 69 | ], 70 | }), 71 | }, 72 | 73 | allImagesAndFontsArray: [ 74 | // cache bust images, but embed small ones as data URIs 75 | { 76 | test: /\.(png|jpg|jpeg|gif)$/, 77 | use: [{ 78 | loader: "url-loader", 79 | query: { 80 | prefix: "img/", 81 | name: "assets/[hash].[ext]", 82 | limit: 5000, 83 | }, 84 | }, ], 85 | }, 86 | 87 | // cache bust svgs 88 | { 89 | test: /\.svg?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 90 | use: [{ 91 | // loader: "file-loader", 92 | loader: "url-loader", 93 | query: { 94 | limit: 7500, 95 | name: "assets/[hash].[ext]", 96 | }, 97 | }, ], 98 | }, 99 | 100 | // cache bust fonts 101 | { 102 | test: /\.(ttf|eot)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 103 | use: [{ 104 | loader: "file-loader", 105 | query: { 106 | name: "fonts/[hash].[ext]", 107 | }, 108 | }, ], 109 | }, 110 | 111 | // Cache bust or data-uri web fonts 112 | { 113 | test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 114 | use: [{ 115 | loader: "url-loader", 116 | query: { 117 | limit: 50000, 118 | mimetype: "application/font-woff", 119 | name: "fonts/[hash].[ext]", 120 | }, 121 | }, ], 122 | }, 123 | ], 124 | }; -------------------------------------------------------------------------------- /webpack/server.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const nodeExternals = require("webpack-node-externals"); 3 | const webpack = require("webpack"); 4 | const loaders = require("./loaders"); 5 | const fs = require("fs"); 6 | 7 | var HappyPack = require("happypack"); 8 | var ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); 9 | const os = require("os"); 10 | 11 | const scriptsDir = path.join(__dirname, "../entry/scripts"); 12 | 13 | /** A map of of entry points for every file in scripts */ 14 | const scriptEntry = fs 15 | .readdirSync(scriptsDir) 16 | .filter(f => /\.tsx?$/.test(f)) 17 | .filter(f => fs.statSync(path.join(scriptsDir, f)).isFile()) 18 | .reduce((o, f) => { 19 | o[`scripts/${f.replace(/\.tsx?$/, "")}`] = path.resolve( 20 | path.join(scriptsDir, f) 21 | ); 22 | return o; 23 | }, {}); 24 | 25 | const entry = Object.assign( 26 | { 27 | server: "./entry/server.ts", 28 | }, 29 | scriptEntry 30 | ); 31 | console.log(entry); 32 | 33 | module.exports = { 34 | entry: entry, 35 | // Never minify the server 36 | mode: "development", 37 | target: "node", 38 | 39 | //devtool: "source-map", 40 | devtool: "inline-source-map", 41 | optimization: { 42 | // Don't turn process.env.NODE_ENV into a compile-time constant 43 | nodeEnv: false, 44 | }, 45 | context: `${__dirname}/../`, 46 | 47 | target: "node", 48 | node: { 49 | __dirname: false, 50 | }, 51 | output: { 52 | path: path.resolve(__dirname, "../dist"), 53 | filename: "[name].js", 54 | devtoolModuleFilenameTemplate: "[absolute-resource-path]", 55 | libraryTarget: "commonjs2", 56 | }, 57 | 58 | resolve: { 59 | extensions: [".ts", ".tsx", ".js"], 60 | modules: [path.resolve(__dirname, "../modules"), "node_modules"], 61 | }, 62 | 63 | externals: [ 64 | nodeExternals({ 65 | whitelist: [/^lodash-es/], 66 | }), 67 | ], 68 | module: { 69 | rules: [loaders.typescript, loaders.graphql], 70 | }, 71 | 72 | // https://github.com/TypeStrong/ts-loader#transpileonly-boolean-defaultfalseO 73 | stats: { 74 | warningsFilter: /export .* was not found in/, 75 | }, 76 | 77 | plugins: [ 78 | new webpack.BannerPlugin({ 79 | banner: 'require("source-map-support").install();', 80 | raw: true, 81 | entryOnly: false, 82 | }), 83 | 84 | new webpack.DefinePlugin({ 85 | __TEST__: "false", 86 | __DEV__: JSON.stringify(process.env.NODE_ENV !== "production"), 87 | }), 88 | 89 | // new webpack.debug.ProfilingPlugin({ 90 | // outputPath: "server-build.json" 91 | // }), 92 | 93 | // new HappyPack({ 94 | // id: "ts", 95 | // threads: process.env.CI ? 1 : Math.max(1, os.cpus().length / 2 - 1), 96 | // loaders: [ 97 | // { 98 | // path: "ts-loader", 99 | // query: { happyPackMode: true, configFile: "tsconfig.json" }, 100 | // }, 101 | // ], 102 | // }), 103 | new ForkTsCheckerWebpackPlugin({ 104 | // https://github.com/Realytics/fork-ts-checker-webpack-plugin#options 105 | useTypescriptIncrementalApi: true, 106 | }), 107 | ], 108 | }; 109 | -------------------------------------------------------------------------------- /webpack/storybook.config.js: -------------------------------------------------------------------------------- 1 | .storybook/webpack.config.js -------------------------------------------------------------------------------- /webpack/webpack-dev-server.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const WebpackDevServer = require("webpack-dev-server"); 3 | const webpackConfig = require("./client.config"); 4 | const config = require("config"); 5 | 6 | const { defaults } = require("lodash"); 7 | 8 | let devServer; 9 | const DEV_PORT = config.get("devServer.port"); 10 | 11 | const PROXY_HOST = config.get("server.apiHost"); 12 | 13 | const startDevServer = () => { 14 | if (devServer) { 15 | return Promise.resolve(devServer); 16 | } 17 | 18 | const { hot, inline, noInfo } = defaults(config.get("devServer"), { 19 | hot: true, 20 | inline: true, 21 | noInfo: true, 22 | }); 23 | 24 | // Set up hot reloading entry points. 25 | webpackConfig.plugins.unshift(new webpack.HotModuleReplacementPlugin()); 26 | webpackConfig.entry.app.unshift( 27 | `webpack-dev-server/client?http://localhost:${DEV_PORT}/`, 28 | "webpack/hot/dev-server" 29 | ); 30 | return new Promise((resolve, reject) => { 31 | devServer = new WebpackDevServer(webpack(webpackConfig), { 32 | publicPath: webpackConfig.output.publicPath, 33 | hot: hot, 34 | inline: inline, 35 | historyApiFallback: true, 36 | noInfo: noInfo, 37 | stats: "errors-only", 38 | disableHostCheck: config.get("devServer.disableHostCheck"), 39 | proxy: { 40 | "/graphql/*": `http://${PROXY_HOST}`, 41 | "/graphiql/*": `http://${PROXY_HOST}`, 42 | }, 43 | }); 44 | 45 | setTimeout(function() {}, 10000); 46 | 47 | devServer.listen(DEV_PORT, err => { 48 | if (err) { 49 | console.error(err); 50 | reject(err); 51 | } 52 | console.log(`WDS: Listening on port ${DEV_PORT}`); 53 | resolve(); 54 | }); 55 | }); 56 | }; 57 | 58 | const stopDevServer = () => { 59 | devServer.close(); 60 | return Promise.resolve(); 61 | }; 62 | 63 | module.exports = { startDevServer, stopDevServer }; 64 | -------------------------------------------------------------------------------- /yarn-bash-completion: -------------------------------------------------------------------------------- 1 | _yarn() { 2 | local cur prev opts base 3 | _get_comp_words_by_ref -n : cur 4 | cur=${COMP_WORDS[COMP_CWORD]} 5 | prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | 7 | if [[ "$cur" == -* ]]; then 8 | COMPREPLY=( $(compgen -W "$(yarn -h | grep -- '--' | sed 's/,.*$//' | awk '{print $1}')" -- $cur ) ) 9 | else 10 | COMPREPLY=( $(compgen -W "$(cat package.json | jq -r '.scripts | keys | .[]') $(yarn -h | grep -- '- ' | awk '{print $2}')" -- $cur ) ) 11 | fi 12 | 13 | __ltrim_colon_completions "$cur" 14 | return 0 15 | 16 | } 17 | 18 | if which -s jq; then 19 | complete -F _yarn yarn 20 | else 21 | >&2 echo "command completion for yarn requires jq; try 'brew install jq'" 22 | fi 23 | --------------------------------------------------------------------------------