├── .browserslistrc ├── .circleci └── config.yml ├── .editorconfig ├── .env.default ├── .eslintignore ├── .eslintrc.json ├── .github └── dependabot.yml ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── .nvmrc ├── .sentrycliignore ├── .stylelintrc.json ├── LICENSE ├── README.md ├── babel.config.js ├── init-env.mjs ├── jest.conf.json ├── linter.js ├── package.json ├── postcss.config.js ├── presets ├── assets.mjs ├── babel.mjs ├── devServer.mjs ├── extract-css.mjs ├── i18n.mjs ├── index.mjs ├── postcss.mjs ├── proxy.mjs ├── react.mjs ├── sass.mjs ├── sentry.mjs ├── spa.mjs └── styles.mjs ├── src ├── __mocks__ │ └── fileMock.js ├── app │ ├── App.js │ ├── api.js │ ├── common │ │ ├── forms │ │ │ ├── BaseFieldHOC.js │ │ │ ├── BaseFieldLayout.js │ │ │ ├── FormPanel.js │ │ │ ├── fields.js │ │ │ ├── index.js │ │ │ ├── inputs │ │ │ │ ├── CheckboxInput.js │ │ │ │ ├── CurrencyInput.js │ │ │ │ ├── DateInput.js │ │ │ │ ├── FileInput.js │ │ │ │ ├── NumberInput.js │ │ │ │ ├── RadiosInput.js │ │ │ │ ├── SelectInput.js │ │ │ │ ├── SelectRangeInput.js │ │ │ │ ├── TextAreaInput.js │ │ │ │ └── TextInput.js │ │ │ └── validation │ │ │ │ ├── constants.js │ │ │ │ ├── email.js │ │ │ │ ├── index.js │ │ │ │ ├── required.js │ │ │ │ └── utils │ │ │ │ ├── compose.js │ │ │ │ ├── index.js │ │ │ │ └── mainValidation.js │ │ ├── modals │ │ │ ├── ModalConfirmation.js │ │ │ ├── ModalConfirmationTrigger.js │ │ │ ├── ModalTrigger.js │ │ │ └── ModalWrapper.js │ │ ├── router │ │ │ ├── Link.jsx │ │ │ ├── Prompt.js │ │ │ ├── RouteRecursive.js │ │ │ ├── Router.js │ │ │ ├── RouterConfig.js │ │ │ ├── index.js │ │ │ └── withRouter.js │ │ ├── session │ │ │ ├── CheckAccess.jsx │ │ │ ├── access.js │ │ │ ├── authMiddleware.js │ │ │ └── index.js │ │ └── widgets │ │ │ ├── Loading.jsx │ │ │ └── Wizard.js │ ├── index.js │ ├── init.js │ ├── layouts │ │ ├── AppLayout.jsx │ │ ├── Footer.jsx │ │ ├── Header.jsx │ │ └── layout.scss │ ├── pages │ │ ├── auth │ │ │ ├── index.js │ │ │ ├── login │ │ │ │ ├── Login.js │ │ │ │ ├── LoginView.js │ │ │ │ ├── index.js │ │ │ │ ├── login.scss │ │ │ │ ├── utils │ │ │ │ │ └── validate.js │ │ │ │ └── withLoginResource.js │ │ │ └── routes.js │ │ ├── dashboard │ │ │ ├── Dashboard.jsx │ │ │ ├── DashboardView.jsx │ │ │ ├── index.js │ │ │ └── routes.js │ │ └── fallbacks │ │ │ └── NotFound.jsx │ ├── polyfills.js │ ├── routes.js │ └── store │ │ ├── index.js │ │ └── session.js ├── fonts │ └── Lato │ │ ├── Lato-Bold.woff │ │ ├── Lato-Regular.woff │ │ └── lato.css ├── img │ ├── ds-logo.png │ ├── example.png │ └── icons │ │ ├── ic-create.svg │ │ └── ic-key.svg ├── index.html └── styles │ ├── _bootstrap.scss │ ├── _variables.scss │ └── index.scss ├── test-setup.js ├── webpack.config.mjs └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.25%, last 2 versions, Firefox ESR, not dead 2 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 2 | version: 2 3 | jobs: 4 | build: 5 | docker: 6 | # legacy node 7 | - image: cimg/node:18.18.0 8 | 9 | working_directory: ~/frontend-skeleton 10 | 11 | steps: 12 | - checkout 13 | 14 | # Download and cache dependencies 15 | - restore_cache: 16 | name: Restore Yarn Package Cache 17 | keys: 18 | - v1-dependencies-{{ checksum "yarn.lock" }} 19 | # fallback to using the latest cache if no exact match is found 20 | - v1-dependencies- 21 | 22 | - run: 23 | name: Install Dependencies 24 | command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn 25 | 26 | - save_cache: 27 | name: Save Yarn Package Cache 28 | paths: 29 | - ~/.cache/yarn 30 | key: v1-dependencies-{{ checksum "yarn.lock" }} 31 | 32 | - run: 33 | name: Check Code Style 34 | command: yarn lint 35 | 36 | #- run: 37 | # name: Run Tests 38 | # command: yarn test 39 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [**.js] 8 | indent_style = space 9 | indent_size = 2 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | 13 | [**.min.*,node_modules/**] 14 | indent_style = ignore 15 | trim_trailing_whitespace = false 16 | insert_final_newline = ignore 17 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | # the URL to backend part, without paths 2 | BACKEND_URL=http://localhost:8000 3 | 4 | # where to proxy PROXY paths 5 | # (userful for not SPA, when you need to proxy API but get templates from local backend) 6 | PROXY_URL=$BACKEND_URL 7 | 8 | # server side rendering 9 | # enable to proxy all requests to BACKEND_URL (except frontend assets) 10 | # userful for react SSR or MPA (multi-page applications) 11 | SSR= 12 | 13 | # subdomain feature 14 | MAIN_HOST=localhost 15 | 16 | # api URL include version 17 | API_URL=/api/v1/ 18 | 19 | # sources folder, relative to client root folder 20 | SOURCES_PATH=src 21 | 22 | # distribution folder, relative to client root folder 23 | OUTPUT_PATH=dist 24 | 25 | # pathname for assets used on client-side in CSS urls 26 | PUBLIC_PATH=/assets/ 27 | PUBLIC_URL=$PUBLIC_PATH 28 | 29 | # authorization header name for JWT token 30 | AUTH_HEADER=Authorization 31 | 32 | # port for dev server 33 | DEV_SERVER_PORT=3000 34 | 35 | # you can set 0.0.0.0 here 36 | DEV_SERVER_HOST=127.0.0.1 37 | 38 | # proxy paths for dev server ( note that API_URL will be added automatically to this array ) 39 | PROXY=["${API_URL}", "/static/", "/media/", "/jsi18n/"] 40 | 41 | # key for store redux state in localStorage 42 | STORAGE_KEY=$APP_NAME 43 | 44 | # what to store, set empty or null to store all state 45 | CACHE_STATE_KEYS=["session.data"] 46 | # persisted store information after "Clear" action 47 | CACHE_STATE_PERSIST_KEYS=[] 48 | # default limit for resources with pagination 49 | LIMIT=25 50 | 51 | # Sentry configs 52 | SENTRY_ORG=djangostars 53 | SENTRY_PROJECT=$APP_NAME 54 | SENTRY_AUTH_TOKEN= 55 | SENTRY_DSN= 56 | SENTRY_ENVIRONMENT=dev 57 | SENTRY_URL=https://sentry.djangostars.com/ 58 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | init-env.mjs -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard", 4 | "plugin:react/recommended" 5 | ], 6 | "parser": "@babel/eslint-parser", 7 | "env": { 8 | "browser": true, 9 | "node": true, 10 | "es6": true, 11 | "jest": true 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2024, 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true, 18 | "modules": true, 19 | "globalReturn": true, 20 | "deprecatedImportAssert": true 21 | }, 22 | "babelOptions": { 23 | "presets": ["@babel/preset-react"] 24 | } 25 | }, 26 | "plugins": [ 27 | "react" 28 | ], 29 | "settings": { 30 | "react": { 31 | "version": "16.2", 32 | "createClass": "createClass" 33 | } 34 | }, 35 | "rules": { 36 | "curly": ["error", "all"], 37 | "comma-dangle": ["error", "always-multiline"], 38 | "keyword-spacing": ["error", { "overrides": { 39 | "if": { "after": false }, 40 | "for": { "after": false }, 41 | "while": { "after": false } 42 | }}], 43 | "spaced-comment": ["error", "always", { 44 | "line": { "exceptions": ["/"], "markers": ["/"] }, 45 | "block": { "exceptions": ["*"], "markers": ["/"], "balanced": true } 46 | }], 47 | "space-before-function-paren": ["error", { "anonymous": "never", "named": "never", "asyncArrow": "always" }], 48 | "prefer-promise-reject-errors": ["off"], 49 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 0, "maxBOF": 0 }], 50 | "no-unused-vars": ["error", {"args": "none"}], 51 | "object-curly-spacing": ["error", "always"], 52 | "react/react-in-jsx-scope": "off", 53 | "react/jsx-uses-vars": ["error"], 54 | "react/prop-types": ["error"], 55 | "react/require-default-props": ["error"], 56 | "react/display-name": ["off", { "ignoreTranspilerName": true }], 57 | "jsx-quotes": ["error", "prefer-double"], 58 | "padding-line-between-statements": [ 59 | "warn", 60 | { "blankLine": "always", "prev": "block", "next": "*" }, 61 | { "blankLine": "always", "prev": "block-like", "next": "*" } 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.rdb 2 | *.pyc 3 | *.pyo 4 | *.swo 5 | *.sublime-* 6 | *.swp 7 | settings_local.py 8 | logs/* 9 | .idea/* 10 | .DS_Store 11 | 12 | .sass-cache/ 13 | node_modules 14 | static 15 | static* 16 | .tmp 17 | .imagemin 18 | npm-debug.log 19 | yarn-error.log 20 | 21 | .env 22 | dist/ 23 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "src/**/*.{jsx,js}": [ 3 | "eslint" 4 | ], 5 | "src/**/*.scss": [ 6 | "stylelint --syntax scss" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.18.0 -------------------------------------------------------------------------------- /.sentrycliignore: -------------------------------------------------------------------------------- 1 | src/app/**.test.js 2 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard" 4 | ], 5 | "fix": true, 6 | "formatter": "verbose", 7 | "plugins": [ 8 | "stylelint-scss" 9 | ], 10 | "rules": { 11 | "selector-list-comma-newline-after": "always", 12 | "selector-type-case": "lower", 13 | "at-rule-no-unknown": null, 14 | "scss/at-rule-no-unknown": true, 15 | "declaration-empty-line-before": "never", 16 | "rule-empty-line-before": [ "always-multi-line", { 17 | "except": ["inside-block"], 18 | "ignore": ["after-comment"] 19 | }], 20 | "no-descending-specificity": true, 21 | "declaration-block-no-duplicate-properties": true, 22 | "declaration-colon-space-after": "always-single-line", 23 | "at-rule-whitelist": ["include", "import", "keyframes", "mixin", "extend"], 24 | "string-quotes": "double", 25 | "no-duplicate-selectors": true, 26 | "color-named": "never", 27 | "color-hex-length": "long", 28 | "color-hex-case": "lower", 29 | "selector-attribute-quotes": "always", 30 | "declaration-block-trailing-semicolon": "always", 31 | "declaration-colon-space-before": "never", 32 | "property-no-vendor-prefix": true, 33 | "value-no-vendor-prefix": true, 34 | "number-leading-zero": "always", 35 | "function-url-quotes": "always", 36 | "font-family-name-quotes": "always-unless-keyword", 37 | "at-rule-no-vendor-prefix": true, 38 | "selector-no-vendor-prefix": true, 39 | "media-feature-name-no-vendor-prefix": true, 40 | "max-empty-lines": 2, 41 | "property-no-unknown": [true, { 42 | "ignoreProperties": ["composes"] 43 | }] 44 | }, 45 | "syntax": "scss" 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Django Stars 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![dependencies status](https://david-dm.org/django-stars/frontend-skeleton/status.svg) 2 | ![devDependencies status](https://david-dm.org/django-stars/frontend-skeleton/dev-status.svg) 3 | 4 | ## Summary 5 | 6 | Boilerplate for fast start frontend project with React/Redux, Babel, Webpack, Sass/Postcss and more other 7 | 8 | ## Supports 9 | 10 | - SPA and MPA/SSR applications 11 | - Proxy API and templates to different backends 12 | - Multiple domains 13 | - Configuration via .env file or environment 14 | - Includes lot of code samples (react application) 15 | - Spliting vendor and app bundles 16 | - SVG icons via postcss-inline-svg 17 | - HMR of course 18 | - Sass, Postcss, Bootstrap, React, Redux 19 | - Linter via ESLint & StyleLint 20 | - Tests via Jest/Enzyme 21 | - Easy webpack configuration via webpack-blocks 22 | - Sentry 23 | 24 | ## Usage 25 | 26 | #### Requirements 27 | - node ^8.9.0 28 | - npm ^5.0.3 29 | - yarn ^1.13.0 30 | 31 | ### Start 32 | 33 | ``` 34 | // 1. clone repo 35 | git clone git@github.com:django-stars/frontend-skeleton.git 36 | 37 | // 2. rename project folder and remove `.git` folder 38 | mv frontend-skeleton 39 | cd 40 | rm -rf .git 41 | 42 | // 3. install dependencies and start 43 | yarn install 44 | yarn start 45 | 46 | // 4. open http://localhost:3000 47 | ``` 48 | 49 | ### Available commands 50 | 51 | ``` 52 | // run dev server 53 | yarn start 54 | 55 | // build bundles 56 | yarn build 57 | 58 | // run tests 59 | yarn test 60 | 61 | // check app source with linter 62 | yarn lint 63 | 64 | // fix app source with linter 65 | yarn lint:fix 66 | 67 | ``` 68 | 69 | ### Available options 70 | 71 | [.env.default](.env.default) 72 | 73 | please do not modify `.env.default` file. you can create `.env` file near `.env.default` and rewrite options that you need 74 | 75 | ## Recipes 76 | 77 | 78 | #### jQuery 79 | 80 | ``` 81 | addPlugins([ 82 | new webpack.ProvidePlugin({ 83 | 'jQuery': 'jquery' 84 | }), 85 | ]), 86 | ``` 87 | 88 | #### enable linter verbose log 89 | 90 | run linter manually 91 | 92 | ``` 93 | DEBUG=eslint:cli-engine node linter 94 | ``` 95 | 96 | more information here: https://github.com/eslint/eslint/issues/1101 97 | 98 | #### Custom env variables in application code 99 | you need add it to `setEnv` in `webpack.config` 100 | 101 | #### get access to env inside index.html 102 | 103 | you can use lodash templates 104 | ``` 105 | <%=htmlWebpackPlugin.options.env.GA_TRACKING_ID%>') 106 | <%=htmlWebpackPlugin.options.env.NODE_ENV%>') 107 | ``` 108 | 109 | #### GA traking page changes in SPA 110 | ``` 111 | if(process.env.NODE_ENV === 'production') { 112 | history.listen(function (location) { 113 | window.gtag('config', process.env.GA_TRACKING_ID, {'page_path': location.pathname + location.search}); 114 | }) 115 | } 116 | ``` 117 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | sourceType: 'module', 3 | compact: false, 4 | presets: [[ 5 | '@babel/preset-env', 6 | { 7 | useBuiltIns: 'usage', 8 | corejs: { 9 | version: 3, 10 | proposals: true, 11 | }, 12 | }, 13 | ]], 14 | plugins: [ 15 | // stage 2 16 | ['@babel/plugin-proposal-decorators', { legacy: true }], 17 | '@babel/plugin-transform-numeric-separator', 18 | '@babel/plugin-proposal-throw-expressions', 19 | // stage 3 20 | '@babel/plugin-syntax-dynamic-import', 21 | '@babel/plugin-syntax-import-meta', 22 | ['@babel/plugin-transform-class-properties', { loose: false }], 23 | '@babel/plugin-transform-json-strings', 24 | '@babel/plugin-transform-export-namespace-from', 25 | ], 26 | env: { 27 | test: { 28 | presets: ['@babel/preset-react', '@babel/preset-flow'], 29 | plugins: [ 30 | 'babel-plugin-react-require', 31 | ], 32 | }, 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /init-env.mjs: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import packageConfig from './package.json' assert { type: 'json' } 3 | import dotenvExpand from 'dotenv-expand' 4 | 5 | if(!Boolean(process.env.APP_NAME)) { 6 | // set APP_NAME from package.json 7 | process.env.APP_NAME = packageConfig.name 8 | } 9 | 10 | // load .env file 11 | const config = dotenv.config({ 12 | // dotenv use .env by default, but you can override this 13 | path: process.env.ENVFILE, 14 | }) 15 | 16 | // load default config from .env.default 17 | const configDefault = dotenv.config({ 18 | path: '.env.default', 19 | }) 20 | 21 | // expand variables 22 | dotenvExpand(config) 23 | dotenvExpand(configDefault) 24 | 25 | 26 | export default process.env 27 | -------------------------------------------------------------------------------- /jest.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": ["./src/app"], 3 | "testRegex": "/src/app/.*\\.test.(js|jsx)$", 4 | "unmockedModulePathPatterns": [ 5 | "./node_modules/react" 6 | ], 7 | "setupFiles": [ 8 | "./test-setup.js" 9 | ], 10 | "modulePaths": [ 11 | "src/app" 12 | ], 13 | "moduleNameMapper": { 14 | "\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/__mocks__/fileMock.js", 15 | "\\.(css|scss)$": "identity-obj-proxy", 16 | "^@ds-frontend/cache": "ds-frontend/packages/cache", 17 | "^@ds-frontend/api": "ds-frontend/packages/api", 18 | "^@ds-frontend/i18n": "ds-frontend/packages/i18n", 19 | "^@ds-frontend/queryParams": "ds-frontend/packages/queryParams", 20 | "^@ds-frontend/redux-helpers": "ds-frontend/packages/redux-helpers", 21 | "^@ds-frontend/resource": "ds-frontend/packages/resource" 22 | }, 23 | "modulePathIgnorePatterns": ["/.*/__mocks__"], 24 | "transformIgnorePatterns": [ 25 | "node_modules/(?!(ds-frontend)/)" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /linter.js: -------------------------------------------------------------------------------- 1 | const eslint = require('eslint') 2 | const path = require('path') 3 | const pkg = require('./package.json') 4 | 5 | const opts = { 6 | version: pkg.version, 7 | homepage: pkg.homepage, 8 | bugs: pkg.bugs.url, 9 | eslint, 10 | cmd: 'linter', 11 | eslintConfig: { 12 | overrideConfigFile: path.join(__dirname, '.eslintrc.json'), 13 | }, 14 | cwd: '', 15 | } 16 | 17 | require('standard-engine').cli(opts) 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend-skeleton", 3 | "version": "3.0.0-beta", 4 | "description": "Yet another boilerplate created at first for using Angular/React in conjunction with Django.", 5 | "author": "DjangoStars (https://github.com/django-stars/)", 6 | "contributors": [ 7 | "Alexander Vilko (https://github.com/vilkoalexander)", 8 | "Alyona Pysarenko (https://github.com/ned-alyona)", 9 | "Artem Yarilchenko (https://github.com/yarilchenko)", 10 | "Denis Podlesniy (https://github.com/haos616)", 11 | "Eugene Bespaly (https://github.com/kosmos342)", 12 | "Nikita Mazur (https://github.com/NikitaMazur)", 13 | "Illia Lucenko (https://github.com/MrRinsvind)", 14 | "Oksana Maslova (https://github.com/Oksana-Maslova)", 15 | "Oleksii Onyshchenko (https://github.com/Asfalot)", 16 | "Tanya Sebastiyan (https://github.com/sebastiyan)", 17 | "Taras Dihtenko (https://github.com/h0m1c1de)", 18 | "Vyacheslav Binetsky (https://github.com/38Slava)", 19 | "Yaroslav Shulika (https://github.com/legendar)" 20 | ], 21 | "bugs": "https://github.com/django-stars/frontend-skeleton/issues", 22 | "license": "MIT", 23 | "repository": "github:django-stars/frontend-skeleton", 24 | "scripts": { 25 | "start": "NODE_ENV=development webpack-dev-server --progress --mode development", 26 | "build": "NODE_ENV=production webpack --mode production", 27 | "lint": "yarn run eslint", 28 | "lint:fix": "yarn run eslint:fix && yarn run stylelint:fix", 29 | "eslint": "node linter --verbose | snazzy", 30 | "eslint:fix": "node linter --fix --verbose | snazzy", 31 | "stylelint": "stylelint 'src/**/*.scss' scss", 32 | "stylelint:fix": "stylelint 'src/**/*.scss' --fix", 33 | "test": "jest --config jest.conf.json -u" 34 | }, 35 | "dependencies": { 36 | "@sentry/browser": "^5.9.0", 37 | "abortcontroller-polyfill": "^1.3.0", 38 | "bootstrap": "^4.0.0", 39 | "core-decorators": "^0.20.0", 40 | "ds-frontend": "https://github.com/django-stars/ds-frontend.git#9dbaa21", 41 | "lodash": "^4.17.15", 42 | "path-to-regexp": "^6.3.0", 43 | "prop-types": "^15.7.2", 44 | "react": "^16.12.0", 45 | "react-dom": "^16.12.0", 46 | "react-redux": "^7.2.0", 47 | "react-router": "^5.1.2", 48 | "react-router-dom": "^5.1.2", 49 | "redux": "^4.0.5", 50 | "redux-devtools-extension": "^2.13.8", 51 | "redux-sentry-middleware": "^0.1.3", 52 | "reselect": "^4.0.0", 53 | "smoothscroll-polyfill": "^0.4.3", 54 | "whatwg-fetch": "^3.0.0" 55 | }, 56 | "devDependencies": { 57 | "@babel/core": "^7.26.0", 58 | "@babel/eslint-parser": "^7.25.9", 59 | "@babel/plugin-proposal-decorators": "^7.25.9", 60 | "@babel/plugin-proposal-function-sent": "^7.25.9", 61 | "@babel/plugin-proposal-throw-expressions": "^7.25.9", 62 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 63 | "@babel/plugin-syntax-import-meta": "^7.10.4", 64 | "@babel/plugin-transform-class-properties": "^7.25.9", 65 | "@babel/plugin-transform-export-namespace-from": "^7.25.9", 66 | "@babel/plugin-transform-json-strings": "^7.25.9", 67 | "@babel/plugin-transform-numeric-separator": "^7.25.9", 68 | "@babel/preset-env": "^7.26.0", 69 | "@babel/preset-flow": "^7.25.9", 70 | "@babel/preset-react": "^7.25.9", 71 | "@babel/register": "^7.25.9", 72 | "@hot-loader/react-dom": "^16.11.0", 73 | "@sentry/webpack-plugin": "^1.8.0", 74 | "autoprefixer": "^9.4.9", 75 | "babel-loader": "^9.2.1", 76 | "babel-plugin-react-require": "^4.0.3", 77 | "clean-webpack-plugin": "^4.0.0", 78 | "core-js": "3", 79 | "dotenv": "^6.0.0", 80 | "dotenv-expand": "^5.1.0", 81 | "enzyme": "^3.3.0", 82 | "enzyme-adapter-react-16": "^1.10.0", 83 | "enzyme-to-json": "^3.3.3", 84 | "eslint": "^8.57.1", 85 | "eslint-config-standard": "^17.1.0", 86 | "eslint-plugin-import": "^2.31.0", 87 | "eslint-plugin-n": "^17.13.2", 88 | "eslint-plugin-node": "^11.1.0", 89 | "eslint-plugin-promise": "^7.1.0", 90 | "eslint-plugin-react": "^7.37.2", 91 | "eslint-plugin-standard": "^5.0.0", 92 | "html-webpack-plugin": "^5.6.3", 93 | "husky": "^3.0.9", 94 | "identity-obj-proxy": "^3.0.0", 95 | "jest": "^29.7.0", 96 | "jest-cli": "^29.7.0", 97 | "jest-enzyme": "^7.1.2", 98 | "lint-staged": "^9.4.3", 99 | "mini-css-extract-plugin": "^2.9.2", 100 | "postcss-inline-svg": "^3.0.0", 101 | "react-hot-loader": "^4.12.19", 102 | "react-test-renderer": "^16.8.3", 103 | "redux-mock-store": "^1.5.1", 104 | "sass": "^1.81.0", 105 | "sass-loader": "^16.0.3", 106 | "snazzy": "^8.0.0", 107 | "standard-engine": "^15.1.0", 108 | "stylelint": "^16.10.0", 109 | "stylelint-config-standard": "^36.0.1", 110 | "stylelint-scss": "^6.10.0", 111 | "webpack": "^5.96.1", 112 | "webpack-blocks": "^2.1.0", 113 | "webpack-cli": "^5.1.4", 114 | "webpack-dev-server": "^5.1.0", 115 | "write-file-webpack-plugin": "^4.5.1" 116 | }, 117 | "resolutions": { 118 | "node-sass": "npm:no-op@latest" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer') 2 | const inlineSVG = require('postcss-inline-svg') 3 | 4 | module.exports = { 5 | sourceMap: true, 6 | plugins: [ 7 | autoprefixer, 8 | // TODO svg optimizations https://github.com/TrySound/postcss-inline-svg#how-to-optimize-svg-on-build-step 9 | inlineSVG({ 10 | removeFill: false, 11 | }), 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /presets/assets.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | const { file, group, match } = webpackBlocks 3 | 4 | export default function(config) { 5 | return group([ 6 | // will copy font files to build directory and link to them 7 | match(['*.eot', '*.ttf', '*.woff', '*.woff2', '*.png', '*.jpg', '*.svg'], [ 8 | file(), 9 | ]), 10 | ]) 11 | } 12 | -------------------------------------------------------------------------------- /presets/babel.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | const { babel, match } = webpackBlocks 3 | 4 | export default function(config) { 5 | return match([/\.(js|jsx)$/], { exclude: /node_modules\/(?!ds-)/ }, [ 6 | babel({ 7 | }), 8 | ]) 9 | } 10 | -------------------------------------------------------------------------------- /presets/devServer.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack dev-server block. 3 | * 4 | * @see https://github.com/andywer/webpack-blocks/blob/master/packages/dev-server/index.js 5 | */ 6 | 7 | /** 8 | * @param {object} [options] See https://webpack.js.org/configuration/dev-server/ 9 | * @param {string|string[]} [entry] 10 | * @return {Function} 11 | */ 12 | export default function devServer(options = {}, entry = []) { 13 | if(options && (typeof options === 'string' || Array.isArray(options))) { 14 | entry = options 15 | options = {} 16 | } 17 | 18 | if(!Array.isArray(entry)) { 19 | entry = entry ? [entry] : [] 20 | } 21 | 22 | const setter = context => prevConfig => { 23 | context.devServer = context.devServer || { entry: [], options: {} } 24 | context.devServer.entry = context.devServer.entry.concat(entry) 25 | context.devServer.options = Object.assign({}, context.devServer.options, options) 26 | 27 | return prevConfig 28 | } 29 | 30 | return Object.assign(setter, { post: postConfig }) 31 | } 32 | 33 | function postConfig(context, util) { 34 | const entryPointsToAdd = context.devServer.entry 35 | 36 | return prevConfig => { 37 | return util.merge({ 38 | devServer: Object.assign( 39 | { 40 | hot: true, 41 | historyApiFallback: true, 42 | }, 43 | context.devServer.options, 44 | ), 45 | entry: addDevEntryToAll(prevConfig.entry || {}, entryPointsToAdd), 46 | })(prevConfig) 47 | } 48 | } 49 | 50 | function addDevEntryToAll(presentEntryPoints, devServerEntry) { 51 | const newEntryPoints = {} 52 | 53 | Object.keys(presentEntryPoints).forEach(chunkName => { 54 | // It's fine to just set the `devServerEntry`, instead of concat()-ing the present ones. 55 | // Will be concat()-ed by webpack-merge (see `createConfig()`) 56 | newEntryPoints[chunkName] = devServerEntry 57 | }) 58 | 59 | return newEntryPoints 60 | } 61 | -------------------------------------------------------------------------------- /presets/extract-css.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * extractCss webpack block. 3 | * 4 | * @see https://webpack.js.org/plugins/mini-css-extract-plugin/ 5 | */ 6 | 7 | // ==================================================================== 8 | // NOTE: this is a copy of extract-text webpack block 9 | // extract-text-webpack-plugin is replaced to mini-css-extract-plugin 10 | // =================================================================== 11 | 12 | import MiniCssExtractPlugin from 'mini-css-extract-plugin' 13 | 14 | /** 15 | * @param {string} outputFilePattern 16 | * @return {Function} 17 | */ 18 | export default function extractCss(outputFilePattern = 'css/[name].[contenthash:8].css') { 19 | const plugin = new MiniCssExtractPlugin({ filename: outputFilePattern }) 20 | 21 | const postHook = (context, util) => prevConfig => { 22 | let nextConfig = prevConfig 23 | 24 | // Only apply to loaders in the same `match()` group or css loaders if there is no `match()` 25 | const ruleToMatch = context.match || { test: /\.css$/ } 26 | const matchingLoaderRules = getMatchingLoaderRules(ruleToMatch, prevConfig) 27 | 28 | if(matchingLoaderRules.length === 0) { 29 | throw new Error( 30 | `extractCss(): No loaders found to extract contents from. Looking for loaders matching ${ 31 | ruleToMatch.test 32 | }`, 33 | ) 34 | } 35 | 36 | // eslint-disable-next-line no-unused-vars 37 | const [fallbackLoaders, nonFallbackLoaders] = splitFallbackRule(matchingLoaderRules) 38 | 39 | /* 40 | const newLoaderDef = Object.assign({}, ruleToMatch, { 41 | use: plugin.extract({ 42 | fallback: fallbackLoaders, 43 | use: nonFallbackLoaders 44 | }) 45 | }) 46 | */ 47 | const newLoaderDef = Object.assign({}, ruleToMatch, { 48 | use: [MiniCssExtractPlugin.loader, ...nonFallbackLoaders], 49 | }) 50 | 51 | for(const ruleToRemove of matchingLoaderRules) { 52 | nextConfig = removeLoaderRule(ruleToRemove)(nextConfig) 53 | } 54 | 55 | nextConfig = util.addPlugin(plugin)(nextConfig) 56 | nextConfig = util.addLoader(newLoaderDef)(nextConfig) 57 | 58 | return nextConfig 59 | } 60 | 61 | return Object.assign(() => prevConfig => prevConfig, { post: postHook }) 62 | } 63 | 64 | function getMatchingLoaderRules(ruleToMatch, webpackConfig) { 65 | return webpackConfig.module.rules.filter( 66 | rule => 67 | isLoaderConditionMatching(rule.test, ruleToMatch.test) && 68 | isLoaderConditionMatching(rule.exclude, ruleToMatch.exclude) && 69 | isLoaderConditionMatching(rule.include, ruleToMatch.include), 70 | ) 71 | } 72 | 73 | function splitFallbackRule(rules) { 74 | const leadingStyleLoaderInAllRules = rules.every(rule => { 75 | return ( 76 | rule.use.length > 0 && 77 | rule.use[0] && 78 | (rule.use[0] === 'style-loader' || rule.use[0].loader === 'style-loader') 79 | ) 80 | }) 81 | 82 | if(leadingStyleLoaderInAllRules) { 83 | const trimmedRules = rules.map(rule => Object.assign({}, rule, { use: rule.use.slice(1) })) 84 | return [['style-loader'], getUseEntriesFromRules(trimmedRules)] 85 | } else { 86 | return [[], getUseEntriesFromRules(rules)] 87 | } 88 | } 89 | 90 | function getUseEntriesFromRules(rules) { 91 | const normalizeUseEntry = use => (typeof use === 'string' ? { loader: use } : use) 92 | 93 | return rules.reduce((useEntries, rule) => useEntries.concat(rule.use.map(normalizeUseEntry)), []) 94 | } 95 | 96 | /** 97 | * @param {object} rule Remove all loaders that match this loader rule. 98 | * @return {Function} 99 | */ 100 | function removeLoaderRule(rule) { 101 | return prevConfig => { 102 | const newRules = prevConfig.module.rules.filter( 103 | prevRule => 104 | !( 105 | isLoaderConditionMatching(prevRule.test, rule.test) && 106 | isLoaderConditionMatching(prevRule.include, rule.include) && 107 | isLoaderConditionMatching(prevRule.exclude, rule.exclude) 108 | ), 109 | ) 110 | 111 | return Object.assign({}, prevConfig, { 112 | module: Object.assign({}, prevConfig.module, { 113 | rules: newRules, 114 | }), 115 | }) 116 | } 117 | } 118 | 119 | function isLoaderConditionMatching(test1, test2) { 120 | if(test1 === test2) { 121 | return true 122 | } else if(typeof test1 !== typeof test2) { 123 | return false 124 | } else if(test1 instanceof RegExp && test2 instanceof RegExp) { 125 | return test1 === test2 || String(test1) === String(test2) 126 | } else if(Array.isArray(test1) && Array.isArray(test2)) { 127 | return areArraysMatching(test1, test2) 128 | } 129 | 130 | return false 131 | } 132 | 133 | function areArraysMatching(array1, array2) { 134 | if(array1.length !== array2.length) { 135 | return false 136 | } 137 | 138 | return array1.every( 139 | item1 => 140 | array2.indexOf(item1) >= 0 || 141 | (item1 instanceof RegExp && 142 | array2.find(item2 => item2 instanceof RegExp && String(item1) === String(item2))), 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /presets/i18n.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | import webpack from 'webpack' 3 | 4 | const { addPlugins } = webpackBlocks 5 | 6 | 7 | export default function(config) { 8 | return addPlugins([ 9 | new webpack.ProvidePlugin({ 10 | gettext: ['ds-frontend/packages/i18n', 'gettext'], 11 | pgettext: ['ds-frontend/packages/i18n', 'pgettext'], 12 | ngettext: ['ds-frontend/packages/i18n', 'ngettext'], 13 | npgettext: ['ds-frontend/packages/i18n', 'npgettext'], 14 | interpolate: ['ds-frontend/packages/i18n', 'interpolate'], 15 | }), 16 | ]) 17 | } 18 | -------------------------------------------------------------------------------- /presets/index.mjs: -------------------------------------------------------------------------------- 1 | import babel from './babel.mjs' 2 | import postcss from './postcss.mjs' 3 | import react from './react.mjs' 4 | import sass from './sass.mjs' 5 | import spa from './spa.mjs' 6 | import styles from './styles.mjs' 7 | import assets from './assets.mjs' 8 | import proxy from './proxy.mjs' 9 | import sentry from './sentry.mjs' 10 | import i18n from './i18n.mjs' 11 | import devServer from './devServer.mjs' 12 | 13 | export { 14 | babel, 15 | postcss, 16 | react, 17 | sass, 18 | styles, 19 | spa, 20 | assets, 21 | proxy, 22 | sentry, 23 | i18n, 24 | devServer, 25 | } 26 | -------------------------------------------------------------------------------- /presets/postcss.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | import path from 'path' 3 | import extractCss from './extract-css.mjs' 4 | 5 | const { css, env, group, match, postcss } = webpackBlocks 6 | 7 | export default function(config) { 8 | return group([ 9 | match('*.css', { exclude: path.resolve('node_modules') }, [ 10 | css(), 11 | postcss(), 12 | env('production', [ 13 | extractCss('bundle.css'), 14 | ]), 15 | ]), 16 | ]) 17 | } 18 | -------------------------------------------------------------------------------- /presets/proxy.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | import devServer from './devServer.mjs' 3 | 4 | const { env, group } = webpackBlocks 5 | 6 | export default function(config) { 7 | return group([ 8 | env('development', [ 9 | devServer({ 10 | proxy: configureProxy(), 11 | }), 12 | ]), 13 | ]) 14 | } 15 | 16 | function configureProxy() { 17 | const ret = [ 18 | // proxy API and other paths from env.PROXY 19 | makeProxyContext(JSON.parse(process.env.PROXY), process.env.PROXY_URL), 20 | ] 21 | 22 | if(process.env.SSR) { 23 | // proxy templates 24 | ret.push( 25 | makeProxyContext([ 26 | '/**', 27 | `!${process.env.PUBLIC_PATH}`, 28 | ], process.env.BACKEND_URL), 29 | ) 30 | } 31 | 32 | return ret 33 | } 34 | 35 | function makeProxyContext(paths, targetUrl) { 36 | const urlData = new URL(targetUrl) 37 | return { 38 | secure: false, 39 | // TODO we need verbose logs for proxy (full request/response data) 40 | // we can make it manually via `logProvider` option 41 | logLevel: 'debug', 42 | 43 | // http -> httpS proxy settings 44 | changeOrigin: true, 45 | headers: { host: urlData.host, referer: urlData.origin }, 46 | 47 | auth: urlData.auth, 48 | target: urlData.protocol + '//' + urlData.host, 49 | router: makeRouter(urlData), 50 | context: paths, 51 | } 52 | } 53 | 54 | function makeRouter(urlData) { 55 | return function router(req) { 56 | const MAIN_HOST = process.env.MAIN_HOST 57 | const subdomain = MAIN_HOST && req.headers.host.includes(MAIN_HOST) 58 | ? req.headers.host.split(MAIN_HOST)[0] 59 | : '' 60 | 61 | const proxyUrl = urlData.protocol + '//' + subdomain + urlData.host 62 | 63 | return proxyUrl 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /presets/react.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | const { group, babel } = webpackBlocks 3 | 4 | 5 | export default function(config) { 6 | return group([ 7 | babel({ 8 | presets: [ 9 | '@babel/preset-react', 10 | '@babel/preset-flow', 11 | ], 12 | 13 | plugins: [ 14 | 'babel-plugin-react-require', 15 | // need for react HMR 16 | // 'extract-hoc/babel', 17 | 'react-hot-loader/babel', 18 | ], 19 | }), 20 | 21 | ]) 22 | } 23 | -------------------------------------------------------------------------------- /presets/sass.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | import path from 'path' 3 | import extractCss from './extract-css.mjs' 4 | 5 | const { css, env, group, match, sass } = webpackBlocks 6 | 7 | export default function(config) { 8 | return group([ 9 | match(['*.css', '*.sass', '*.scss'], { exclude: path.resolve('node_modules') }, [ 10 | css(), 11 | sass({ 12 | loadPaths: [ 13 | path.resolve('./src/styles'), 14 | path.resolve('./node_modules/bootstrap/scss'), 15 | path.resolve('./node_modules'), 16 | ], 17 | }), 18 | env('production', [ 19 | extractCss('bundle.css'), 20 | ]), 21 | ]), 22 | ]) 23 | } 24 | -------------------------------------------------------------------------------- /presets/sentry.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | import SentryWebpackPlugin from '@sentry/webpack-plugin' 3 | 4 | const { addPlugins, group, env } = webpackBlocks 5 | 6 | const isSentryConfigured = process.env.SENTRY_ORG && 7 | process.env.SENTRY_PROJECT && 8 | process.env.SENTRY_AUTH_TOKEN && 9 | process.env.SENTRY_DSN 10 | 11 | if(isSentryConfigured) { 12 | process.env.SENTRY_URL = new URL(process.env.SENTRY_DSN).origin 13 | } 14 | 15 | export default function(config) { 16 | return group([ 17 | env('production', [ 18 | addPlugins([ 19 | isSentryConfigured && new SentryWebpackPlugin({ 20 | include: 'src/app', 21 | ignoreFile: '.sentrycliignore', 22 | }), 23 | ].filter(Boolean)), 24 | ]), 25 | ]) 26 | } 27 | -------------------------------------------------------------------------------- /presets/spa.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | import HtmlWebpackPlugin from 'html-webpack-plugin' 3 | import path from 'path' 4 | 5 | const { addPlugins, group } = webpackBlocks 6 | 7 | export default function(config) { 8 | return group([ 9 | addPlugins([ 10 | // Injects bundles in your index file instead of wiring all manually. 11 | // you can use chunks option to exclude some bundles and add separate entry point 12 | new HtmlWebpackPlugin({ 13 | template: path.resolve(`${process.env.SOURCES_PATH}/index.html`), 14 | inject: 'body', 15 | hash: true, 16 | showErrors: true, 17 | // index.html should be outside assets folder 18 | filename: path.resolve(`${process.env.OUTPUT_PATH}/index.html`), 19 | env: process.env, 20 | }), 21 | ]), 22 | ]) 23 | } 24 | -------------------------------------------------------------------------------- /presets/styles.mjs: -------------------------------------------------------------------------------- 1 | import webpackBlocks from 'webpack-blocks' 2 | import path from 'path' 3 | import extractCss from './extract-css.mjs' 4 | 5 | const { css, env, group, match, sass, postcss } = webpackBlocks 6 | 7 | export default function(config) { 8 | return group([ 9 | match(['*node_modules*.css'], [ 10 | css({ 11 | styleLoader: { 12 | insert: insertAtTop, 13 | }, 14 | }), 15 | ]), 16 | // NOTE we can't use path.resolve for exclude 17 | // path.resolve doesn't resolve symlinks 18 | // in docker containers node_modules folder usually placed in separate symlinked directories 19 | // path.resolve will provide incorrect string, so we need to use RegExp here 20 | // more documentation here: https://webpack.js.org/configuration/module/#condition 21 | match(['*.css', '*.sass', '*.scss'], { exclude: /node_modules/ }, [ 22 | process.env.SSR 23 | ? css() 24 | : css.modules({ 25 | localsConvention: 'camelCase', 26 | }), 27 | sass({ 28 | sassOptions: { 29 | loadPaths: [ 30 | path.resolve('./src/styles'), 31 | path.resolve('./node_modules/bootstrap/scss'), 32 | path.resolve('./node_modules'), 33 | ], 34 | }, 35 | }), 36 | postcss(), 37 | env('production', [ 38 | extractCss('bundle.css'), 39 | ]), 40 | ]), 41 | ]) 42 | } 43 | 44 | function insertAtTop(element) { 45 | const parent = document.querySelector('head') 46 | const lastInsertedElement = window._lastElementInsertedByStyleLoader 47 | 48 | if(!lastInsertedElement) { 49 | parent.insertBefore(element, parent.firstChild) 50 | } else if(lastInsertedElement.nextSibling) { 51 | parent.insertBefore(element, lastInsertedElement.nextSibling) 52 | } else { 53 | parent.appendChild(element) 54 | } 55 | 56 | window._lastElementInsertedByStyleLoader = element 57 | } 58 | -------------------------------------------------------------------------------- /src/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | export default '' 2 | -------------------------------------------------------------------------------- /src/app/App.js: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux' 2 | import { Router } from 'common/router' 3 | import { CheckCache } from '@ds-frontend/cache' 4 | import { hot } from 'react-hot-loader/root' 5 | import routes from './routes' 6 | import PropTypes from 'prop-types' 7 | 8 | AppProvider.propTypes = { 9 | store: PropTypes.object.isRequired, 10 | history: PropTypes.object.isRequired, 11 | } 12 | 13 | 14 | function AppProvider({ store, history }) { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | export default hot(AppProvider) 25 | -------------------------------------------------------------------------------- /src/app/api.js: -------------------------------------------------------------------------------- 1 | import { API } from '@ds-frontend/api' 2 | import { QueryParams } from '@ds-frontend/queryParams' 3 | 4 | export const QS = new QueryParams() 5 | 6 | const api = new API({ 7 | baseURL: `${process.env.API_URL}`, 8 | queryFuntion: QS.buildQueryParams, 9 | }) 10 | 11 | export default api 12 | -------------------------------------------------------------------------------- /src/app/common/forms/BaseFieldHOC.js: -------------------------------------------------------------------------------- 1 | import { Field } from 'react-final-form' 2 | import BaseFieldLayout from './BaseFieldLayout' 3 | 4 | export default function BaseFieldHOC(Component) { 5 | return function(props) { 6 | return ( 7 | 13 | ) 14 | } 15 | } 16 | 17 | // https://github.com/final-form/react-final-form/issues/130 18 | function identity(value) { 19 | return value 20 | } 21 | -------------------------------------------------------------------------------- /src/app/common/forms/BaseFieldLayout.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | BaseFieldLayout.propTypes = { 5 | label: PropTypes.node, 6 | required: PropTypes.bool, 7 | inputComponent: PropTypes.oneOfType([ 8 | PropTypes.element, 9 | PropTypes.elementType, 10 | PropTypes.func, 11 | ]).isRequired, 12 | meta: PropTypes.object.isRequired, 13 | input: PropTypes.object.isRequired, 14 | prefix: PropTypes.node, 15 | } 16 | 17 | BaseFieldLayout.defaultProps = { 18 | label: undefined, 19 | required: false, 20 | prefix: undefined, 21 | } 22 | 23 | export default function BaseFieldLayout({ 24 | label, 25 | prefix, 26 | required, 27 | inputComponent: InputComponent, 28 | meta, 29 | input, 30 | ...rest 31 | }) { 32 | const error = useMemo(() => { 33 | if(meta.submitError && !meta.dirtySinceLastSubmit) { 34 | return meta.submitError 35 | } 36 | 37 | if(meta.error && meta.touched) { 38 | return meta.error 39 | } 40 | }, [meta.error, meta.touched, meta.dirtySinceLastSubmit, meta.submitError]) 41 | const formattedError = useMemo(() => Array.isArray(error) ? error[0] : error, [error]) 42 | 43 | return ( 44 |
45 | {label && ( 46 | 50 | )} 51 |
52 |
53 | {prefix &&
{prefix}
} 54 | 59 |

