Initial commit

This commit is contained in:
bourquecharles
2021-07-13 10:50:59 -04:00
commit 6d71ef7ff4
23 changed files with 24392 additions and 0 deletions

19
.editorconfig Normal file
View File

@@ -0,0 +1,19 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 100
trim_trailing_whitespace = true
[*.md]
max_line_length = 0
trim_trailing_whitespace = false
[{Makefile,**.mk}]
# Use tabs for indentation (Makefiles require tabs)
indent_style = tab
[*.scss]
indent_size = 2
indent_style = space

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
/src/assets
/build
/node_modules
.github

34
.eslintrc Normal file
View File

@@ -0,0 +1,34 @@
{
"extends": ["airbnb", "prettier"],
"plugins": ["prettier"],
"env": {
"browser": true,
"jest": true
},
"rules": {
"max-len": ["error", {"code": 150}],
"prefer-promise-reject-errors": ["off"],
"react/jsx-filename-extension": ["off"],
"react/prop-types": ["warn"],
"no-return-assign": ["off"],
"react/jsx-props-no-spreading": ["off"],
"react/destructuring-assignment": ["off"],
"no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"],
"react/jsx-one-expression-per-line": "off",
"react/jsx-wrap-multilines": "off",
"react/jsx-curly-newline": "off"
},
"settings": {
"import/resolver": {
"node": {
"paths": ["src"]
}
}
},
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"allowImportExportEverywhere": false,
"codeFrame": false
},
}

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
/src/assets
build
node_modules
.github

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"printWidth": 100,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true
}

29
LICENSE Normal file
View File

@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2021, Stephane Bourque
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# uCentral UI Library
## What is this?
The uCentral UI Library is a library that will be used for different micro services clients part of the uCentral program such as [uCentralGW UI](https://github.com/Telecominfraproject/wlan-cloud-ucentralgw-ui). To use the interface,
you either need to run it on your machine for development or install it.
### Development
Here are the instructions to use the librar Please install `npm` for the platform you are using.
```
git clone https://github.com/Telecominfraproject/wlan-cloud-ucentral-ui-libs
cd wlan-cloud-ucentral-ui-libs
npm install
```
### Install for use in other projects
```
npm install ucentral-libs
```

12
babel.config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-proposal-class-properties",
"transform-react-remove-prop-types",
"@babel/plugin-transform-react-inline-elements",
"@babel/plugin-transform-react-constant-elements"
]
}

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"*": ["*"]
}
},
"include": ["src"]
}

23550
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

85
package.json Normal file
View File

