├── .nvmrc
├── .eslintignore
├── .sentrycliignore
├── src
├── __mocks__
│ └── fileMock.js
├── app
│ ├── common
│ │ ├── forms
│ │ │ ├── index.js
│ │ │ ├── validation
│ │ │ │ ├── constants.js
│ │ │ │ ├── required.js
│ │ │ │ ├── utils
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── compose.js
│ │ │ │ │ └── mainValidation.js
│ │ │ │ ├── email.js
│ │ │ │ └── index.js
│ │ │ ├── inputs
│ │ │ │ ├── FileInput.js
│ │ │ │ ├── SelectRangeInput.js
│ │ │ │ ├── TextAreaInput.js
│ │ │ │ ├── TextInput.js
│ │ │ │ ├── NumberInput.js
│ │ │ │ ├── CurrencyInput.js
│ │ │ │ ├── SelectInput.js
│ │ │ │ ├── CheckboxInput.js
│ │ │ │ ├── DateInput.js
│ │ │ │ └── RadiosInput.js
│ │ │ ├── BaseFieldHOC.js
│ │ │ ├── fields.js
│ │ │ ├── FormPanel.js
│ │ │ └── BaseFieldLayout.js
│ │ ├── session
│ │ │ ├── index.js
│ │ │ ├── CheckAccess.jsx
│ │ │ ├── access.js
│ │ │ └── authMiddleware.js
│ │ ├── router
│ │ │ ├── index.js
│ │ │ ├── Router.js
│ │ │ ├── RouterConfig.js
│ │ │ ├── Link.jsx
│ │ │ ├── RouteRecursive.js
│ │ │ ├── withRouter.js
│ │ │ └── Prompt.js
│ │ ├── widgets
│ │ │ ├── Loading.jsx
│ │ │ └── Wizard.js
│ │ └── modals
│ │ │ ├── ModalConfirmationTrigger.js
│ │ │ ├── ModalConfirmation.js
│ │ │ ├── ModalWrapper.js
│ │ │ └── ModalTrigger.js
│ ├── store
│ │ ├── index.js
│ │ └── session.js
│ ├── pages
│ │ ├── auth
│ │ │ ├── login
│ │ │ │ ├── index.js
│ │ │ │ ├── Login.js
│ │ │ │ ├── utils
│ │ │ │ │ └── validate.js
│ │ │ │ ├── withLoginResource.js
│ │ │ │ ├── login.scss
│ │ │ │ └── LoginView.js
│ │ │ ├── index.js
│ │ │ └── routes.js
│ │ ├── dashboard
│ │ │ ├── index.js
│ │ │ ├── Dashboard.jsx
│ │ │ ├── routes.js
│ │ │ └── DashboardView.jsx
│ │ └── fallbacks
│ │ │ └── NotFound.jsx
│ ├── index.js
│ ├── layouts
│ │ ├── Footer.jsx
│ │ ├── Header.jsx
│ │ ├── layout.scss
│ │ └── AppLayout.jsx
│ ├── api.js
│ ├── App.js
│ ├── routes.js
│ ├── polyfills.js
│ └── init.js
├── img
│ ├── ds-logo.png
│ ├── example.png
│ └── icons
│ │ ├── ic-create.svg
│ │ └── ic-key.svg
├── fonts
│ └── Lato
│ │ ├── Lato-Bold.woff
│ │ ├── Lato-Regular.woff
│ │ └── lato.css
├── index.html
└── styles
│ ├── index.scss
│ ├── _bootstrap.scss
│ └── _variables.scss
├── .browserslistrc
├── .huskyrc
├── .lintstagedrc
├── test-setup.js
├── presets
├── babel.mjs
├── assets.mjs
├── postcss.mjs
├── react.mjs
├── i18n.mjs
├── index.mjs
├── sass.mjs
├── sentry.mjs
├── spa.mjs
├── proxy.mjs
├── devServer.mjs
├── styles.mjs
└── extract-css.mjs
├── .gitignore
├── .editorconfig
├── postcss.config.js
├── linter.js
├── .github
└── dependabot.yml
├── init-env.mjs
├── babel.config.js
├── jest.conf.json
├── .circleci
└── config.yml
├── LICENSE
├── .stylelintrc.json
├── .env.default
├── .eslintrc.json
├── README.md
├── webpack.config.mjs
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18.18.0
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | init-env.mjs
--------------------------------------------------------------------------------
/.sentrycliignore:
--------------------------------------------------------------------------------
1 | src/app/**.test.js
2 |
--------------------------------------------------------------------------------
/src/__mocks__/fileMock.js:
--------------------------------------------------------------------------------
1 | export default ''
2 |
--------------------------------------------------------------------------------
/src/app/common/forms/index.js:
--------------------------------------------------------------------------------
1 | export * from './fields'
2 |
--------------------------------------------------------------------------------
/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 0.25%, last 2 versions, Firefox ESR, not dead
2 |
--------------------------------------------------------------------------------
/.huskyrc:
--------------------------------------------------------------------------------
1 | {
2 | "hooks": {
3 | "pre-commit": "lint-staged"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/store/index.js:
--------------------------------------------------------------------------------
1 | const reducers = {}
2 |
3 | export {
4 | reducers,
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/pages/auth/login/index.js:
--------------------------------------------------------------------------------
1 | import Login from './Login'
2 |
3 | export default Login
4 |
--------------------------------------------------------------------------------
/src/app/pages/auth/index.js:
--------------------------------------------------------------------------------
1 | import routes from './routes'
2 |
3 | export {
4 | routes,
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/pages/dashboard/index.js:
--------------------------------------------------------------------------------
1 | import routes from './routes'
2 |
3 | export {
4 | routes,
5 | }
6 |
--------------------------------------------------------------------------------
/src/img/ds-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/django-stars/frontend-skeleton/HEAD/src/img/ds-logo.png
--------------------------------------------------------------------------------
/src/img/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/django-stars/frontend-skeleton/HEAD/src/img/example.png
--------------------------------------------------------------------------------
/src/fonts/Lato/Lato-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/django-stars/frontend-skeleton/HEAD/src/fonts/Lato/Lato-Bold.woff
--------------------------------------------------------------------------------
/src/fonts/Lato/Lato-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/django-stars/frontend-skeleton/HEAD/src/fonts/Lato/Lato-Regular.woff
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "src/**/*.{jsx,js}": [
3 | "eslint"
4 | ],
5 | "src/**/*.scss": [
6 | "stylelint --syntax scss"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/test-setup.js:
--------------------------------------------------------------------------------
1 | import Enzyme from 'enzyme'
2 | import Adapter from 'enzyme-adapter-react-16'
3 |
4 | Enzyme.configure({ adapter: new Adapter() })
5 |
--------------------------------------------------------------------------------
/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/session/index.js:
--------------------------------------------------------------------------------
1 | import CheckAccess from './CheckAccess'
2 | export * as access from './access'
3 |
4 |
5 | export {
6 | CheckAccess,
7 | }
8 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
8 | )
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%=htmlWebpackPlugin.options.env.APP_NAME%>
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
11 |
12 | )
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/app/common/forms/inputs/SelectRangeInput.js:
--------------------------------------------------------------------------------
1 | import range from 'lodash/range'
2 | import PropTypes from 'prop-types'
3 | import SelectInput from 'shared/forms/inputs/SelectInput'
4 |
5 | SelectRangeInput.propTypes = {
6 | rangeStart: PropTypes.number.isRequired,
7 | rangeEnd: PropTypes.number.isRequired,
8 | rangeStep: PropTypes.number.isRequired,
9 | }
10 |
11 |
12 | export default function SelectRangeInput({ rangeStart, rangeEnd, rangeStep, ...restProps }) {
13 | return (
14 | { return { label: value, value } })}
16 | {...restProps}
17 | />
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/img/icons/ic-create.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/forms/inputs/TextAreaInput.js:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | TextAreaInput.propTypes = {
5 | inputClassName: PropTypes.string,
6 | placeholder: PropTypes.string,
7 | disabled: PropTypes.bool,
8 | required: PropTypes.bool,
9 | rows: PropTypes.number,
10 | cols: PropTypes.number,
11 | value: PropTypes.string,
12 | name: PropTypes.string,
13 | onChange: PropTypes.func.isRequired,
14 | }
15 |
16 | TextAreaInput.defaultProps = {
17 | inputClassName: 'input-custom',
18 | placeholder: '',
19 | disabled: false,
20 | required: false,
21 | rows: undefined,
22 | cols: undefined,
23 | value: '',
24 | name: undefined,
25 | }
26 |
27 |
28 | export default function TextAreaInput({
29 | onChange,
30 | inputClassName,
31 | ...props
32 | }) {
33 | const handleChange = useCallback((e) => onChange(e.target.value), [onChange])
34 | return (
35 |
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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/img/icons/ic-key.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------