{formattedError}

60 |
61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/app/common/forms/FormPanel.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { Panel } from 'react-bootstrap' 3 | 4 | FormPanel.propTypes = { 5 | panelTitle: PropTypes.string, 6 | isCollapsible: PropTypes.bool, 7 | isExpanded: PropTypes.bool, 8 | } 9 | 10 | FormPanel.defaultProps = { 11 | isCollapsible: false, 12 | isExpanded: true, 13 | panelTitle: '', 14 | } 15 | 16 | export default function FormPanel(props) { 17 | const { panelTitle, isCollapsible, isExpanded, ...restProps } = props 18 | return ( 19 | 20 | 21 | {panelTitle} 22 | { isCollapsible && () } 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/common/forms/fields.js: -------------------------------------------------------------------------------- 1 | import BaseFieldHOC from './BaseFieldHOC' 2 | 3 | import CheckboxInput from './inputs/CheckboxInput' 4 | import NumberInput from './inputs/NumberInput' 5 | import RadiosInput from './inputs/RadiosInput' 6 | import TextInput from './inputs/TextInput' 7 | import TextAreaInput from './inputs/TextAreaInput' 8 | import FileInput from './inputs/FileInput' 9 | 10 | 11 | const CheckboxField = BaseFieldHOC(CheckboxInput) 12 | const NumberField = BaseFieldHOC(NumberInput) 13 | const RadiosField = BaseFieldHOC(RadiosInput) 14 | const TextField = BaseFieldHOC(TextInput) 15 | const TextAreaField = BaseFieldHOC(TextAreaInput) 16 | const FileInputField = BaseFieldHOC(FileInput) 17 | 18 | export { 19 | CheckboxField, 20 | NumberField, 21 | RadiosField, 22 | TextField, 23 | TextAreaField, 24 | FileInputField, 25 | } 26 | -------------------------------------------------------------------------------- /src/app/common/forms/index.js: -------------------------------------------------------------------------------- 1 | export * from './fields' 2 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/CheckboxInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | CheckboxInput.propTypes = { 5 | inputClassName: PropTypes.string, 6 | value: PropTypes.oneOfType([ 7 | PropTypes.bool, 8 | PropTypes.string, 9 | ]), 10 | disabled: PropTypes.bool, 11 | required: PropTypes.bool, 12 | checkboxLabel: PropTypes.node, 13 | onChange: PropTypes.func.isRequired, 14 | name: PropTypes.string, 15 | } 16 | 17 | CheckboxInput.defaultProps = { 18 | inputClassName: 'custom-checkbox', 19 | value: false, 20 | disabled: false, 21 | required: false, 22 | checkboxLabel: undefined, 23 | name: undefined, 24 | } 25 | 26 | export default function CheckboxInput({ 27 | inputClassName, 28 | value, 29 | checkboxLabel, 30 | disabled, 31 | required, 32 | onChange, 33 | name, 34 | }) { 35 | const handleChange = useCallback((e) => onChange(e.target.checked), [onChange]) 36 | return ( 37 |
38 | { 39 | 52 | } 53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/CurrencyInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | import NumberFormat from 'react-number-format' 4 | 5 | CurrencyInput.propTypes = { 6 | inputClassName: PropTypes.string, 7 | placeholder: PropTypes.string, 8 | required: PropTypes.bool, 9 | disabled: PropTypes.bool, 10 | name: PropTypes.string, 11 | value: PropTypes.oneOfType([ 12 | PropTypes.number, 13 | PropTypes.string, 14 | ]), 15 | thousandSeparator: PropTypes.string, 16 | onChange: PropTypes.func.isRequired, 17 | } 18 | CurrencyInput.defaultProps = { 19 | inputClassName: 'input-custom', 20 | thousandSeparator: "'", 21 | placeholder: '', 22 | required: false, 23 | disabled: false, 24 | value: '', 25 | name: undefined, 26 | } 27 | 28 | export default function CurrencyInput({ 29 | onChange, 30 | inputClassName, 31 | ...props 32 | }) { 33 | const handleChange = useCallback((e) => onChange(e.target.value), [onChange]) 34 | return ( 35 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/DateInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import DatePicker from 'react-datepicker' 3 | import PropTypes from 'prop-types' 4 | import valueToDate from 'shared/utils/valueToDate' 5 | import dateToValue from 'shared/utils/dateToValue' 6 | 7 | DateInput.propTypes = { 8 | inputClassName: PropTypes.string, 9 | monthsShown: PropTypes.number, 10 | placeholder: PropTypes.string, 11 | disabled: PropTypes.bool, 12 | dateFormat: PropTypes.string, 13 | value: PropTypes.string, 14 | onChange: PropTypes.func.isRequired, 15 | name: PropTypes.string, 16 | } 17 | DateInput.defaultProps = { 18 | inputClassName: 'input-custom', 19 | monthsShown: 1, 20 | dateFormat: 'DD.MM.YYYY', 21 | placeholder: '', 22 | required: false, 23 | disabled: false, 24 | value: '', 25 | name: undefined, 26 | } 27 | 28 | export default function DateInput({ 29 | inputClassName, 30 | monthsShown, 31 | placeholder, 32 | disabled, 33 | value, 34 | dateFormat, 35 | onChange, 36 | name, 37 | }) { 38 | const handleChange = useCallback((value) => onChange(dateToValue(value, dateFormat)), [onChange]) 39 | return ( 40 |
41 | 51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/FileInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | 5 | FileInput.propTypes = { 6 | name: PropTypes.string.isRequired, 7 | onChange: PropTypes.func.isRequired, 8 | } 9 | 10 | export default function FileInput({ name, onChange }) { 11 | const handleChange = useCallback((e) => onChange(e.target.files[0]), [onChange]) 12 | return ( 13 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/NumberInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | NumberInput.propTypes = { 5 | inputClassName: PropTypes.string, 6 | placeholder: PropTypes.string, 7 | pattern: PropTypes.string, 8 | required: PropTypes.bool, 9 | disabled: PropTypes.bool, 10 | name: PropTypes.string, 11 | value: PropTypes.oneOfType([ 12 | PropTypes.number, 13 | PropTypes.string, 14 | ]), 15 | onChange: PropTypes.func.isRequired, 16 | } 17 | 18 | NumberInput.defaultProps = { 19 | inputClassName: 'input-custom', 20 | placeholder: '', 21 | pattern: '###', 22 | required: false, 23 | disabled: false, 24 | value: '', 25 | name: undefined, 26 | } 27 | 28 | export default function NumberInput({ 29 | onChange, 30 | inputClassName, 31 | ...props 32 | }) { 33 | const handleChange = useCallback((e) => onChange(e.target.value), [onChange]) 34 | return ( 35 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/RadiosInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | RadiosInput.propTypes = { 5 | inputClassName: PropTypes.string, 6 | value: PropTypes.string, 7 | valueKey: PropTypes.string, 8 | labelKey: PropTypes.string, 9 | options: PropTypes.array.isRequired, 10 | disabled: PropTypes.bool, 11 | name: PropTypes.string, 12 | onChange: PropTypes.func.isRequired, 13 | } 14 | RadiosInput.defaultProps = { 15 | inputClassName: 'radio-custom', 16 | valueKey: 'value', 17 | labelKey: 'label', 18 | value: '', 19 | disabled: false, 20 | name: undefined, 21 | } 22 | 23 | export default function RadiosInput({ 24 | onChange, 25 | inputClassName, 26 | value, 27 | valueKey, 28 | labelKey, 29 | options, 30 | disabled, 31 | name, 32 | }) { 33 | const handleChange = useCallback((e) => onChange(e.target.value), [onChange]) 34 | return ( 35 |
36 | { 37 | options.map((option) => ( 38 | 52 | )) 53 | } 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/SelectInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import Select from 'react-select' 3 | import PropTypes from 'prop-types' 4 | 5 | SelectInput.propTypes = { 6 | inputClassName: PropTypes.string, 7 | placeholder: PropTypes.string, 8 | name: PropTypes.string, 9 | options: PropTypes.array, 10 | required: PropTypes.bool, 11 | value: PropTypes.oneOfType([ 12 | PropTypes.object, 13 | PropTypes.string, 14 | ]), 15 | isDisabled: PropTypes.bool, 16 | isClearable: PropTypes.bool, 17 | isMulti: PropTypes.bool, 18 | isSearchable: PropTypes.bool, 19 | onChange: PropTypes.func.isRequired, 20 | } 21 | SelectInput.defaultProps = { 22 | inputClassName: 'select-custom', 23 | name: undefined, 24 | isClearable: false, 25 | isMulti: false, 26 | isSearchable: false, 27 | placeholder: '', 28 | options: [], 29 | required: false, 30 | value: '', 31 | isDisabled: false, 32 | } 33 | 34 | export default function SelectInput({ 35 | inputClassName, 36 | onChange, 37 | value, 38 | ...props 39 | }) { 40 | const handleChange = useCallback((e) => onChange(e[value]), [onChange, value]) 41 | return ( 42 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/app/common/forms/inputs/TextInput.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | 5 | TextInput.propTypes = { 6 | inputClassName: PropTypes.string, 7 | placeholder: PropTypes.string, 8 | pattern: PropTypes.string, 9 | required: PropTypes.bool, 10 | disabled: PropTypes.bool, 11 | readOnly: PropTypes.bool, 12 | value: PropTypes.string, 13 | name: PropTypes.string, 14 | onChange: PropTypes.func.isRequired, 15 | } 16 | 17 | TextInput.defaultProps = { 18 | inputClassName: 'input-custom', 19 | readOnly: false, 20 | placeholder: '', 21 | pattern: undefined, 22 | required: undefined, 23 | disabled: undefined, 24 | value: '', 25 | name: undefined, 26 | } 27 | 28 | export default function TextInput({ onChange, inputClassName, ...props }) { 29 | const handleChange = useCallback((e) => onChange(e.target.value), [onChange]) 30 | return ( 31 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/app/common/forms/validation/constants.js: -------------------------------------------------------------------------------- 1 | const errors = { 2 | required: 'Required', 3 | email: 'Email is wrong', 4 | } 5 | 6 | export default errors 7 | -------------------------------------------------------------------------------- /src/app/common/forms/validation/email.js: -------------------------------------------------------------------------------- 1 | import errors from './constants' 2 | 3 | export default function email(value) { 4 | const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-z\u0080-\u00ff\-0-9]+\.)+[a-zA-Z]{2,}))$/ 5 | return (value && !emailRe.test(value)) ? errors.email : undefined 6 | } 7 | -------------------------------------------------------------------------------- /src/app/common/forms/validation/index.js: -------------------------------------------------------------------------------- 1 | import { compose, composeValidators, mainValidation } from './utils' 2 | import email from './email' 3 | import required from './required' 4 | 5 | export function validateEmail(fields) { 6 | return mainValidation(fields, email) 7 | } 8 | 9 | export function validateRequired(fields) { 10 | return mainValidation(fields, required) 11 | } 12 | 13 | export { 14 | compose, 15 | composeValidators, 16 | email, 17 | required, 18 | } 19 | -------------------------------------------------------------------------------- /src/app/common/forms/validation/required.js: -------------------------------------------------------------------------------- 1 | import errors from './constants' 2 | 3 | export default function required(value) { 4 | return !value ? errors.required : undefined 5 | } 6 | -------------------------------------------------------------------------------- /src/app/common/forms/validation/utils/compose.js: -------------------------------------------------------------------------------- 1 | export function compose(...validations) { 2 | return function(values) { 3 | return validations.reduce(function(errors, validator) { 4 | return { ...errors, ...validator(values) } 5 | }, {}) 6 | } 7 | } 8 | 9 | export function composeValidators(...validations) { 10 | return function(value) { 11 | return validations.reduce(function(error, validator) { 12 | return error || validator(value) 13 | }, undefined) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/common/forms/validation/utils/index.js: -------------------------------------------------------------------------------- 1 | import mainValidation from './mainValidation' 2 | import { compose, composeValidators } from './compose' 3 | 4 | export { 5 | compose, 6 | composeValidators, 7 | mainValidation, 8 | } 9 | -------------------------------------------------------------------------------- /src/app/common/forms/validation/utils/mainValidation.js: -------------------------------------------------------------------------------- 1 | import isEmpty from 'lodash/isEmpty' 2 | 3 | export default function mainValidation(fields, validate) { 4 | return function(values) { 5 | if(!Array.isArray(fields)) { 6 | fields = [fields] 7 | } 8 | 9 | if(isEmpty(fields)) { 10 | throw new Error('fields should be defined') 11 | } 12 | 13 | return fields.reduce(function(res, key) { 14 | const errorMessage = validate(values[key]) 15 | if(errorMessage) { 16 | return { 17 | ...res, 18 | [key]: errorMessage, 19 | } 20 | } 21 | 22 | return res 23 | }, {}) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/common/modals/ModalConfirmation.js: -------------------------------------------------------------------------------- 1 | import { Button } from 'react-bootstrap' 2 | import PropTypes from 'prop-types' 3 | 4 | ModalConfirmation.propTypes = { 5 | onHide: PropTypes.func, 6 | onConfirm: PropTypes.func, 7 | confirmationText: PropTypes.node, 8 | dismissBtn: PropTypes.node, 9 | confirmBtn: PropTypes.node, 10 | } 11 | 12 | ModalConfirmation.defaultProps = { 13 | onHide: undefined, 14 | onConfirm: undefined, 15 | confirmationText: null, 16 | dismissBtn: null, 17 | confirmBtn: null, 18 | } 19 | 20 | export default function ModalConfirmation(props) { 21 | const { onHide, onConfirm, confirmationText, dismissBtn, confirmBtn } = props 22 | return ( 23 |
24 | {confirmationText} 25 |
26 | 27 | 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/app/common/modals/ModalConfirmationTrigger.js: -------------------------------------------------------------------------------- 1 | import ModalTrigger from './ModalTrigger' 2 | import ModalConfirmation from './ModalConfirmation' 3 | import PropTypes from 'prop-types' 4 | 5 | ModalConfirmationTrigger.propTypes = { 6 | onConfirm: PropTypes.func, 7 | statusClassName: PropTypes.string, 8 | } 9 | 10 | ModalConfirmationTrigger.defaultProps = { 11 | onConfirm: undefined, 12 | statusClassName: '', 13 | } 14 | 15 | export default function ModalConfirmationTrigger(props) { 16 | const { onConfirm, statusClassName } = props 17 | return ( 18 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/common/modals/ModalTrigger.js: -------------------------------------------------------------------------------- 1 | import { autobind } from 'core-decorators' 2 | import PropTypes from 'prop-types' 3 | import { Component, Children, cloneElement } from 'react' 4 | 5 | import ModalWrapper from './ModalWrapper' 6 | 7 | const propTypes = { 8 | children: PropTypes.object, 9 | } 10 | 11 | const defaultProps = { 12 | children: undefined, 13 | } 14 | 15 | 16 | export default class ModalTrigger extends Component { 17 | state = { 18 | toggled: false, 19 | } 20 | 21 | @autobind 22 | open(e) { 23 | e.stopPropagation() 24 | e.preventDefault() 25 | this.setState({ toggled: true }) 26 | } 27 | 28 | @autobind 29 | close() { 30 | this.setState({ toggled: false }) 31 | } 32 | 33 | render() { 34 | const { children } = this.props 35 | 36 | // ensure that we have only one child (control element) 37 | const child = cloneElement(Children.only(children), { onClick: this.open, key: 'modal-control' }) 38 | return [ 39 | child, 40 | , 41 | ] 42 | } 43 | } 44 | 45 | ModalTrigger.propTypes = propTypes 46 | ModalTrigger.defaultProps = defaultProps 47 | -------------------------------------------------------------------------------- /src/app/common/modals/ModalWrapper.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { Modal } from 'react-bootstrap' 3 | 4 | ModalWrapper.propTypes = { 5 | modalClassName: PropTypes.string, 6 | title: PropTypes.string, 7 | show: PropTypes.bool, 8 | onHide: PropTypes.func, 9 | component: PropTypes.element, 10 | } 11 | 12 | ModalWrapper.defaultProps = { 13 | modalClassName: '', 14 | title: '', 15 | show: false, 16 | onHide: undefined, 17 | component: null, 18 | } 19 | 20 | export default function ModalWrapper(props) { 21 | const { 22 | modalClassName, 23 | title, 24 | show, 25 | onHide, 26 | component: ModalComponent, 27 | } = props 28 | 29 | return ( 30 | 31 |
32 | 33 | {title} 34 |
35 |
36 | 37 | 38 | 39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/app/common/router/Link.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { useContext, useMemo } from 'react' 3 | import { Link as RouterLink, NavLink as RouterNavLink } from 'react-router-dom' 4 | import isEmpty from 'lodash/isEmpty' 5 | import get from 'lodash/get' 6 | import omit from 'lodash/omit' 7 | import { RouterConfigContext } from './RouterConfig' 8 | import { compile, parse } from 'path-to-regexp' 9 | 10 | 11 | function NamedLink(LinkComponent) { 12 | LinkWrapped.propTypes = { 13 | to: PropTypes.string.isRequired, 14 | state: PropTypes.object, 15 | } 16 | 17 | LinkWrapped.defaultProps = { 18 | state: {}, 19 | } 20 | function LinkWrapped({ to, state = {}, ...props }) { 21 | const namedRoutes = useContext(RouterConfigContext) 22 | let path = get(namedRoutes, to, '') 23 | if(!path && !isEmpty(namedRoutes)) { 24 | throw new Error('no route with name: ' + to) 25 | } 26 | 27 | if(path.includes(':')) { 28 | path = compile(path)(props) 29 | } 30 | 31 | const omitProps = useMemo(() => parse(get(namedRoutes, to, '')).filter(item => item.name).map(({ name }) => name), [path]) 32 | return 33 | } 34 | 35 | return LinkWrapped 36 | } 37 | 38 | const Link = NamedLink(RouterLink) 39 | const NavLink = NamedLink(RouterNavLink) 40 | 41 | export { Link, NavLink } 42 | -------------------------------------------------------------------------------- /src/app/common/router/Prompt.js: -------------------------------------------------------------------------------- 1 | import { autobind } from 'core-decorators' 2 | import { Component } from 'react' 3 | import PropTypes from 'prop-types' 4 | import { Modal, ModalBody } from 'reactstrap' 5 | import { ModalConfirmation } from 'common/widgets' 6 | 7 | // TODO onBeforeUnload 8 | const propTypes = { 9 | onConfirm: PropTypes.func.isRequired, 10 | isLocationAllowed: PropTypes.func, 11 | title: PropTypes.string, 12 | message: PropTypes.string.isRequired, 13 | } 14 | 15 | const defaultProps = { 16 | title: 'Warning!', 17 | isLocationAllowed: function(location) { return false }, 18 | } 19 | 20 | const contextTypes = { 21 | router: PropTypes.shape({ 22 | history: PropTypes.shape({ 23 | block: PropTypes.func.isRequired, 24 | }).isRequired, 25 | }).isRequired, 26 | } 27 | 28 | export default class Prompt extends Component { 29 | constructor(...args) { 30 | super(...args) 31 | this.state = { 32 | isModalShown: false, 33 | action: null, 34 | location: null, 35 | } 36 | } 37 | 38 | @autobind 39 | handleBlock(location, action) { 40 | if(this.props.isLocationAllowed(location)) { 41 | return true 42 | } 43 | 44 | this.setState({ isModalShown: true, action, location }) 45 | 46 | return false 47 | } 48 | 49 | @autobind 50 | toggleModal({ isSuccess }) { 51 | this.setState({ isModalShown: false }) 52 | if(isSuccess) { 53 | this.props.onConfirm() 54 | this.disable() 55 | this.navigate() 56 | } 57 | } 58 | 59 | navigate() { 60 | const { action, location } = this.state 61 | this.context.router.history[action.toLowerCase()]( 62 | // FIXME it seems pathname doesn't contains query and hash 63 | // this should be fixed 64 | location.pathname, 65 | location.state, 66 | ) 67 | this.setState({}) 68 | } 69 | 70 | enable() { 71 | if(this.unblock) { this.unblock() } 72 | 73 | this.unblock = this.context.router.history.block(this.handleBlock) 74 | } 75 | 76 | disable() { 77 | if(this.unblock) { 78 | this.unblock() 79 | this.unblock = null 80 | } 81 | } 82 | 83 | componentWillMount() { 84 | this.enable() 85 | } 86 | 87 | componentWillReceiveProps(nextProps) { 88 | this.enable() 89 | } 90 | 91 | componentWillUnmount() { 92 | this.disable() 93 | } 94 | 95 | render() { 96 | const isModalShown = this.state.isModalShown 97 | const { title, message } = this.props 98 | 99 | return ( 100 | 101 | 102 | 107 | 108 | 109 | ) 110 | } 111 | } 112 | 113 | Prompt.contextTypes = contextTypes 114 | Prompt.propTypes = propTypes 115 | Prompt.defaultProps = defaultProps 116 | -------------------------------------------------------------------------------- /src/app/common/router/RouteRecursive.js: -------------------------------------------------------------------------------- 1 | import { Route, Redirect, Switch } from 'react-router-dom' 2 | import isEmpty from 'lodash/isEmpty' 3 | import { CheckAccess } from 'common/session' 4 | import PropTypes from 'prop-types' 5 | 6 | RouteRecursive.propTypes = { 7 | access: PropTypes.number, 8 | layout: PropTypes.elementType, 9 | component: PropTypes.elementType, 10 | routes: PropTypes.array, 11 | redirectTo: PropTypes.string, 12 | location: PropTypes.shape({ 13 | pathname: PropTypes.string, 14 | state: PropTypes.object, 15 | }), 16 | match: PropTypes.object, 17 | } 18 | 19 | RouteRecursive.defaultProps = { 20 | access: undefined, 21 | layout: undefined, 22 | component: undefined, 23 | routes: undefined, 24 | redirectTo: undefined, 25 | match: undefined, 26 | location: undefined, 27 | } 28 | 29 | export default function RouteRecursive({ access, layout: Layout, component: Component, routes, redirectTo, ...route }) { 30 | let renderRoute = null 31 | if(Array.isArray(routes) && !isEmpty(routes)) { 32 | renderRoute = function(props) { 33 | return ( 34 | 35 | {routes.map((r, i) => ())} 36 | { 37 | // fallback 38 | Component 39 | ? 40 | : 41 | } 42 | 43 | ) 44 | } 45 | } 46 | 47 | if(redirectTo) { 48 | renderRoute = function(props) { 49 | let newPath = props.location.pathname 50 | if(newPath.startsWith(props.match.path)) { 51 | newPath = redirectTo + newPath.substr(props.match.path.length) 52 | } else { 53 | newPath = redirectTo 54 | } 55 | 56 | return 57 | } 58 | } 59 | 60 | let rendered = ( 61 | 62 | ) 63 | 64 | if(Layout) { 65 | rendered = {rendered} 66 | } 67 | 68 | return ( 69 | 73 | } 74 | >{rendered} 75 | ) 76 | } 77 | 78 | function relativePath(root = '', path = '') { 79 | return (root + path).split('//').join('/') 80 | } 81 | -------------------------------------------------------------------------------- /src/app/common/router/Router.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'react-router' 2 | import PropTypes from 'prop-types' 3 | import RouteRecursive from './RouteRecursive' 4 | import RouterConfig from './RouterConfig' 5 | 6 | AppRouter.propTypes = { 7 | routes: PropTypes.array.isRequired, 8 | history: PropTypes.object.isRequired, 9 | } 10 | 11 | export default function AppRouter({ routes, history }) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/common/router/RouterConfig.js: -------------------------------------------------------------------------------- 1 | import { createContext, Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | const RouterConfigContext = createContext({}) 4 | 5 | const propTypes = { 6 | routes: PropTypes.array.isRequired, 7 | children: PropTypes.node, 8 | } 9 | 10 | const defaultProps = { 11 | children: undefined, 12 | } 13 | 14 | export default class RouterConfig extends Component { 15 | render() { 16 | return ( 17 | 18 | {this.props.children} 19 | 20 | ) 21 | } 22 | } 23 | 24 | RouterConfig.propTypes = propTypes 25 | RouterConfig.defaultProps = defaultProps 26 | 27 | export { RouterConfigContext } 28 | 29 | 30 | function routesMap(routes, basePath = '/') { 31 | return routes.reduce((acc, { name, path, routes }) => { 32 | if(!path) { 33 | return acc 34 | } 35 | 36 | path = makePath(path, basePath) 37 | 38 | if(name) { 39 | acc = { 40 | ...acc, 41 | [name]: path, 42 | } 43 | } 44 | 45 | if(routes) { 46 | acc = { 47 | ...acc, 48 | ...(routesMap(routes, path)), 49 | } 50 | } 51 | 52 | return acc 53 | }, {}) 54 | } 55 | 56 | 57 | function makePath(path, basePath) { 58 | return (basePath + path).replace(/\/+/g, '/') 59 | } 60 | -------------------------------------------------------------------------------- /src/app/common/router/index.js: -------------------------------------------------------------------------------- 1 | import Router from './Router' 2 | import RouteRecursive from './RouteRecursive' 3 | import { Link, NavLink } from './Link' 4 | import withRouter from './withRouter' 5 | 6 | export { 7 | Link, 8 | NavLink, 9 | withRouter, 10 | Router, 11 | RouteRecursive, 12 | } 13 | -------------------------------------------------------------------------------- /src/app/common/router/withRouter.js: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react' 2 | import isEmpty from 'lodash/isEmpty' 3 | import get from 'lodash/get' 4 | import findKey from 'lodash/findKey' 5 | import { compile, match } from 'path-to-regexp' 6 | import { __RouterContext as RouterContext } from 'react-router' 7 | import { RouterConfigContext } from './RouterConfig' 8 | import { QS } from 'api' 9 | 10 | 11 | export default function withNamedRouter(ChildComponent) { 12 | return function NamedRouter(props) { 13 | const routerValue = useContext(RouterContext) 14 | const namedRoutes = useContext(RouterConfigContext) 15 | const location = { 16 | ...routerValue.location, 17 | state: { 18 | ...(get(routerValue.location, 'state', {})), 19 | name: findKey(namedRoutes, key => match(key && key.replace(/\/$/, ''))(routerValue.location.pathname)), 20 | }, 21 | } 22 | const history = useMemo(() => namedHistory(routerValue.history, namedRoutes), [routerValue]) 23 | return ( 24 | 30 | ) 31 | } 32 | } 33 | 34 | function namedHistory(location = {}, namedRoutes) { 35 | return { 36 | ...location, 37 | push: (path, state) => location.push(customNavigation(makePath(path, namedRoutes), state), state), 38 | replace: (path, state) => location.replace(customNavigation(makePath(path, namedRoutes), state), state), 39 | } 40 | } 41 | 42 | function customNavigation(path, state) { 43 | if(path.pathname.search(/\/:/) > -1) { 44 | path.pathname = compile(path.pathname)(state) 45 | } 46 | 47 | if(path.search && typeof path.search === 'object') { 48 | path.search = QS.buildQueryParams(path.search) 49 | } 50 | 51 | return path 52 | } 53 | 54 | function makePath(to, namedRoutes) { 55 | if(typeof to === 'string') { 56 | return { pathname: getNamedRouteName(to, namedRoutes) } 57 | } 58 | 59 | return { 60 | ...to, 61 | pathname: getNamedRouteName(to.pathname, namedRoutes), 62 | } 63 | } 64 | 65 | function getNamedRouteName(to, namedRoutes) { 66 | if(to.startsWith('/')) { 67 | return to 68 | } 69 | 70 | const pathname = get(namedRoutes, to, '') 71 | if(!pathname && !isEmpty(namedRoutes)) { 72 | throw new Error('no route with name: ' + to) 73 | } 74 | 75 | return pathname 76 | } 77 | -------------------------------------------------------------------------------- /src/app/common/session/CheckAccess.jsx: -------------------------------------------------------------------------------- 1 | import { compose } from 'redux' 2 | import { connect } from 'react-redux' 3 | import { F_PUBLIC, userLevelSelector } from './access' 4 | // import { Children } from 'react' 5 | 6 | 7 | function CheckAccess({ access = F_PUBLIC, level, fallback = null, children }) { 8 | return level & access ? children : fallback 9 | } 10 | 11 | export default compose( 12 | connect( 13 | (state, props) => ({ 14 | level: userLevelSelector({ 15 | ...state, 16 | }), 17 | }), 18 | ), 19 | )(CheckAccess) 20 | -------------------------------------------------------------------------------- /src/app/common/session/access.js: -------------------------------------------------------------------------------- 1 | import isEmpty from 'lodash/isEmpty' 2 | import get from 'lodash/get' 3 | import { createSelector } from 'reselect' 4 | 5 | export const F_PUBLIC = 2 ** 0 6 | export const F_PROTECTED = 2 ** 1 7 | export const F_UNAUTHORISED = 2 ** 2 8 | 9 | // NOTE F_CHIEF have full access to application 10 | // should contains all flags. the value should be next exponent minus one 11 | // NOTE the maximum exponent can be 52, because the MAX_SAFE_INTEGER is (2 ** 53) 12 | // const F_CHIEF = 2 ** 52 - 1 13 | 14 | export const userLevelSelector = createSelector( 15 | // base permissions 16 | (state) => isEmpty(get(state, 'session.data.token')) ? F_UNAUTHORISED : F_PROTECTED, 17 | 18 | // collect all user permissions 19 | (...args) => args.reduce((level, flag) => level | flag, F_PUBLIC), 20 | ) 21 | -------------------------------------------------------------------------------- /src/app/common/session/authMiddleware.js: -------------------------------------------------------------------------------- 1 | import api from 'api' 2 | import get from 'lodash/get' 3 | import { logout, LOGOUT_ACTION } from 'store/session' 4 | 5 | export default function authMiddleware(store) { 6 | api.interceptors.response.use({ 7 | onError: function({ data, response }) { 8 | if(get(response, 'status') === 401) { 9 | store.dispatch(logout()) 10 | throw new Error(response.statusText) 11 | } 12 | 13 | return { data, response } 14 | }, 15 | }) 16 | 17 | let removeRequestInterceptor 18 | return (next) => action => { 19 | const token = get(store.getState(), 'session.data.token') 20 | 21 | if(action.type === LOGOUT_ACTION) { 22 | removeRequestInterceptor && removeRequestInterceptor() 23 | return next(action) 24 | } 25 | 26 | const nextToken = get(action, 'payload.data.token') || get(action, 'payload.session.data.token') 27 | if(nextToken !== token && nextToken) { 28 | removeRequestInterceptor && removeRequestInterceptor() 29 | removeRequestInterceptor = api.interceptors.request.use({ 30 | onSuccess: (configs) => { 31 | const headers = new Headers(configs.headers) 32 | headers.set('Authorization', `JWT ${nextToken}`) 33 | return { 34 | ...configs, 35 | headers, 36 | } 37 | }, 38 | }) 39 | } 40 | 41 | return next(action) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/common/session/index.js: -------------------------------------------------------------------------------- 1 | import CheckAccess from './CheckAccess' 2 | export * as access from './access' 3 | 4 | 5 | export { 6 | CheckAccess, 7 | } 8 | -------------------------------------------------------------------------------- /src/app/common/widgets/Loading.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | Loading.propTypes = { 4 | isLoading: PropTypes.bool.isRequired, 5 | children: PropTypes.node, 6 | } 7 | 8 | Loading.defaultProps = { 9 | children: undefined, 10 | } 11 | 12 | export default function Loading({ isLoading, children }) { 13 | return isLoading ?
loading..
: children 14 | } 15 | -------------------------------------------------------------------------------- /src/app/common/widgets/Wizard.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | Wizard.propTypes = { 4 | steps: PropTypes.array, 5 | activeStepIndex: PropTypes.number, 6 | } 7 | 8 | Wizard.defaultProps = { 9 | steps: [], 10 | activeStepIndex: 0, 11 | } 12 | 13 | export default function Wizard(props) { 14 | const { steps, activeStepIndex } = props 15 | 16 | return ( 17 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/app/index.js: -------------------------------------------------------------------------------- 1 | import 'react-hot-loader' 2 | import { render } from 'react-dom' 3 | import { store, history } from './init' 4 | import App from './App' 5 | 6 | 7 | render( 8 | , 9 | document.getElementById('root'), 10 | ) 11 | -------------------------------------------------------------------------------- /src/app/init.js: -------------------------------------------------------------------------------- 1 | import 'polyfills' // should be first 2 | import '../styles/index.scss' 3 | import API from './api' 4 | import { resourcesReducer } from '@ds-frontend/resource' 5 | import { cacheMiddleware, persistReducer } from '@ds-frontend/cache' 6 | import { promisableActionMiddleware, composeReducers, combineReducers } from '@ds-frontend/redux-helpers' 7 | import { createBrowserHistory } from 'history' 8 | import { createStore, applyMiddleware } from 'redux' 9 | import { reducers } from 'store' 10 | import * as Sentry from '@sentry/browser' 11 | import createSentryMiddleware from 'redux-sentry-middleware' 12 | import authMiddleware from 'common/session/authMiddleware' 13 | import omit from 'lodash/omit' 14 | // TODO migrate to the official dev tools 15 | // https://github.com/reduxjs/redux-devtools/tree/master/packages/redux-devtools 16 | import { composeWithDevTools } from 'redux-devtools-extension' 17 | 18 | 19 | if(process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { 20 | Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.SENTRY_ENVIRONMENT }) 21 | } 22 | 23 | 24 | // support for redux dev tools 25 | const compose = composeWithDevTools({ 26 | name: process.env.APP_NAME, 27 | }) 28 | 29 | const store = createStore( 30 | composeReducers( 31 | {}, 32 | combineReducers(reducers), 33 | persistReducer(JSON.parse(process.env.CACHE_STATE_PERSIST_KEYS)), 34 | resourcesReducer, 35 | ), 36 | {}, 37 | compose( 38 | applyMiddleware(...[ 39 | authMiddleware, 40 | promisableActionMiddleware({ API }), 41 | cacheMiddleware({ 42 | storeKey: process.env.STORAGE_KEY, 43 | cacheKeys: JSON.parse(process.env.CACHE_STATE_KEYS), 44 | storage: localStorage, 45 | }), 46 | process.env.SENTRY_DSN && createSentryMiddleware(Sentry, { 47 | stateTransformer: (state) => { return omit(state, 'session') }, 48 | }), 49 | 50 | ].filter(Boolean)), 51 | ), 52 | ) 53 | 54 | const history = createBrowserHistory() 55 | 56 | export { 57 | store, 58 | history, 59 | } 60 | -------------------------------------------------------------------------------- /src/app/layouts/AppLayout.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import Header from './Header' 3 | import Footer from './Footer' 4 | import styles from './layout.scss' 5 | 6 | AppLayout.propTypes = { 7 | children: PropTypes.node, 8 | } 9 | 10 | AppLayout.defaultProps = { 11 | children: null, 12 | } 13 | 14 | export default function AppLayout({ children }) { 15 | return ( 16 |
17 |
18 |
19 | {children} 20 |
21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/app/layouts/Footer.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import styles from './layout.scss' 3 | 4 | export default class Footer extends Component { 5 | render() { 6 | return ( 7 |
Footer
8 | ) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/layouts/Header.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import { Link } from 'common/router/Link' 3 | import logo from '../../img/ds-logo.png' 4 | import styles from './layout.scss' 5 | 6 | export default class Header extends Component { 7 | render() { 8 | return ( 9 |
10 | logo 11 |
12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/layouts/layout.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | .wrapper { 4 | height: 100%; 5 | background-color: $gray-100; 6 | } 7 | 8 | .main { 9 | height: calc(100% - #{$header-height} - #{$footer-height}); 10 | overflow: auto; 11 | } 12 | 13 | .header { 14 | height: $header-height; 15 | background-color: #fff; 16 | display: flex; 17 | justify-content: space-around; 18 | align-items: center; 19 | } 20 | 21 | .footer { 22 | height: $footer-height; 23 | background-color: $primary; 24 | text-align: center; 25 | color: #fff; 26 | } 27 | -------------------------------------------------------------------------------- /src/app/pages/auth/index.js: -------------------------------------------------------------------------------- 1 | import routes from './routes' 2 | 3 | export { 4 | routes, 5 | } 6 | -------------------------------------------------------------------------------- /src/app/pages/auth/login/Login.js: -------------------------------------------------------------------------------- 1 | import withLoginResource from './withLoginResource' 2 | import LoginView from './LoginView' 3 | 4 | export default withLoginResource(LoginView) 5 | -------------------------------------------------------------------------------- /src/app/pages/auth/login/LoginView.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { TextField } from 'common/forms' 3 | import classNames from './login.scss' 4 | 5 | 6 | LoginView.propTypes = { 7 | handleSubmit: PropTypes.func.isRequired, 8 | submitting: PropTypes.bool, 9 | valid: PropTypes.bool, 10 | } 11 | 12 | LoginView.defaultProps = { 13 | submitting: false, 14 | valid: true, 15 | } 16 | 17 | export default function LoginView({ handleSubmit, submitting, valid }) { 18 | return ( 19 |
20 |

Login

21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/app/pages/auth/login/index.js: -------------------------------------------------------------------------------- 1 | import Login from './Login' 2 | 3 | export default Login 4 | -------------------------------------------------------------------------------- /src/app/pages/auth/login/login.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | .login { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | max-width: 460px; 8 | margin: 0 auto; 9 | padding-top: 40px; 10 | } 11 | 12 | .key-icon { 13 | background-size: contain; 14 | background-repeat: no-repeat; 15 | display: inline-block; 16 | background-image: svg-load("../../../../img/icons/ic-key.svg"); 17 | width: 18px; 18 | height: 18px; 19 | margin-right: 10px; 20 | } 21 | 22 | .login-button { 23 | height: 40px; 24 | text-align: center; 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | font-size: 16px; 29 | margin-top: 20px; 30 | border: 1px solid $gray-400; 31 | width: 210px; 32 | } 33 | -------------------------------------------------------------------------------- /src/app/pages/auth/login/utils/validate.js: -------------------------------------------------------------------------------- 1 | import { validateEmail, validateRequired, compose } from 'common/forms/validation' 2 | 3 | export default compose( 4 | validateEmail('email'), 5 | validateRequired(['email', 'password']), 6 | ) 7 | -------------------------------------------------------------------------------- /src/app/pages/auth/login/withLoginResource.js: -------------------------------------------------------------------------------- 1 | import { withFinalForm } from '@ds-frontend/resource' 2 | import validate from './utils/validate' 3 | 4 | 5 | export default withFinalForm( 6 | { 7 | validate, 8 | }, 9 | { 10 | namespace: 'session', 11 | endpoint: 'accounts/signin', 12 | }, 13 | { 14 | prefetch: false, 15 | }, 16 | ) 17 | -------------------------------------------------------------------------------- /src/app/pages/auth/routes.js: -------------------------------------------------------------------------------- 1 | import LoginForm from './login' 2 | 3 | const routes = [ 4 | { 5 | path: '/', 6 | routes: [ 7 | { 8 | path: '/', 9 | exact: true, 10 | redirectTo: '/auth/login', 11 | }, 12 | { 13 | path: '/login', 14 | component: LoginForm, 15 | name: 'login', 16 | }, 17 | ], 18 | }, 19 | ] 20 | 21 | export default routes 22 | -------------------------------------------------------------------------------- /src/app/pages/dashboard/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import DashboardView from './DashboardView' 2 | import { connect } from 'react-redux' 3 | import { logout } from 'store/session' 4 | 5 | 6 | export default connect(null, { logout })(DashboardView) 7 | -------------------------------------------------------------------------------- /src/app/pages/dashboard/DashboardView.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | DashboardView.propTypes = { 4 | logout: PropTypes.func.isRequired, 5 | } 6 | 7 | 8 | export default function DashboardView({ logout }) { 9 | return ( 10 |
11 |

Dashboard

12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/app/pages/dashboard/index.js: -------------------------------------------------------------------------------- 1 | import routes from './routes' 2 | 3 | export { 4 | routes, 5 | } 6 | -------------------------------------------------------------------------------- /src/app/pages/dashboard/routes.js: -------------------------------------------------------------------------------- 1 | import Dashboard from './Dashboard' 2 | 3 | const routes = [ 4 | { 5 | path: '/', 6 | component: Dashboard, 7 | name: 'dashboard', 8 | }, 9 | ] 10 | 11 | export default routes 12 | -------------------------------------------------------------------------------- /src/app/pages/fallbacks/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | 3 | // TODO make it pretty 4 | export default function NotFound(props) { 5 | return ( 6 |
7 |
.404
8 |
9 | The page you are trying to reach does not exist, or has been moved. 10 | Go to homepage 11 |
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/app/polyfills.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import smoothScroll from 'smoothscroll-polyfill' 3 | smoothScroll.polyfill() 4 | 5 | // should be after React import for IE11 6 | // 'require' used because inside condition 7 | if(!!window.MSInputMethodContext && !!document.documentMode) { // IE11 check 8 | require('whatwg-fetch') 9 | require('abortcontroller-polyfill/dist/polyfill-patch-fetch') 10 | 11 | // classList.toggle polyfill for IE 12 | DOMTokenList.prototype.toggle = function(token, force) { 13 | if(force === undefined) { 14 | force = !this.contains(token) 15 | } 16 | 17 | return this[force ? 'add' : 'remove'](token) 18 | } 19 | } 20 | 21 | // node.remove polyfill fro IE 22 | ;(function() { 23 | var arr = [window.Element, window.CharacterData, window.DocumentType] 24 | var args = [] 25 | 26 | arr.forEach(function(item) { 27 | if(item) { 28 | args.push(item.prototype) 29 | } 30 | }) 31 | 32 | // from:https://github.com/jserz/js_piece/blob/master/DOM/ChildNode/remove()/remove().md 33 | ;(function(arr) { 34 | arr.forEach(function(item) { 35 | if(item.hasOwnProperty('remove')) { 36 | return 37 | } 38 | Object.defineProperty(item, 'remove', { 39 | configurable: true, 40 | enumerable: true, 41 | writable: true, 42 | value: function remove() { 43 | this.parentNode.removeChild(this) 44 | }, 45 | }) 46 | }) 47 | })(args) 48 | })() 49 | 50 | ;(function() { 51 | // IE 52 | if(!Element.prototype.scrollIntoViewIfNeeded) { 53 | Element.prototype.scrollIntoViewIfNeeded = function() { 54 | const rect = this.getBoundingClientRect() 55 | if(rect.top < 0 || rect.bottom > window.innerHeight || rect.left < 0 || rect.right > window.innerWidth) { 56 | this.scrollIntoView() 57 | } 58 | } 59 | } 60 | })() 61 | -------------------------------------------------------------------------------- /src/app/routes.js: -------------------------------------------------------------------------------- 1 | import NotFound from 'pages/fallbacks/NotFound' 2 | import AppLayout from 'layouts/AppLayout' 3 | 4 | import { routes as auth } from 'pages/auth' 5 | import { routes as dashboard } from 'pages/dashboard' 6 | 7 | import { access } from 'common/session' 8 | 9 | const appRoutes = [ 10 | { 11 | path: '/', 12 | exact: true, 13 | name: 'root', 14 | redirectTo: '/dashboard', 15 | }, 16 | { 17 | path: '/', 18 | layout: AppLayout, 19 | routes: [ 20 | { 21 | path: '/auth', 22 | routes: auth, 23 | access: access.F_UNAUTHORISED, 24 | accessRedirectTo: '/dashboard', 25 | }, 26 | { 27 | path: '/dashboard', 28 | routes: dashboard, 29 | access: access.F_PROTECTED, 30 | accessRedirectTo: '/auth', 31 | name: 'dashboard', 32 | }, 33 | { 34 | component: NotFound, 35 | }, 36 | ], 37 | }, 38 | ] 39 | 40 | export default appRoutes 41 | -------------------------------------------------------------------------------- /src/app/store/index.js: -------------------------------------------------------------------------------- 1 | const reducers = {} 2 | 3 | export { 4 | reducers, 5 | } 6 | -------------------------------------------------------------------------------- /src/app/store/session.js: -------------------------------------------------------------------------------- 1 | import { reset } from '@ds-frontend/cache' 2 | 3 | export const LOGOUT_ACTION = 'LOGOUT_ACTION' 4 | 5 | export function logout() { 6 | return function(dispatch) { 7 | dispatch({ 8 | type: LOGOUT_ACTION, 9 | }) 10 | dispatch(reset()) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/fonts/Lato/Lato-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-stars/frontend-skeleton/f5964d6b6d929bc529545a130e6332927928e0d6/src/fonts/Lato/Lato-Bold.woff -------------------------------------------------------------------------------- /src/fonts/Lato/Lato-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-stars/frontend-skeleton/f5964d6b6d929bc529545a130e6332927928e0d6/src/fonts/Lato/Lato-Regular.woff -------------------------------------------------------------------------------- /src/fonts/Lato/lato.css: -------------------------------------------------------------------------------- 1 | 2 | @font-face { 3 | font-family: 'Lato'; 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: url("./Lato-Regular.woff") format("woff"); 8 | } 9 | 10 | @font-face { 11 | font-family: 'Lato'; 12 | font-style: normal; 13 | font-weight: 700; 14 | font-display: swap; 15 | src: url("./Lato-Bold.woff") format("woff"); 16 | } -------------------------------------------------------------------------------- /src/img/ds-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-stars/frontend-skeleton/f5964d6b6d929bc529545a130e6332927928e0d6/src/img/ds-logo.png -------------------------------------------------------------------------------- /src/img/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-stars/frontend-skeleton/f5964d6b6d929bc529545a130e6332927928e0d6/src/img/example.png -------------------------------------------------------------------------------- /src/img/icons/ic-create.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Svg Vector Icons : http://www.onlinewebfonts.com/icon 6 | 7 | -------------------------------------------------------------------------------- /src/img/icons/ic-key.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%=htmlWebpackPlugin.options.env.APP_NAME%> 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/styles/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | @import "functions"; 2 | @import "variables"; 3 | @import "mixins"; 4 | @import "root"; 5 | @import "reboot"; 6 | @import "type"; 7 | @import "images"; 8 | @import "code"; 9 | @import "grid"; 10 | @import "tables"; 11 | @import "forms"; 12 | @import "buttons"; 13 | @import "transitions"; 14 | @import "dropdown"; 15 | @import "button-group"; 16 | @import "input-group"; 17 | @import "custom-forms"; 18 | @import "nav"; 19 | @import "navbar"; 20 | @import "card"; 21 | @import "breadcrumb"; 22 | @import "pagination"; 23 | @import "badge"; 24 | @import "jumbotron"; 25 | @import "alert"; 26 | // SMELL 27 | // 28 | // ( ) 29 | // ( ) ( 30 | // ) _ ) 31 | // ( \_ 32 | // _(_\ \)__ 33 | // (____\___)) 34 | // 35 | // https://github.com/webpack-contrib/sass-loader/issues/556 36 | @import "progress.scss"; 37 | @import "media"; 38 | @import "list-group"; 39 | @import "close"; 40 | @import "modal"; 41 | @import "tooltip"; 42 | @import "popover"; 43 | @import "carousel"; 44 | @import "utilities"; 45 | @import "print"; 46 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap/scss/functions"; 2 | @import "bootstrap/scss/variables"; 3 | /* stylelint-disable number-leading-zero */ 4 | // Variables 5 | // 6 | // Variables should follow the `$component-state-property-size` formula for 7 | // consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs. 8 | 9 | $header-height: 76px; 10 | $footer-height: 48px; 11 | 12 | // Color system 13 | 14 | $white: #fff !default; 15 | $gray-100: #f8f9fa !default; 16 | $gray-200: #e9ecef !default; 17 | $gray-300: #dee2e6 !default; 18 | $gray-400: #ced4da !default; 19 | $gray-500: #adb5bd !default; 20 | $gray-600: #6c757d !default; 21 | $gray-700: #495057 !default; 22 | $gray-800: #343a40 !default; 23 | $gray-900: #212529 !default; 24 | $black: #000 !default; 25 | 26 | $blue: #007bff !default; 27 | $indigo: #6610f2 !default; 28 | $purple: #6f42c1 !default; 29 | $pink: #e83e8c !default; 30 | $red: #dc3545 !default; 31 | $orange: #fd7e14 !default; 32 | $yellow: #ffc107 !default; 33 | $green: #28a745 !default; 34 | $teal: #20c997 !default; 35 | $cyan: #17a2b8 !default; 36 | 37 | $primary: $blue !default; 38 | $secondary: $gray-600 !default; 39 | $success: $green !default; 40 | $info: $cyan !default; 41 | $warning: $yellow !default; 42 | $danger: $red !default; 43 | $light: $gray-100 !default; 44 | $dark: $gray-800 !default; 45 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | /* stylelint-disable selector-pseudo-class-no-unknown */ 2 | @import "bootstrap"; 3 | @import "./variables.scss"; 4 | @import "../fonts/Lato/lato.css"; 5 | 6 | :global { 7 | html, 8 | body, 9 | #root { 10 | height: 100%; 11 | display: block; 12 | margin: 0; 13 | font-family: "Lato", sans-serif; 14 | font-size: 16px; 15 | font-weight: normal; 16 | color: $gray-800; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme' 2 | import Adapter from 'enzyme-adapter-react-16' 3 | 4 | Enzyme.configure({ adapter: new Adapter() }) 5 | -------------------------------------------------------------------------------- /webpack.config.mjs: -------------------------------------------------------------------------------- 1 | import './init-env.mjs' // SHOULD BE FIRST 2 | 3 | import path from 'path' 4 | import { CleanWebpackPlugin } from 'clean-webpack-plugin' 5 | import WriteFilePlugin from 'write-file-webpack-plugin' 6 | import webpackBlocks from 'webpack-blocks' 7 | import { 8 | // postcss, 9 | react, 10 | // sass, 11 | styles, 12 | spa, 13 | assets, 14 | proxy, 15 | sentry, 16 | babel, 17 | devServer, 18 | } from './presets/index.mjs' 19 | 20 | const { 21 | addPlugins, 22 | createConfig, 23 | env, 24 | entryPoint, 25 | resolve, 26 | setEnv, 27 | setOutput, 28 | sourceMaps, 29 | when, 30 | customConfig, 31 | } = webpackBlocks 32 | 33 | export default createConfig([ 34 | 35 | entryPoint({ 36 | bundle: 'index.js', 37 | // styles: './src/sass/app.sass', 38 | // you can add you own entries here (also check SplitChunksPlugin) 39 | // code splitting guide: https://webpack.js.org/guides/code-splitting/ 40 | // SplitChunksPlugin: https://webpack.js.org/plugins/split-chunks-plugin/ 41 | }), 42 | 43 | resolve({ 44 | modules: [ 45 | path.resolve(`${process.env.SOURCES_PATH}/app`), 46 | 'node_modules', 47 | ], 48 | alias: { 49 | 'react-dom': process.env.NODE_ENV !== 'development' ? 'react-dom' : '@hot-loader/react-dom', 50 | '@ds-frontend/cache': 'ds-frontend/packages/cache', 51 | '@ds-frontend/api': 'ds-frontend/packages/api', 52 | '@ds-frontend/i18n': 'ds-frontend/packages/i18n', 53 | '@ds-frontend/queryParams': 'ds-frontend/packages/queryParams', 54 | '@ds-frontend/redux-helpers': 'ds-frontend/packages/redux-helpers', 55 | '@ds-frontend/resource': 'ds-frontend/packages/resource', 56 | }, 57 | extensions: ['.js', '.jsx', '.json', '.css', '.sass', '.scss'], 58 | }), 59 | 60 | setOutput({ 61 | path: path.resolve(`${process.env.OUTPUT_PATH}${process.env.PUBLIC_PATH}`), 62 | publicPath: process.env.PUBLIC_URL, 63 | // NOTE: 'name' here is the name of entry point 64 | filename: '[name].js', 65 | // TODO check are we need this (HMR?) 66 | // chunkFilename: '[id].chunk.js', 67 | pathinfo: process.env.NODE_ENV === 'development', 68 | }), 69 | 70 | setEnv([ 71 | // pass env values to compile environment 72 | 'API_URL', 'AUTH_HEADER', 'MAIN_HOST', 73 | 'CACHE_STATE_KEYS', 'STORAGE_KEY', 'SENTRY_DSN', 'SENTRY_ENVIRONMENT', 'CACHE_STATE_PERSIST_KEYS', 'LIMIT', 74 | 'NODE_ENV', 'APP_NAME', 75 | ]), 76 | 77 | addPlugins([ 78 | // clean distribution folder before compile 79 | new CleanWebpackPlugin(), 80 | ]), 81 | 82 | customConfig({ 83 | mode: process.env.NODE_ENV ?? 'development', 84 | optimization: { 85 | splitChunks: { 86 | cacheGroups: { 87 | // move all modules defined outside of application directory to vendor bundle 88 | defaultVendors: { 89 | test: function(module) { 90 | return module.resource && module.resource.indexOf(path.resolve('src')) === -1 91 | }, 92 | name: 'vundle', 93 | chunks: 'all', 94 | }, 95 | }, 96 | }, 97 | }, 98 | }), 99 | 100 | env('development', [ 101 | devServer({ 102 | static: { 103 | directory: path.resolve(`${process.env.OUTPUT_PATH}`), 104 | }, 105 | port: process.env.DEV_SERVER_PORT || 3000, 106 | host: process.env.DEV_SERVER_HOST || 'local-ip', 107 | allowedHosts: [ 108 | '.localhost', 109 | `.${process.env.MAIN_HOST}`, 110 | ], 111 | hot: true, 112 | client: { 113 | overlay: false, 114 | }, 115 | }), 116 | sourceMaps('eval-source-map'), 117 | 118 | addPlugins([ 119 | // write generated files to filesystem (for debug) 120 | // FIXME are we realy need this??? 121 | new WriteFilePlugin(), 122 | ]), 123 | ]), 124 | 125 | when(!process.env.SSR, [spa()]), 126 | proxy(), 127 | 128 | babel(), 129 | react(), 130 | sentry(), 131 | // sass(), 132 | styles(), 133 | // postcss(), 134 | assets(), 135 | ]) 136 | --------------------------------------------------------------------------------