@@ -0,0 +1,85 @@
{
"name": "ucentral-libs",
"version": "0.8.9",
"main": "dist/index.js",
"source": "src/index.js",
"engines": {
"node": ">=10"
},
"files": [
"dist"
],
"peerDependencies": {
"@coreui/coreui": "^3.4.0",
"@coreui/icons": "^2.0.1",
"@coreui/icons-react": "^1.1.0",
"@coreui/react": "^3.4.6",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-paginate": "^7.1.3",
"react-router-dom": "^5.2.0",
"uuid": "^8.3.2"
},
"scripts": {
"format": "prettier --write 'src/**/*{.js,.scss}'",
"build": "webpack --mode production",
"eslint": "eslint 'src/**/*.js'",
"watch": "webpack --watch",
"fix": "eslint --fix 'src/**/*.js'"
},
"eslintConfig": {
"extends": "react-app"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,jsx}": [
"eslint",
"prettier --write"
]
},
"devDependencies": {
"@babel/core": "^7.14.6",
"@babel/plugin-proposal-class-properties": "^7.14.5",
"@babel/plugin-transform-react-constant-elements": "^7.14.5",
"@babel/plugin-transform-react-inline-elements": "^7.14.5",
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.14.7",
"@babel/preset-react": "^7.14.5",
"autoprefixer": "^10.2.6",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.2",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"babel-polyfill": "^6.26.0",
"babel-runtime": "^6.26.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^5.2.6",
"dotenv-webpack": "^6.0.4",
"eslint": "^7.29.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-prettier": "^7.2.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-loader": "^4.0.2",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"node-sass": "^5.0.0",
"path": "^0.12.7",
"prettier": "^2.3.2",
"sass-loader": "^11.1.1",
"style-loader": "^2.0.0",
"webpack": "^5.40.0",
"webpack-cli": "^4.7.2",
"webpack-dev-server": "^3.11.2",
"webpack-merge": "^5.8.0",
"webpack-node-externals": "^3.0.0"
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { CSelect } from '@coreui/react';
import PropTypes from 'prop-types';
const LanguageSwitcher = ({ i18n }) => (
<CSelect
custom
defaultValue={i18n.language.split('-')[0]}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
<option value="de">Deutsche</option>
<option value="es">Español</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="pt">Portugues</option>
</CSelect>
);
LanguageSwitcher.propTypes = {
i18n: PropTypes.instanceOf(Object).isRequired,
};
export default React.memo(LanguageSwitcher);

View File

@@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import {
CButton,
CCard,
CCardBody,
CCardGroup,
CCol,
CContainer,
CForm,
CInput,
CInputGroup,
CInputGroupPrepend,
CInputGroupText,
CRow,
CPopover,
CAlert,
CInvalidFeedback,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilUser, cilLockLocked, cilLink } from '@coreui/icons';
import PropTypes from 'prop-types';
import LanguageSwitcher from '../LanguageSwitcher';
import styles from './index.module.scss';
const LoginPage = ({ t, i18n, signIn, defaultConfig, error, setHadError }) => {
const [userId, setUsername] = useState('');
const [password, setPassword] = useState('');
const [uCentralSecUrl, setUCentralSecUrl] = useState('');
const [emptyUsername, setEmptyUsername] = useState(false);
const [emptyPassword, setEmptyPassword] = useState(false);
const [emptyGateway, setEmptyGateway] = useState(false);
const placeholderUrl = 'uCentralSec URL (ex: https://your-url:port)';
const formValidation = () => {
setHadError(false);
let isSuccessful = true;
if (userId.trim() === '') {
setEmptyUsername(true);
isSuccessful = false;
}
if (password.trim() === '') {
setEmptyPassword(true);
isSuccessful = false;
}
if (uCentralSecUrl.trim() === '') {
setEmptyGateway(true);
isSuccessful = false;
}
return isSuccessful;
};
const onKeyDown = (event) => {
if (event.code === 'Enter' && formValidation()) {
signIn({ userId, password }, uCentralSecUrl);
}
};
useEffect(() => {
if (emptyUsername) setEmptyUsername(false);
}, [userId]);
useEffect(() => {
if (emptyPassword) setEmptyPassword(false);
}, [password]);
useEffect(() => {
if (emptyGateway) setEmptyGateway(false);
}, [uCentralSecUrl]);
useEffect(() => {
setUCentralSecUrl(defaultConfig.DEFAULT_UCENTRALSEC_URL);
}, [defaultConfig]);
return (
<div className="c-app c-default-layout flex-row align-items-center">
<CContainer>
<CRow className="justify-content-center">
<CCol md="8">
<img
className={[styles.logo, 'c-sidebar-brand-full'].join(' ')}
src="assets/OpenWiFi_LogoLockup_DarkGreyColour.svg"
alt="OpenWifi"
/>
<CCardGroup>
<CCard className="p-4">
<CCardBody>
<CForm onKeyDown={onKeyDown}>
<h1>{t('login.login')}</h1>
<p className="text-muted">{t('login.sign_in_to_account')}</p>
<CInputGroup className="mb-3">
<CPopover content={t('login.username')}>
<CInputGroupPrepend>
<CInputGroupText>
<CIcon name="cilUser" content={cilUser} />
</CInputGroupText>
</CInputGroupPrepend>
</CPopover>
<CInput
invalid={emptyUsername}
autoFocus
required
type="text"
placeholder={t('login.username')}
autoComplete="username"
onChange={(event) => setUsername(event.target.value)}
/>
<CInvalidFeedback className="help-block">
{t('login.please_enter_username')}
</CInvalidFeedback>
</CInputGroup>
<CInputGroup className="mb-4">
<CPopover content={t('login.password')}>
<CInputGroupPrepend>
<CInputGroupText>
<CIcon content={cilLockLocked} />
</CInputGroupText>
</CInputGroupPrepend>
</CPopover>
<CInput
invalid={emptyPassword}
required
type="password"
placeholder={t('login.password')}
autoComplete="current-password"
onChange={(event) => setPassword(event.target.value)}
/>
<CInvalidFeedback className="help-block">
{t('login.please_enter_password')}
</CInvalidFeedback>
</CInputGroup>
<CInputGroup className="mb-4" hidden={!defaultConfig.ALLOW_UCENTRALSEC_CHANGE}>
<CPopover content={t('login.url')}>
<CInputGroupPrepend>
<CInputGroupText>
<CIcon name="cilLink" content={cilLink} />
</CInputGroupText>
</CInputGroupPrepend>
</CPopover>
<CInput
invalid={emptyGateway}
type="text"
required
placeholder={placeholderUrl}
value={uCentralSecUrl}
autoComplete="gateway-url"
onChange={(event) => setUCentralSecUrl(event.target.value)}
/>
<CInvalidFeedback className="help-block">
{t('login.please_enter_gateway')}
</CInvalidFeedback>
</CInputGroup>
<CRow>
<CCol>
<CAlert show={error} color="danger">
{t('login.login_error')}
</CAlert>
</CCol>
</CRow>
<CRow>
<CCol xs="6">
<CButton
color="primary"
className="px-4"
onClick={() =>
formValidation() ? signIn({ userId, password }, uCentralSecUrl) : null
}
>
{t('login.login')}
</CButton>
</CCol>
<CCol xs="6">
<div className={styles.languageSwitcher}>
<LanguageSwitcher i18n={i18n} />
</div>
</CCol>
</CRow>
</CForm>
</CCardBody>
</CCard>
</CCardGroup>
</CCol>
</CRow>
</CContainer>
</div>
);
};
LoginPage.propTypes = {
t: PropTypes.func.isRequired,
i18n: PropTypes.instanceOf(Object).isRequired,
signIn: PropTypes.func.isRequired,
defaultConfig: PropTypes.instanceOf(Object).isRequired,
error: PropTypes.bool.isRequired,
setHadError: PropTypes.func.isRequired,
};
export default React.memo(LoginPage);

View File

@@ -0,0 +1,9 @@
.logo {
padding-left: 17%;
width: 85%;
}
.languageSwitcher {
float: right;
width: 150px;
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactPaginate from 'react-paginate';
import { CCard, CCardHeader, CSelect, CCol, CRow, CCardBody, CDataTable } from '@coreui/react';
const UserListTable = ({
t,
users,
loading,
usersPerPage,
setUsersPerPage,
pageCount,
setPage,
}) => {
const fields = [
{ key: 'id', label: t('common.config_id'), _style: { width: '5%' } },
{ key: 'name', label: t('common.config_id'), _style: { width: '5%' } },
{ key: 'email', label: t('common.config_id'), _style: { width: '5%' } },
{ key: 'userRole', label: t('common.config_id'), _style: { width: '5%' } },
{ key: 'id', label: t('common.config_id'), _style: { width: '5%' } },
{
key: 'user_actions',
label: '',
_style: { width: '3%' },
sorter: false,
filter: false,
},
];
return (
<CCard>
<CCardHeader>
<CRow>
<CCol />
<CCol xs={1}>
<CSelect
custom
defaultValue={usersPerPage}
onChange={(e) => setUsersPerPage(e.target.value)}
disabled={loading}
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</CSelect>
</CCol>
</CRow>
</CCardHeader>
<CCardBody>
<CDataTable items={users} fields={fields} loading={loading} hover border />
<ReactPaginate
previousLabel="← Previous"
nextLabel="Next →"
pageCount={pageCount}
onPageChange={setPage}
breakClassName="page-item"
breakLinkClassName="page-link"
containerClassName="pagination"
pageClassName="page-item"
pageLinkClassName="page-link"
previousClassName="page-item"
previousLinkClassName="page-link"
nextClassName="page-item"
nextLinkClassName="page-link"
activeClassName="active"
/>
</CCardBody>
</CCard>
);
};
UserListTable.propTypes = {
t: PropTypes.func.isRequired,
users: PropTypes.arrayOf(Object).isRequired,
loading: PropTypes.bool.isRequired,
usersPerPage: PropTypes.string.isRequired,
setUsersPerPage: PropTypes.func.isRequired,
pageCount: PropTypes.number.isRequired,
setPage: PropTypes.func.isRequired,
};
export default React.memo(UserListTable);

12
src/index.js Normal file
View File

@@ -0,0 +1,12 @@
// Layout
export { default as Sidebar } from './layout/Sidebar';
export { default as Header } from './layout/Header';
export { default as Footer } from './layout/Footer';
export { default as PageContainer } from './layout/PageContainer';
// Components
export { default as LanguageSwitcher } from './components/LanguageSwitcher';
export { default as UserListTable } from './components/UserListTable';
// Pages
export { default as LoginPage } from './components/LoginPage';

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { CFooter } from '@coreui/react';
import PropTypes from 'prop-types';
const TheFooter = ({ t, version }) => (
<CFooter fixed={false}>
<div>
{t('footer.version')} {version}
</div>
<div className="mfs-auto">
<span className="mr-1">{t('footer.powered_by')}</span>
<a href="https://coreui.io/react" target="_blank" rel="noopener noreferrer">
{t('footer.coreui_for_react')}
</a>
</div>
</CFooter>
);
TheFooter.propTypes = {
t: PropTypes.func.isRequired,
version: PropTypes.string.isRequired,
};
export default React.memo(TheFooter);

View File

@@ -0,0 +1,82 @@
import React, { useState, useEffect } from 'react';
import {
CHeader,
CToggler,
CHeaderBrand,
CHeaderNav,
CSubheader,
CBreadcrumbRouter,
CLink,
CPopover,
} from '@coreui/react';
import PropTypes from 'prop-types';
import CIcon from '@coreui/icons-react';
import { cilAccountLogout } from '@coreui/icons';
import LanguageSwitcher from '../../components/LanguageSwitcher';
const Header = ({ showSidebar, setShowSidebar, routes, t, i18n, logout, authToken, endpoints }) => {
const [translatedRoutes, setTranslatedRoutes] = useState(routes);
const toggleSidebar = () => {
const val = [true, 'responsive'].includes(showSidebar) ? false : 'responsive';
setShowSidebar(val);
};
const toggleSidebarMobile = () => {
const val = [false, 'responsive'].includes(showSidebar) ? true : 'responsive';
setShowSidebar(val);
};
useEffect(() => {
setTranslatedRoutes(routes.map(({ name, ...rest }) => ({ ...rest, name: t(name) })));
}, [i18n.language]);
return (
<CHeader withSubheader>
<CToggler inHeader className="ml-md-3 d-lg-none" onClick={toggleSidebarMobile} />
<CToggler inHeader className="ml-3 d-md-down-none" onClick={toggleSidebar} />
<CHeaderBrand className="mx-auto d-lg-none" to="/">
<CIcon name="logo" height="48" alt="Logo" />
</CHeaderBrand>
<CHeaderNav className="d-md-down-none mr-auto" />
<CHeaderNav className="px-3">
<LanguageSwitcher i18n={i18n} />
</CHeaderNav>
<CHeaderNav className="px-3">
<CPopover content={t('common.logout')}>
<CLink className="c-subheader-nav-link">
<CIcon
name="cilAccountLogout"
content={cilAccountLogout}
size="2xl"
onClick={() => logout(authToken, endpoints.ucentralsec)}
/>
</CLink>
</CPopover>
</CHeaderNav>
<CSubheader className="px-3 justify-content-between">
<CBreadcrumbRouter
className="border-0 c-subheader-nav m-0 px-0 px-md-3"
routes={translatedRoutes}
/>
</CSubheader>
</CHeader>
);
};
Header.propTypes = {
showSidebar: PropTypes.string.isRequired,
setShowSidebar: PropTypes.func.isRequired,
routes: PropTypes.arrayOf(Object).isRequired,
t: PropTypes.func.isRequired,
i18n: PropTypes.instanceOf(Object).isRequired,
logout: PropTypes.func.isRequired,
authToken: PropTypes.string.isRequired,
endpoints: PropTypes.instanceOf(Object).isRequired,
};
export default React.memo(Header);

View File

@@ -0,0 +1,48 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { Suspense } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { v4 as createUuid } from 'uuid';
import { CContainer, CFade } from '@coreui/react';
import PropTypes from 'prop-types';
const loading = (
<div className="pt-3 text-center">
<div className="sk-spinner sk-spinner-pulse" />
</div>
);
const PageContainer = ({ t, routes, redirectTo }) => (
<main className="c-main">
<CContainer fluid>
<Suspense fallback={loading}>
<Switch>
{routes.map(
(route) =>
route.component && (
<Route
key={createUuid()}
path={route.path}
exact={route.exact}
name={t(route.name)}
render={(props) => (
<CFade>
<route.component {...props} />
</CFade>
)}
/>
),
)}
<Redirect from="/" to={redirectTo} />
</Switch>
</Suspense>
</CContainer>
</main>
);
PageContainer.propTypes = {
t: PropTypes.func.isRequired,
routes: PropTypes.arrayOf(Object).isRequired,
redirectTo: PropTypes.string.isRequired,
};
export default React.memo(PageContainer);

View File

@@ -0,0 +1,53 @@
import React from 'react';
import {
CCreateElement,
CSidebar,
CSidebarBrand,
CSidebarNav,
CSidebarNavDivider,
CSidebarNavTitle,
CSidebarMinimizer,
CSidebarNavDropdown,
CSidebarNavItem,
} from '@coreui/react';
import PropTypes from 'prop-types';
import styles from './index.module.scss';
const Sidebar = ({ showSidebar, setShowSidebar, logo, options, redirectTo }) => (
<CSidebar show={showSidebar} onShowChange={(val) => setShowSidebar(val)}>
<CSidebarBrand className="d-md-down-none" to={redirectTo}>
<img
className={[styles.sidebarImgFull, 'c-sidebar-brand-full'].join(' ')}
src={logo}
alt="OpenWifi"
/>
<img
className={[styles.sidebarImgMinimized, 'c-sidebar-brand-minimized'].join(' ')}
src={logo}
alt="OpenWifi"
/>
</CSidebarBrand>
<CSidebarNav>
<CCreateElement
items={options}
components={{
CSidebarNavDivider,
CSidebarNavDropdown,
CSidebarNavItem,
CSidebarNavTitle,
}}
/>
</CSidebarNav>
<CSidebarMinimizer className="c-d-md-down-none" />
</CSidebar>
);
Sidebar.propTypes = {
showSidebar: PropTypes.string.isRequired,
setShowSidebar: PropTypes.func.isRequired,
logo: PropTypes.string.isRequired,
options: PropTypes.arrayOf(Object).isRequired,
redirectTo: PropTypes.string.isRequired,
};
export default React.memo(Sidebar);

View File

@@ -0,0 +1,9 @@
.sidebarImgFull {
height: 100px;
width: 230px;
}
.sidebarImgMinimized {
height: 75px;
width: 75px;
}

59
webpack.config.js Normal file
View File

@@ -0,0 +1,59 @@
/* eslint-disable import/no-extraneous-dependencies */
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/dist/',
filename: 'index.js',
library: 'ucentral-libs',
libraryTarget: 'umd',
umdNamedDefine: true,
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: ['babel-loader', 'eslint-loader'],
},
{
test: /\.(css|scss)$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]___[hash:base64:5]',
},
sourceMap: true,
},
},
{
loader: 'sass-loader',
},
],
},
],
},
resolve: {
modules: [path.resolve('./node_modules'), path.resolve('./src')],
alias: {
src: path.resolve(__dirname, './src'),
react: path.resolve(__dirname, './node_modules/react'),
'react-dom': path.resolve(__dirname, './node_modules/react-dom'),
'prop-types': path.resolve(__dirname, './node_modules/prop-types'),
'react-router-dom': path.resolve(__dirname, './node_modules/react-router-dom'),
},
},
plugins: [new CleanWebpackPlugin()],
};