diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a7554c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +# dependencies +/node_modules \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1140ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +/coverage + +# production +/build +/docker/files/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +test-results.json + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..31be39d --- /dev/null +++ b/src/App.css @@ -0,0 +1,32 @@ +.App { + text-align: center; +} + +.App-logo { + animation: App-logo-spin infinite 20s linear; + height: 80px; +} + +.App-header { + background-color: #222; + height: 150px; + padding: 20px; + color: white; +} + +.App-title { + font-size: 1.5em; +} + +.App-intro { + font-size: large; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..899d872 --- /dev/null +++ b/src/App.js @@ -0,0 +1,104 @@ +import React, { Component } from "react"; +import { Provider } from "react-redux"; +import { PersistGate } from "redux-persist/integration/react"; +import { I18nextProvider } from "react-i18next"; + +/** + * Look here for more information on ConnectedRouter + * (can be easily confused with the old react-router-redux)... + * https://github.com/ReactTraining/react-router/tree/master/packages/react-router-redux + */ +import { ConnectedRouter } from "react-router-redux"; +import { Route } from "react-router"; + +import { changeLanguage, i18n } from "./i18n"; +import configureStore from "./redux/createStore"; + +import { MuiThemeProvider, createMuiTheme } from "material-ui/styles"; + +import Login from "./components/Login"; +import UserList from "./components/UserList"; +import UserCreateScreen from "./components/UserCreateScreen"; +import RoleCreateScreen from "./components/RoleCreateScreen"; +import LandingPage from "./components/LandingPage"; +import GroupCreateScreen from "./components/GroupCreateScreen"; +import RoleList from "./components/RoleList"; +import GroupList from "./components/GroupList"; +import ContactList from "./components/ContactList"; + +const { store, history, persistor } = configureStore(); +const theme = createMuiTheme({ + custom: { + drawer: { + width: 240, + closedWidth: 90 + } + } +}); + +class App extends Component { + constructor(props) { + super(props); + //Use a fixed language in development for now + if (process.env.NODE_ENV === "development") { + changeLanguage("en"); + //console.log(i18n); + } + } + + render() { + return ( + + + + + +
+ + + + + + + + + + + + + +
+
+
+
+
+
+ ); + } +} + +export default App; diff --git a/src/App.test.js b/src/App.test.js new file mode 100644 index 0000000..989fb85 --- /dev/null +++ b/src/App.test.js @@ -0,0 +1,8 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./App"; +import { shallow } from "enzyme"; + +it.skip("renders without crashing", () => { + shallow(); +}); diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js new file mode 100644 index 0000000..8bc323c --- /dev/null +++ b/src/components/Checkbox.js @@ -0,0 +1,60 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { withStyles } from "material-ui/styles"; +import green from "material-ui/colors/green"; +import { FormGroup, FormControlLabel } from "material-ui/Form"; +import Checkbox from "material-ui/Checkbox"; + +const styles = { + root: { + color: green[600], + "&$checked": { + color: green[500] + } + }, + checked: {}, + size: { + width: 40, + height: 40 + }, + sizeIcon: { + fontSize: 20 + } +}; + +/** + * state = { + checkedA: true, + }; + + handleChange = name => event => { + this.setState({ [name]: event.target.checked }); + }; + */ +class CheckboxLabels extends React.Component { + render() { + const { label, checked, name, onChange } = this.props; + + return ( + + onChange(name, event.target.checked)} + value="checked" + color="primary" + /> + } + label={label} + /> + + ); + } +} + +CheckboxLabels.propTypes = { + classes: PropTypes.object.isRequired +}; + +export default withStyles(styles)(CheckboxLabels); diff --git a/src/components/ContactList.js b/src/components/ContactList.js new file mode 100644 index 0000000..a912e7a --- /dev/null +++ b/src/components/ContactList.js @@ -0,0 +1,207 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { withStyles } from "material-ui/styles"; +import Table, { + TableBody, + TableCell, + TableHead, + TableRow, + TableFooter, + TablePagination +} from "material-ui/Table"; +import { connect } from "react-redux"; +import { fetchList } from "../redux/actions"; +import { getItems, getItemById } from "../redux/selectors"; +import IconButton from "material-ui/IconButton"; +import KeyboardArrowLeft from "material-ui-icons/KeyboardArrowLeft"; +import KeyboardArrowRight from "material-ui-icons/KeyboardArrowRight"; +import Screen from "./Screen"; +import { entity } from "../lib/entity"; +import { apiMethod } from "../config"; + +const actionsStyles = theme => ({ + root: { + flexShrink: 0, + color: theme.palette.text.secondary, + marginLeft: theme.spacing.unit * 2.5 + } +}); + +class TablePaginationActions extends React.Component { + handleBackButtonClick = event => { + this.props.onChangePage(event, this.props.page - 1); + }; + + handleNextButtonClick = event => { + this.props.onChangePage(event, this.props.page + 1); + }; + + render() { + const { classes, count, page, rowsPerPage, theme } = this.props; + + return ( +
+ + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Next Page" + > + {theme.direction === "rtl" ? ( + + ) : ( + + )} + +
+ ); + } +} + +TablePaginationActions.propTypes = { + classes: PropTypes.object.isRequired, + count: PropTypes.number.isRequired, + onChangePage: PropTypes.func.isRequired, + page: PropTypes.number.isRequired, + rowsPerPage: PropTypes.number.isRequired, + theme: PropTypes.object.isRequired +}; + +const TablePaginationActionsWrapped = withStyles(actionsStyles, { + withTheme: true +})(TablePaginationActions); + +const styles = theme => ({ + root: { + width: "100%", + marginTop: theme.spacing.unit * 3, + overflowX: "auto" + }, + table: { + minWidth: "20%", + maxWidth: "90%" + }, + tableWrapper: { + overflowX: "auto" + } +}); + +class ContactList extends Component { + componentWillMount = () => { + const { fetchContacts } = this.props; + fetchContacts(); + }; + + constructor(props, context) { + super(props, context); + this.state = { + page: 0, + rowsPerPage: 5 + }; + } + + handleChangePage = (event, page) => { + this.setState({ page }); + }; + + handleChangeRowsPerPage = event => { + this.setState({ rowsPerPage: event.target.value }); + }; + + render() { + const { classes, users } = this.props; + const { rowsPerPage, page } = this.state; + + //console.log("Render a component", users, this.props.user(6)); + return ( + +

Übersicht aller Mitglieder

+ {users ? ( +
+ + + + Mitgliedsnummer + Name + Email + Rollen + + + + {users + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map(user => { + return ( + + {user.id} + + {user.firstName} {user.lastName} + + {user.email} + + {user.roles ? ( +
+ {user.roles.map(role => { + return

{role.name}

; + })} +
+ ) : null} +
+
+ ); + })} +
+ + + + + +
+
+ ) : ( +
Es existieren keine Mitglieder!
+ )} +
+ ); + } +} + +ContactList.propTypes = { + classes: PropTypes.object.isRequired +}; + +const mapStateToProps = state => { + return { + users: getItems(apiMethod.list)(entity.contact)(state), + user: id => getItemById(apiMethod.list)(entity.contact)(id)(state) + }; +}; + +const mapDispatchToProps = { + fetchContacts: fetchList(entity.contact) +}; + +export const ConnectedContactList = connect( + mapStateToProps, + mapDispatchToProps +)(ContactList); + +export default withStyles(styles)(ConnectedContactList); diff --git a/src/components/GroupCreateForm.js b/src/components/GroupCreateForm.js new file mode 100644 index 0000000..40816b0 --- /dev/null +++ b/src/components/GroupCreateForm.js @@ -0,0 +1,183 @@ +import { Field, reduxForm } from "redux-form"; +import { withStyles } from "material-ui/styles/index"; +import { Link } from "react-router-dom"; +import React from "react"; +import PropTypes from "prop-types"; +import Typography from "material-ui/Typography"; +import Paper from "material-ui/Paper"; +import { TextField, Button } from "material-ui"; +import "react-select/dist/react-select.css"; +import ReactSelect from "./ReactSelect"; +import { translate } from "react-i18next"; + +const styles = theme => ({ + chip: { + margin: theme.spacing.unit / 4 + }, + root: theme.mixins.gutters({ + display: "flex", + flexDirection: "column", + alignItems: "center", + paddingTop: 16, + paddingBottom: 16, + margin: `${theme.spacing.unit * 3}px auto 0`, + width: 700 + }), + formColumn: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start" + }, + formRow: { + display: "flex", + flexDirection: "row", + width: "100%" + }, + formItem: { + margin: theme.spacing.unit, + width: "100%" + }, + buttonsRow: { + display: "flex", + flexDirection: "row", + alignItems: "center", + margin: 25 + }, + buttonRow: { + display: "flex", + flexDirection: "row", + margin: 5 + }, + cancelButton: { + "text-decoration": "none" + } +}); + +const renderTextField = ({ input, meta: { touched, error }, ...rest }) => ( + +); + +export class GroupCreateForm extends React.Component { + render() { + const { + classes, + handleDelete, + handleSubmit, + canSubmit, + users, + contacts, + multiUsers, + multiContacts, + multiResponsibles, + singlePermission, + permissionToChoose, + handleChange, + handleChangeSingle, + id, + t + } = this.props; + var validID = !!id || id === 0; + return ( +
+ + + {!!validID + ? t("group.edit_group_header") + : t("group.create_group_header")} + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ + + +
+ {!!validID ? ( +
+ +
+ ) : null} +
+ +
+
+
+
+ ); + } +} + +GroupCreateForm.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired, + handleChangeSingle: PropTypes.func.isRequired, + handleChange: PropTypes.func.isRequired +}; + +export default reduxForm({ form: "GroupCreateForm", enableReinitialize: true })( + withStyles(styles)(translate()(GroupCreateForm)) +); diff --git a/src/components/GroupCreateForm.test.js b/src/components/GroupCreateForm.test.js new file mode 100644 index 0000000..866aecd --- /dev/null +++ b/src/components/GroupCreateForm.test.js @@ -0,0 +1,21 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { GroupCreateForm } from "./GroupCreateForm"; + +it("renders without crashing", () => { + shallow( + + ); +}); diff --git a/src/components/GroupCreateScreen.js b/src/components/GroupCreateScreen.js new file mode 100644 index 0000000..377db73 --- /dev/null +++ b/src/components/GroupCreateScreen.js @@ -0,0 +1,312 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; +import { CircularProgress, withStyles, Snackbar } from "material-ui"; +import { permission } from "../config/groupPermissionTypes"; + +import GroupCreateForm from "./GroupCreateForm"; +import { + fetchList, + fetchCreateByObject, + fetchDeleteById, + fetchUpdateByObject, + fetchDetailById, + resetGroupCreateState +} from "../redux/actions"; +import { + getUsers, + getGroupFormValues, + getTimeFetchedUsers, + isFetchingUsers, + isCreatingGroup, + getCreateGroupTimeFetched, + getCreateGroupError, + isEditedGroup, + getGroupById, + isLoadedGroups, + isLoadedGroup, + isGroupWithErrors +} from "../redux/selectors"; +import Screen from "./Screen"; +import PropTypes from "prop-types"; +import { entity } from "../lib/entity"; +import { getContacts } from "../redux/selectors/contactsSelectors"; + +const styles = theme => ({ + progress: { + margin: theme.spacing.unit * 2 + }, + progressContainer: { + width: "100%", + flex: 1, + display: "flex", + flexDirection: "row", + justifyContent: "center" + } +}); + +export class GroupCreateScreen extends Component { + state = { + singlePermission: "", + multiUsers: [], + multiContacts: [], + multiResponsibles: [], + groupName: "", + groupDescription: "", + setFormValues: false + }; + + handleChange = name => value => { + this.setState({ + [name]: !!value ? value.split(",").map(v => parseInt(v, 10)) : [] + }); + }; + + handleChangeSingle = name => value => { + this.setState({ + [name]: value + }); + }; + + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleDelete = this.handleDelete.bind(this); + } + + componentWillMount = () => { + const { fetchUsers, fetchContacts, id, fetchGroup } = this.props; + fetchUsers({}); + fetchContacts({}); + if (!!id || id === 0) { + fetchGroup(id); + } + }; + + componentWillUnmount = () => { + this.setState({ + groupName: "", + singlePermission: "", + multiUsers: [], + multiContacts: [], + multiResponsibles: [], + setFormValues: false + }); + const { resetGroupCreateState } = this.props; + resetGroupCreateState(); + }; + + componentWillReceiveProps = props => { + const { id, groupForEdit, isLoadedGroup, isLoadedGroups } = props; + const { setFormValues } = this.state; + + if ( + (parseInt(id, 10) || parseInt(id, 10) === 0) && + !!isLoadedGroup && + !!isLoadedGroups && + !setFormValues + ) { + this.setState({ + groupName: groupForEdit.name || "", + singlePermission: groupForEdit.permission || "", + multiUsers: groupForEdit.users || [], + multiContacts: groupForEdit.contacts || [], + multiResponsibles: groupForEdit.responsibles || [], + setFormValues: true + }); + } + }; + + handleDelete = () => { + const { fetchDeleteGroup, id } = this.props; + fetchDeleteGroup(id); + }; + + handleSubmit = () => { + const { + fetchCreateGroup, + fetchEditGroup, + values: { name }, + id + } = this.props; + + const { + singlePermission, + multiUsers, + multiContacts, + multiResponsibles + } = this.state; + + !!(id || id === 0) + ? fetchEditGroup({ + id, + name, + permission: singlePermission, + users: multiUsers, + contacts: multiContacts, + responsibles: multiResponsibles + }) + : fetchCreateGroup({ + name, + permission: singlePermission, + users: multiUsers, + contacts: multiContacts, + responsibles: multiResponsibles + }); + }; + + //todo: add canSubmit + render() { + const { + users, + contacts, + isLoaded, + classes, + isSent, + isEdited, + isGroupWithErrors, + values, + id, + t + } = this.props; + const { + groupName, + groupDescription, + multiUsers, + multiContacts, + multiResponsibles, + singlePermission + } = this.state; + + var alert = null; + if (!!id) { + alert = isEdited ? ( +
+ {t("group.group_is_edited_msg")} +
+ ) : null; + } else { + alert = isEdited ? ( +
+ {t("group.group_created_msg")} +
+ ) : null; + } + + const mappedUsers = (users || []).map(user => { + return { value: user.id, label: user.username }; + }); + + const mappedContacts = (contacts || []).map(contact => { + return { + value: contact.id, + label: contact.lastName + ", " + contact.firstName + }; + }); + + const mappedPermission = permission.map(permission => { + return { value: permission.value, label: permission.label }; + }); + + const loadingScreen = ( + +
+ +
+
+ ); + + if (id) { + if (!isLoaded) { + return loadingScreen; + } + } + + if (!!id && !isSent && !isLoaded) { + return loadingScreen; + } + + const canSubmit = values && values.name ? true : false; + + return ( + + (this.form = form)} + onSubmit={this.handleSubmit} + canSubmit={canSubmit} + id={id} + /> + {alert} + {t("group.error_message")}} + /> + + ); + } +} + +const mapStateToProps = (state, ownProps) => { + //todo remove parseInts from above + const id = parseInt(ownProps.match.params.id, 10); + const isFetching = isFetchingUsers(state); + //todo replace with timeFetchedUsers + const timeFetchedUsers = getTimeFetchedUsers(state); + const isCreating = isCreatingGroup(state); + const isEdited = isEditedGroup(state); + const groupCreatedTime = getCreateGroupTimeFetched(state); + const createGroupError = getCreateGroupError(state); + return { + users: getUsers(state), + contacts: getContacts(state), + group: state.group.create, + values: getGroupFormValues(state), + isFetching, + isLoaded: !isFetching && !!timeFetchedUsers, + isSent: !isCreating && !!groupCreatedTime && !createGroupError, + isEdited, + id, + groupForEdit: getGroupById(id)(state), + isLoadedGroups: isLoadedGroups(state), + isLoadedGroup: isLoadedGroup(state), + isGroupWithErrors: isGroupWithErrors(state) + }; +}; + +const mapDispatchToProps = { + fetchUsers: fetchList(entity.user), + fetchContacts: fetchList(entity.contact), + fetchCreateGroup: fetchCreateByObject(entity.group), + fetchGroup: fetchDetailById(entity.group), + fetchEditGroup: fetchUpdateByObject(entity.group), + resetGroupCreateState, + fetchDeleteGroup: fetchDeleteById(entity.group) +}; + +GroupCreateScreen.propTypes = { + t: PropTypes.func.isRequired +}; + +export const ConnectedCreateScreen = connect( + mapStateToProps, + mapDispatchToProps +)(GroupCreateScreen); + +export default withStyles(styles)(translate()(ConnectedCreateScreen)); diff --git a/src/components/GroupList.js b/src/components/GroupList.js new file mode 100644 index 0000000..3089a86 --- /dev/null +++ b/src/components/GroupList.js @@ -0,0 +1,327 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router-dom"; +import { withStyles } from "material-ui/styles"; +import Table, { + TableBody, + TableCell, + TableFooter, + TablePagination, + TableRow, + TableHead +} from "material-ui/Table"; +import Paper from "material-ui/Paper"; +import IconButton from "material-ui/IconButton"; +import FirstPageIcon from "material-ui-icons/FirstPage"; +import KeyboardArrowLeft from "material-ui-icons/KeyboardArrowLeft"; +import KeyboardArrowRight from "material-ui-icons/KeyboardArrowRight"; +import LastPageIcon from "material-ui-icons/LastPage"; +import Screen from "./Screen"; +import { getGroupById, getGroups, getUsers } from "../redux/selectors"; +import { fetchDeleteById, fetchList } from "../redux/actions"; +import { entity } from "../lib/entity"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; +import CreateIcon from "material-ui-icons/Create"; +import DeleteIcon from "material-ui-icons/Delete"; +import Typography from "material-ui/Typography"; +import { getContacts } from "../redux/selectors/contactsSelectors"; + +const actionsStyles = theme => ({ + root: { + flexShrink: 0, + color: theme.palette.text.secondary, + marginLeft: theme.spacing.unit * 2.5 + } +}); + +class TablePaginationActions extends React.Component { + handleFirstPageButtonClick = event => { + this.props.onChangePage(event, 0); + }; + + handleBackButtonClick = event => { + this.props.onChangePage(event, this.props.page - 1); + }; + + handleNextButtonClick = event => { + this.props.onChangePage(event, this.props.page + 1); + }; + + handleLastPageButtonClick = event => { + this.props.onChangePage( + event, + Math.max(0, Math.ceil(this.props.count / this.props.rowsPerPage) - 1) + ); + }; + + render() { + const { classes, count, page, rowsPerPage, theme } = this.props; + + return ( +
+ + {theme.direction === "rtl" ? : } + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Next Page" + > + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Last Page" + > + {theme.direction === "rtl" ? : } + +
+ ); + } +} + +TablePaginationActions.propTypes = { + classes: PropTypes.object.isRequired, + count: PropTypes.number.isRequired, + onChangePage: PropTypes.func.isRequired, + page: PropTypes.number.isRequired, + rowsPerPage: PropTypes.number.isRequired, + theme: PropTypes.object.isRequired +}; + +const TablePaginationActionsWrapped = withStyles(actionsStyles, { + withTheme: true +})(TablePaginationActions); + +const styles = theme => ({ + root: { + width: "100%", + marginTop: theme.spacing.unit * 3, + overflowX: "auto", + padding: "24px", + "flex-grow": "1", + "background-color": "#fafafa" + }, + table: { + minWidth: "20%", + maxWidth: "90%" + }, + tableWrapper: { + overflowX: "auto", + padding: "24px", + "flex-grow": "1", + "background-color": "#fafafa" + } +}); + +class GroupList extends React.Component { + constructor(props, context) { + super(props, context); + this.handleDelete = this.handleDelete.bind(this); + + this.state = { + page: 0, + rowsPerPage: 5 + }; + } + + componentWillMount = () => { + const { fetchGroups, fetchUsers, fetchContacts } = this.props; + fetchGroups(); + fetchUsers(); + fetchContacts(); + }; + + handleChangePage = (event, page) => { + this.setState({ page }); + }; + + handleChangeRowsPerPage = event => { + this.setState({ rowsPerPage: event.target.value }); + }; + + handleDelete = id => { + const { fetchDeleteGroup, fetchGroups } = this.props; + fetchDeleteGroup(id); + fetchGroups(); + }; + + render() { + const { classes, groups, t, users, contacts } = this.props; + const { rowsPerPage, page } = this.state; + const emptyRows = + rowsPerPage - + Math.min(rowsPerPage, Object.keys(groups).length - page * rowsPerPage); + + const newGroups = groups.map(group => + Object.assign({}, group, { + users: !!group.users + ? users.filter(u => group.users.includes(u.id)) + : [], + contacts: !!group.contacts + ? contacts.filter(contact => group.contacts.includes(contact.id)) + : [], + responsibles: !!group.responsibles + ? contacts.filter(resp => group.responsibles.includes(resp.id)) + : [] + }) + ); + + return ( + + + {t("group_list.header")} + + +
+ + + + {t("group_list.name")} + {t("group_list.permission")} + + {t("group_list.users")} + + + {t("group_list.contacts")} + + + {t("group_list.responsibles")} + + {t("group_list.edit")} + {t("group_list.delete")} + + + + {newGroups + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map(group => { + return ( + + {group.name} + {group.permission} + + {group.users ? ( +
+ {group.users.map(user => { + return

{user.username}

; + })} +
+ ) : null} +
+ + {group.contacts ? ( +
+ {group.contacts.map(contact => { + return ( +

{`${contact.lastName}, ${ + contact.firstName + }`}

+ ); + })} +
+ ) : null} +
+ + {group.responsibles ? ( +
+ {group.responsibles.map(responsibility => { + return ( +

{`${ + responsibility.lastName + }, ${responsibility.firstName}`}

+ ); + })} +
+ ) : null} +
+ + + + + + +
this.handleDelete(group.id)} + > + +
+
+
+ ); + })} + {emptyRows > 0 && ( + + + + )} +
+ + + + + +
+
+
+
+ ); + } +} + +const mapStateToProps = state => { + return { + groups: getGroups(state), + users: getUsers(state), + contacts: getContacts(state), + group: id => getGroupById(id)(state) + }; +}; + +const mapDispatchToProps = { + fetchGroups: fetchList(entity.group), + fetchUsers: fetchList(entity.user), + fetchContacts: fetchList(entity.contact), + fetchDeleteGroup: fetchDeleteById(entity.group) +}; + +GroupList.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +export const ConnectedGroupList = connect(mapStateToProps, mapDispatchToProps)( + GroupList +); + +export default withStyles(styles)(translate()(ConnectedGroupList)); diff --git a/src/components/LandingPage.js b/src/components/LandingPage.js new file mode 100644 index 0000000..db6ab32 --- /dev/null +++ b/src/components/LandingPage.js @@ -0,0 +1,17 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { push } from "react-router-redux"; +import Screen from "./Screen"; + +class LandingPage extends Component { + componentDidMount = () => { + const { push } = this.props; + push("/user/list"); + }; + + render() { + return ; + } +} + +export default connect(null, { push: push })(LandingPage); diff --git a/src/components/Login.js b/src/components/Login.js new file mode 100644 index 0000000..10b406b --- /dev/null +++ b/src/components/Login.js @@ -0,0 +1,92 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import PropTypes from "prop-types"; +import { translate } from "react-i18next"; +import { Snackbar } from "material-ui"; + +import LoginForm from "./LoginForm"; +import { fetchLogin } from "../redux/actions"; +import { getLoginFormValues, getLoginError } from "../redux/selectors"; + +export class Login extends Component { + constructor(props) { + super(props); + this.state = { + error: null + }; + } + + componentWillReceiveProps(nextProps) { + const { error } = this.props; + if (error !== nextProps.error) { + this.setState({ + error + }); + } + } + + handleClose = () => { + this.setState({ error: null }); + }; + + submit = values => { + const { fetchLogin, values: { username, password } } = this.props; + fetchLogin(username, password); + }; + + render() { + const { values } = this.props; + const { error } = this.state; + + const canSubmit = + values && values.username && values.password ? true : false; + + return ( +
+ (this.form = form)} + initialValues={{ username: "", password: "" }} + onSubmit={this.submit} + canSubmit={canSubmit} + /> + Failed to Login} + /> +
+ ); + } +} + +Login.propTypes = { + t: PropTypes.func.isRequired +}; + +const mapStateToProps = state => { + return { + values: getLoginFormValues(state), + error: getLoginError(state) + }; +}; + +const mapDispatchToProps = { + fetchLogin +}; +/* +mapDispatchToProps above does the same like: +const mapDispatchToProps = dispatch => ({ + fetchLogin: () => dispatch(fetchLogin()) +}); + */ + +export const ConnectedLogin = connect(mapStateToProps, mapDispatchToProps)( + Login +); + +export default translate()(ConnectedLogin); diff --git a/src/components/Login.test.js b/src/components/Login.test.js new file mode 100644 index 0000000..4751dc1 --- /dev/null +++ b/src/components/Login.test.js @@ -0,0 +1,16 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { Login } from "./Login"; + +it("renders without crashing", () => { + shallow(); +}); + +it("has exactly one LoginForm", () => { + const component = shallow(); + const initialValues = component + .find("ReduxForm") + .map(form => form.prop("initialValues")); + expect(initialValues).toHaveLength(1); + expect(initialValues[0]).toEqual({ username: "", password: "" }); +}); diff --git a/src/components/LoginForm.js b/src/components/LoginForm.js new file mode 100644 index 0000000..b18c508 --- /dev/null +++ b/src/components/LoginForm.js @@ -0,0 +1,77 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { withStyles } from "material-ui/styles"; +import { TextField, Button } from "material-ui"; +import { Field, reduxForm } from "redux-form"; +import { translate } from "react-i18next"; + +const styles = theme => ({ + loginForm: { + display: "flex", + flexDirection: "column", + alignItems: "center" + }, + formItem: { + margin: theme.spacing.unit + }, + formColumn: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start" + }, + formRow: { + display: "flex", + flexDirection: "row" + } +}); + +const renderTextField = ({ input, meta: { touched, error }, ...rest }) => ( + +); + +export class LoginForm extends Component { + render() { + const { handleSubmit, canSubmit, classes, t } = this.props; + return ( +
+
+ +
+
+ +
+
+ +
+
+ ); + } +} + +LoginForm.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +export default reduxForm({ form: "login" })( + withStyles(styles)(translate()(LoginForm)) +); diff --git a/src/components/MenuAppBar.js b/src/components/MenuAppBar.js new file mode 100644 index 0000000..3616c40 --- /dev/null +++ b/src/components/MenuAppBar.js @@ -0,0 +1,118 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { withStyles } from "material-ui/styles"; +import AppBar from "material-ui/AppBar"; +import { Toolbar } from "material-ui"; +import Typography from "material-ui/Typography"; +import IconButton from "material-ui/IconButton"; +import MenuIcon from "material-ui-icons/Menu"; +import AccountCircle from "material-ui-icons/AccountCircle"; +import Menu, { MenuItem } from "material-ui/Menu"; + +import { logout } from "../redux/actions"; + +const styles = theme => ({ + flex: { + flex: 1 + }, + menuButton: { + marginLeft: -12, + marginRight: 20 + } +}); + +class MenuAppBar extends React.Component { + state = { + auth: true, + anchorEl: null + }; + + handleChange = (event, checked) => { + this.setState({ auth: checked }); + }; + + handleMenu = event => { + this.setState({ anchorEl: event.currentTarget }); + }; + + handleClose = () => { + this.setState({ anchorEl: null }); + }; + + handleLogout = () => { + const { logout } = this.props; + logout(); + this.handleClose(); + }; + + render() { + const { classes } = this.props; + const { auth, anchorEl } = this.state; + const open = Boolean(anchorEl); + + return ( + + + + + + + Mitgliederverwaltung + + {auth && ( +
+ + + + + Profile + Logout + +
+ )} +
+
+ ); + } +} + +MenuAppBar.propTypes = { + classes: PropTypes.object.isRequired +}; + +const mapStateToProps = state => { + return {}; +}; + +const mapDispatchToProps = { + logout +}; + +const ConnectedMenuAppBar = connect(mapStateToProps, mapDispatchToProps)( + MenuAppBar +); + +export default withStyles(styles)(ConnectedMenuAppBar); diff --git a/src/components/ReactSelect.js b/src/components/ReactSelect.js new file mode 100644 index 0000000..94cf945 --- /dev/null +++ b/src/components/ReactSelect.js @@ -0,0 +1,242 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { withStyles } from "material-ui/styles"; +import Typography from "material-ui/Typography"; +import Input from "material-ui/Input"; +import { MenuItem } from "material-ui/Menu"; +import ArrowDropDownIcon from "material-ui-icons/ArrowDropDown"; +import CancelIcon from "material-ui-icons/Cancel"; +import ArrowDropUpIcon from "material-ui-icons/ArrowDropUp"; +import ClearIcon from "material-ui-icons/Clear"; +import Chip from "material-ui/Chip"; +import Select from "react-select"; +import "react-select/dist/react-select.css"; +import { translate } from "react-i18next"; + +const ITEM_HEIGHT = 48; + +const styles = theme => ({ + // We had to use a lot of global selectors in order to style react-select. + // We are waiting on https://github.com/JedWatson/react-select/issues/1679 + // to provide a better implementation. + // Also, we had to reset the default style injected by the library. + "@global": { + ".Select-control": { + display: "flex", + alignItems: "center", + border: 0, + height: "auto", + background: "transparent", + "&:hover": { + boxShadow: "none" + } + }, + ".Select-multi-value-wrapper": { + flexGrow: 1, + display: "flex", + flexWrap: "wrap" + }, + ".Select--multi": { + height: "auto" + }, + ".Select--multi .Select-input": { + margin: 0 + }, + ".Select.has-value.is-clearable.Select--single > .Select-control .Select-value": { + padding: 0 + }, + ".Select-noresults": { + padding: theme.spacing.unit * 2 + }, + ".Select-input": { + display: "inline-flex !important", + padding: 0, + height: "auto" + }, + ".Select-input input": { + background: "transparent", + border: 0, + padding: 0, + cursor: "default", + display: "inline-block", + fontFamily: "inherit", + fontSize: "inherit", + margin: 0, + outline: 0 + }, + ".Select-placeholder, .Select--single .Select-value": { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "flex", + alignItems: "center", + fontFamily: theme.typography.fontFamily, + fontSize: theme.typography.pxToRem(16), + padding: 0 + }, + ".Select-placeholder": { + opacity: 0.42, + color: theme.palette.common.black + }, + ".Select--single > .Select-control .Select-value": { + color: theme.palette.common.black + }, + ".Select-menu-outer": { + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[2], + position: "absolute", + left: 0, + top: `calc(100% + ${theme.spacing.unit}px)`, + width: "100%", + zIndex: 2, + maxHeight: ITEM_HEIGHT * 4.5 + }, + ".Select.is-focused:not(.is-open) > .Select-control": { + boxShadow: "none" + }, + ".Select-menu": { + maxHeight: ITEM_HEIGHT * 4.5, + overflowY: "auto" + }, + ".Select-menu div": { + boxSizing: "content-box" + }, + ".Select-arrow-zone, .Select-clear-zone": { + color: theme.palette.action.active, + cursor: "pointer", + height: 21, + width: 21, + zIndex: 1 + }, + // Only for screen readers. We can't use display none. + ".Select-aria-only": { + position: "absolute", + overflow: "hidden", + clip: "rect(0 0 0 0)", + height: 1, + width: 1, + margin: -1 + } + } +}); + +class Option extends React.Component { + handleClick = event => { + this.props.onSelect(this.props.option, event); + }; + + render() { + const { children, isFocused, isSelected, onFocus } = this.props; + + return ( + + {children} + + ); + } +} + +function SelectWrapped(props) { + const { classes, ...other } = props; + + return ( + + ); + } +} + +ReactSelect.propTypes = { + classes: PropTypes.object.isRequired, + multi: PropTypes.object, + options: PropTypes.array.isRequired, + handleChangeMulti: PropTypes.func.isRequired +}; + +export default withStyles(styles)(translate()(ReactSelect)); diff --git a/src/components/RoleCreateForm.js b/src/components/RoleCreateForm.js new file mode 100644 index 0000000..970ff1d --- /dev/null +++ b/src/components/RoleCreateForm.js @@ -0,0 +1,153 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router-dom"; +import { withStyles } from "material-ui/styles"; +import Typography from "material-ui/Typography"; +import Paper from "material-ui/Paper"; +import { Field, reduxForm } from "redux-form"; +import { TextField, Button } from "material-ui"; +import "react-select/dist/react-select.css"; +import ReactSelect from "./ReactSelect"; +import { translate } from "react-i18next"; + +const styles = theme => ({ + chip: { + margin: theme.spacing.unit / 4 + }, + root: theme.mixins.gutters({ + display: "flex", + flexDirection: "column", + alignItems: "center", + paddingTop: 16, + paddingBottom: 16, + margin: `${theme.spacing.unit * 3}px auto 0`, + width: 700 + }), + formColumn: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start" + }, + formRow: { + display: "flex", + flexDirection: "row" + }, + formItem: { + margin: theme.spacing.unit, + width: 700 + }, + buttonsRow: { + display: "flex", + flexDirection: "row", + alignItems: "center", + margin: 25 + }, + buttonRow: { + display: "flex", + flexDirection: "row", + margin: 5 + }, + cancelButton: { + "text-decoration": "none" + } +}); + +const renderTextField = ({ input, meta: { touched, error }, ...rest }) => ( + +); + +export class RoleCreateForm extends React.Component { + render() { + const { + classes, + optionsPermissions, + optionsUsers, + handleDelete, + handleSubmit, + handleChangeMultiPermissions, + multiPermissions, + handleChangeMultiUsers, + multiUsers, + initialValues, + canSubmit, + id, + t + } = this.props; + + return ( +
+ + + {!!id || id === 0 + ? t("role.edit_role_header") + : t("role.create_role_header")} + +
+ +
+
+
+ +
+
+
+
+ +
+
+ +
+
+ + + +
+ {!!id || id === 0 ? ( +
+ +
+ ) : null} +
+ +
+
+
+
+ ); + } +} + +RoleCreateForm.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +export default reduxForm({ form: "RoleCreateForm", enableReinitialize: true })( + withStyles(styles)(translate()(RoleCreateForm)) +); diff --git a/src/components/RoleCreateForm.test.js b/src/components/RoleCreateForm.test.js new file mode 100644 index 0000000..745ee6d --- /dev/null +++ b/src/components/RoleCreateForm.test.js @@ -0,0 +1,19 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { RoleCreateForm } from "./RoleCreateForm"; + +it("renders without crashing", () => { + shallow( + + ); +}); diff --git a/src/components/RoleCreateScreen.js b/src/components/RoleCreateScreen.js new file mode 100644 index 0000000..96e4071 --- /dev/null +++ b/src/components/RoleCreateScreen.js @@ -0,0 +1,275 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; +import { CircularProgress, withStyles, Snackbar } from "material-ui"; + +import RoleCreateForm from "./RoleCreateForm"; +import { + fetchCreateByObject, + fetchDeleteById, + fetchUpdateByObject, + fetchDetailById, + fetchList, + resetRoleCreateState +} from "../redux/actions"; +import { + getPermissions, + getUsers, + getRoleFormValues, + getTimeFetchedPermissions, + isFetchingPermissions, + isCreatingRole, + getCreateRoleTimeFetched, + getCreateRoleError, + isEditedRole, + isLoadedRole, + getRoleById, + isLoadedRoles, + isRoleWithErrors +} from "../redux/selectors"; +import Screen from "./Screen"; +import { entity } from "../lib/entity"; + +const styles = theme => ({ + progress: { + margin: theme.spacing.unit * 2 + }, + progressContainer: { + width: "100%", + flex: 1, + display: "flex", + flexDirection: "row", + justifyContent: "center" + } +}); + +export class RoleCreateScreen extends Component { + state = { + multiPermissions: [], + multiUsers: [], + roleName: "", + setFormValues: false + }; + + handleChangeMultiPermissions = multiPermissions => { + this.setState({ + multiPermissions: !!multiPermissions + ? multiPermissions.split(",").map(v => parseInt(v, 10)) + : [] + }); + }; + + handleChangeMultiUsers = multiUsers => { + this.setState({ + multiUsers: !!multiUsers + ? multiUsers.split(",").map(v => parseInt(v, 10)) + : [] + }); + }; + + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleDelete = this.handleDelete.bind(this); + } + + componentWillMount = () => { + const { + fetchPermissions, + fetchUsers, + fetchRoles, + fetchRole, + id + } = this.props; + fetchRoles({}); + fetchPermissions({}); + fetchUsers({}); + if (!!id || id === 0) { + fetchRole(id); + } + }; + + componentWillUnmount = () => { + this.setState({ + multiPermissions: [], + multiUsers: [], + roleName: "", + setFormValues: false + }); + const { resetRoleCreateState } = this.props; + resetRoleCreateState(); + }; + + componentWillReceiveProps = (props, state) => { + const { id, roleForEdit, isLoadedRole, isLoadedRoles } = props; + const { setFormValues } = this.state; + + if ( + (parseInt(id, 10) || parseInt(id, 10) === 0) && + !!isLoadedRole && + !!isLoadedRoles && + !setFormValues + ) { + this.setState({ + multiPermissions: roleForEdit.permissions || [], + multiUsers: roleForEdit.users || [], + roleName: roleForEdit.name || "", + setFormValues: true + }); + } + }; + + handleDelete = () => { + const { fetchDeleteRole, id } = this.props; + fetchDeleteRole(id); + }; + + handleSubmit = () => { + const { fetchCreateRole, fetchEditRole, values: { name }, id } = this.props; + const { multiPermissions, multiUsers } = this.state; + !!id || id === 0 + ? fetchEditRole({ + id, + name, + permissions: multiPermissions, + users: multiUsers + }) + : fetchCreateRole({ + name, + permissions: multiPermissions, + users: multiUsers + }); + }; + + //todo: add canSubmit + //todo: HANDLE trying to view unexisting role + render() { + const { + permissions, + users, + isRoleWithErrors, + isLoaded, + classes, + isEdited, + isSent, + values, + id, + t + } = this.props; + const { roleName } = this.state; + const optionsPermissions = (permissions || []).map(permission => { + return { value: permission.id, label: permission.name }; + }); + const optionsUsers = (users || []).map(user => { + return { value: user.id, label: user.username }; + }); + + var alert = null; + if (!!id || id === 0) { + alert = isEdited ? ( +
+ {t("role.role_is_edited_msg")} +
+ ) : null; + } else { + alert = isEdited ? ( +
{t("role.role_created_msg")}
+ ) : null; + } + + const loadingScreen = ( + +
+ +
+
+ ); + + /* + if (!!id || id===0) { + if (!isLoaded) { + return loadingScreen; + } + } + */ + if ((!!id || id === 0) && !isSent && !isLoaded) { + return loadingScreen; + } + + const canSubmit = values && values.name ? true : false; + return ( + + (this.form = form)} + handleDelete={this.handleDelete} + onSubmit={this.handleSubmit} + canSubmit={canSubmit} + id={id} + /> + {alert} + {t("role.error_message")}} + /> + + ); + } +} + +const mapStateToProps = (state, ownProps) => { + const id = parseInt(ownProps.match.params.id, 10); + + const isFetchingPerms = isFetchingPermissions(state); + const timeFetchedPermissions = getTimeFetchedPermissions(state); + const isCreating = isCreatingRole(state); + const isEdited = isEditedRole(state); + const roleCreatedTime = getCreateRoleTimeFetched(state); + const createRoleError = getCreateRoleError(state); + + return { + permissions: getPermissions(state), + users: getUsers(state), + role: state.role.create, + roleForEdit: getRoleById(id)(state), + values: getRoleFormValues(state), + isFetchingPerms, + isLoaded: !isFetchingPerms && !!timeFetchedPermissions, + isLoadedRoles: isLoadedRoles(state), + isLoadedRole: isLoadedRole(state), + isSent: !isCreating && !!roleCreatedTime && !createRoleError, + id, + isEdited, + isRoleWithErrors: isRoleWithErrors(state) + }; +}; + +const mapDispatchToProps = { + fetchPermissions: fetchList(entity.permission), + fetchUsers: fetchList(entity.user), + fetchCreateRole: fetchCreateByObject(entity.role), + fetchRoles: fetchList(entity.role), + fetchRole: fetchDetailById(entity.role), + fetchEditRole: fetchUpdateByObject(entity.role), + fetchDeleteRole: fetchDeleteById(entity.role), + resetRoleCreateState +}; + +export const ConnectedCreateScreen = connect( + mapStateToProps, + mapDispatchToProps +)(RoleCreateScreen); + +export default withStyles(styles)(translate()(ConnectedCreateScreen)); diff --git a/src/components/RoleList.js b/src/components/RoleList.js new file mode 100644 index 0000000..008b0a7 --- /dev/null +++ b/src/components/RoleList.js @@ -0,0 +1,328 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router-dom"; +import { withStyles } from "material-ui/styles"; +import Table, { + TableBody, + TableCell, + TableFooter, + TablePagination, + TableRow, + TableHead +} from "material-ui/Table"; +import Paper from "material-ui/Paper"; +import IconButton from "material-ui/IconButton"; +import FirstPageIcon from "material-ui-icons/FirstPage"; +import KeyboardArrowLeft from "material-ui-icons/KeyboardArrowLeft"; +import KeyboardArrowRight from "material-ui-icons/KeyboardArrowRight"; +import LastPageIcon from "material-ui-icons/LastPage"; +import Screen from "./Screen"; +import { + getPermissions, + getUsers, + getRoleById, + getRoles, + getTimeFetchedPermissions, + getTimeFetchedUserList +} from "../redux/selectors"; +import { fetchDeleteById, fetchList } from "../redux/actions"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; +import CreateIcon from "material-ui-icons/Create"; +import DeleteIcon from "material-ui-icons/Delete"; +import Typography from "material-ui/Typography"; +import { entity } from "../lib/entity"; +import { CircularProgress } from "material-ui"; + +const actionsStyles = theme => ({ + root: { + flexShrink: 0, + color: theme.palette.text.secondary, + marginLeft: theme.spacing.unit * 2.5 + } +}); + +class TablePaginationActions extends React.Component { + handleFirstPageButtonClick = event => { + this.props.onChangePage(event, 0); + }; + + handleBackButtonClick = event => { + this.props.onChangePage(event, this.props.page - 1); + }; + + handleNextButtonClick = event => { + this.props.onChangePage(event, this.props.page + 1); + }; + + handleLastPageButtonClick = event => { + this.props.onChangePage( + event, + Math.max(0, Math.ceil(this.props.count / this.props.rowsPerPage) - 1) + ); + }; + + //TODO FIX ADD ROLE + render() { + const { classes, count, page, rowsPerPage, theme } = this.props; + + return ( +
+ + {theme.direction === "rtl" ? : } + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Next Page" + > + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Last Page" + > + {theme.direction === "rtl" ? : } + +
+ ); + } +} + +TablePaginationActions.propTypes = { + classes: PropTypes.object.isRequired, + count: PropTypes.number.isRequired, + onChangePage: PropTypes.func.isRequired, + page: PropTypes.number.isRequired, + rowsPerPage: PropTypes.number.isRequired, + theme: PropTypes.object.isRequired +}; + +const TablePaginationActionsWrapped = withStyles(actionsStyles, { + withTheme: true +})(TablePaginationActions); + +const styles = theme => ({ + root: { + width: "100%", + marginTop: theme.spacing.unit * 3, + overflowX: "auto", + padding: "24px", + "flex-grow": "1", + "background-color": "#fafafa" + }, + table: { + minWidth: "20%", + maxWidth: "90%" + }, + tableWrapper: { + overflowX: "auto", + padding: "24px", + "flex-grow": "1", + "background-color": "#fafafa" + } +}); + +class RoleList extends React.Component { + constructor(props, context) { + super(props, context); + this.handleDelete = this.handleDelete.bind(this); + + this.state = { + page: 0, + rowsPerPage: 5 + }; + } + + componentWillMount = () => { + const { fetchRoles, fetchPermissions, fetchUsers } = this.props; + fetchRoles(); + fetchPermissions(); + fetchUsers(); + }; + + handleChangePage = (event, page) => { + this.setState({ page }); + }; + + handleChangeRowsPerPage = event => { + this.setState({ rowsPerPage: event.target.value }); + }; + + handleDelete = id => { + const { fetchDeleteRole, fetchRoles } = this.props; + fetchDeleteRole(id); + fetchRoles(); + }; + + render() { + const { classes, roles, permissions, users, t, isLoading } = this.props; + const { rowsPerPage, page } = this.state; + const emptyRows = + rowsPerPage - + Math.min(rowsPerPage, (roles || []).length - page * rowsPerPage); + + const newRoles = roles.map(role => + Object.assign({}, role, { + users: !!role.users ? users.filter(u => role.users.includes(u.id)) : [], + permissions: !!role.permissions + ? permissions.filter(p => role.permissions.includes(p.id)) + : [] + }) + ); + + const loadingScreen = ( + +
+ +
+
+ ); + + if (isLoading) { + return loadingScreen; + } + + return ( + + + {t("role_list.header")} + + +
+ + + + {t("role_list.name")} + + {t("role_list.permissions")} + + + {t("role_list.users")} + + {t("role_list.edit")} + {t("role_list.delete")} + + + + {(newRoles || []) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map(role => { + return ( + + {role.name} + + {role.permissions ? ( +
+ {role.permissions.map(permission => { + return ( +

{permission.name}

+ ); + })} +
+ ) : null} +
+ + {role.users ? ( +
+ {role.users.map(user => { + return

{user.username}

; + })} +
+ ) : null} +
+ + + + + + +
this.handleDelete(role.id)} + > + +
+
+
+ ); + })} + {emptyRows > 0 && ( + + + + )} +
+ + + + + +
+
+
+
+ ); + } +} + +const mapStateToProps = state => { + const timeFetchedPermissions = getTimeFetchedPermissions(state); + const timeFetchedUserList = getTimeFetchedUserList(state); + return { + roles: getRoles(state), + role: id => getRoleById(id)(state), + permissions: getPermissions(state), + users: getUsers(state), + timeFetchedPermissions, + timeFetchedUserList, + isLoading: + !getRoles(state) || !timeFetchedPermissions || !timeFetchedUserList + }; +}; + +const mapDispatchToProps = { + fetchRoles: fetchList(entity.role), + fetchPermissions: fetchList(entity.permission), + fetchUsers: fetchList(entity.user), + fetchDeleteRole: fetchDeleteById(entity.role) +}; + +RoleList.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +export const ConnectedRoleList = connect(mapStateToProps, mapDispatchToProps)( + RoleList +); + +export default withStyles(styles)(translate()(ConnectedRoleList)); diff --git a/src/components/Screen.js b/src/components/Screen.js new file mode 100644 index 0000000..4c08330 --- /dev/null +++ b/src/components/Screen.js @@ -0,0 +1,263 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { + withStyles, + Drawer, + IconButton, + Divider, + AppBar, + List, + ListItem, + ListSubheader, + ListItemIcon, + ListItemText +} from "material-ui"; +import ChevronLeftIcon from "material-ui-icons/ChevronLeft"; +import ChevronRightIcon from "material-ui-icons/ChevronRight"; +import AddIcon from "material-ui-icons/Add"; +import ListIcon from "material-ui-icons/List"; +import { Link } from "react-router-dom"; +import { connect } from "react-redux"; +import { openDrawer, closeDrawer } from "../redux/actions"; +import { isDrawerOpen } from "../redux/selectors"; +import { translate } from "react-i18next"; +import Toolbar from "./Toolbar"; + +const styles = theme => { + const { custom: { drawer } } = theme; + const drawerWidth = drawer.width; + const closedDrawerWidth = drawer.closedWidth; + return { + root: { + flexGrow: 1, + minHeight: "100vh", + zIndex: 1, + overflow: "hidden", + position: "relative", + display: "flex" + }, + appBar: { + position: "fixed", + zIndex: theme.zIndex.drawer + 1, + transition: theme.transitions.create(["width", "margin"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen + }) + }, + appBarShift: { + marginLeft: drawerWidth, + width: `calc(100% - ${drawerWidth}px)`, + transition: theme.transitions.create(["width", "margin"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen + }) + }, + menuButton: { + marginLeft: 12, + marginRight: 36 + }, + hide: { + display: "none" + }, + drawerPaper: { + position: "fixed", + backgroundColor: "white", + width: drawerWidth, + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen + }) + }, + drawerPaperClose: { + position: "fixed", + backgroundColor: "white", + width: closedDrawerWidth, + overflowX: "hidden", + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen + }) + }, + fullWidth: { + width: "100%", + marginRight: 0, + marginLeft: 0, + alignSelf: "center" + }, + toolbar: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + padding: "0 8px", + ...theme.mixins.toolbar + }, + content: { + flexGrow: 1, + backgroundColor: theme.palette.background.default, + padding: theme.spacing.unit * 3 + }, + noTextDecoration: { + textDecoration: "none" + } + }; +}; + +export const DrawerItem = ({ + icon = null, + label = "", + open = true, + classes +}) => ( + + {icon ? ( + + {icon} + + ) : null} + {open ? : null} + +); + +DrawerItem.propTypes = { + classes: PropTypes.object.isRequired, + open: PropTypes.bool +}; + +export const DrawerListBase = props => { + const { translate = true, header, items = [], t, classes, open } = props; + return ( + + {header ? ( + + {translate ? t(header.label) : header.label} + + ) : null} + {items.map((item, index) => { + const { label, link, ...rest } = item; + return ( + + + + ); + })} + + ); +}; + +DrawerListBase.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired, + translate: PropTypes.bool, + header: PropTypes.object, + items: PropTypes.array, + open: PropTypes.bool +}; + +const DrawerList = translate()(withStyles(styles)(DrawerListBase)); + +const contactSection = [ + { link: "/contact/list", label: "navigation.list", icon: }, + { link: "/contact/create", label: "navigation.create", icon: } +]; +const userSection = [ + { link: "/user/list", label: "navigation.list", icon: }, + { link: "/user/create", label: "navigation.create", icon: } +]; +const roleSection = [ + { link: "/role/list", label: "navigation.list", icon: }, + { link: "/role/create", label: "navigation.create", icon: } +]; +const groupSection = [ + { link: "/group/list", label: "navigation.list", icon: }, + { link: "/group/create", label: "navigation.create", icon: } +]; + +export class Screen extends React.Component { + render() { + const { classes, theme, children, closeDrawer, open } = this.props; + + return ( +
+ + + + +
+ + {theme.direction === "rtl" ? ( + + ) : ( + + )} + +
+ + + + + +
+
+
+ {children} +
+
+ ); + } +} + +Screen.propTypes = { + classes: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired +}; + +const mapStateToProps = state => { + return { + open: isDrawerOpen(state) + }; +}; + +const mapDispatchToProps = { + openDrawer, + closeDrawer +}; + +const ConnectedScreen = connect(mapStateToProps, mapDispatchToProps)( + withStyles(styles, { withTheme: true })(translate()(Screen)) +); + +export default ConnectedScreen; diff --git a/src/components/Screen.test.js b/src/components/Screen.test.js new file mode 100644 index 0000000..8678414 --- /dev/null +++ b/src/components/Screen.test.js @@ -0,0 +1,91 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { Screen, DrawerItem, DrawerListBase } from "./Screen"; +import CreateIcon from "material-ui-icons/Create"; +import AddIcon from "material-ui-icons/Add"; +import ListIcon from "material-ui-icons/List"; +import { ListItem, ListItemText, ListItemIcon } from "material-ui"; + +it("renders without crashing", () => { + shallow(); +}); + +it("renders an AppBar", () => { + const component = shallow(); + const Appbar = component.find("WithStyles(AppBar)"); + expect(Appbar).toHaveLength(1); +}); + +it("renders a useful Drawer with DrawerLists", () => { + const component = shallow(); + const Drawer = component.find("WithStyles(Drawer)"); + expect(Drawer).toHaveLength(1); + const DrawerLists = component.find("Translate(WithStyles(DrawerListBase))"); + expect(DrawerLists.length).toBeGreaterThan(1); + const items = DrawerLists.map(list => list.prop("items")); + items.forEach(item => + item.forEach(item => { + expect(item).toHaveProperty("icon"); + expect(item).toHaveProperty("label"); + expect(item).toHaveProperty("link"); + }) + ); +}); + +it("renders a DrawerItem without crashing", () => { + shallow(); +}); + +it("DrawerItem renders a ListItem", () => { + const component = shallow(); + const ListItemText = component.find("WithStyles(ListItemText)"); + expect(ListItemText).toHaveLength(1); +}); + +it("DrawerItem renders a ListItem with icon and without labels if drawer is closed", () => { + const component = shallow( + + ); + const ListItemText = component.find("WithStyles(ListItemText)"); + const ListItemIcon = component.find("WithStyles(ListItemIcon)"); + expect(ListItemText).toHaveLength(0); + expect(ListItemIcon).toHaveLength(1); +}); + +it("DrawerItem renders a ListItem with icon and labels if drawer is open", () => { + const component = shallow( + + ); + const ListItemText = component.find("WithStyles(ListItemText)"); + const ListItemIcon = component.find("WithStyles(ListItemIcon)"); + expect(ListItemText).toHaveLength(1); + expect(ListItemIcon).toHaveLength(1); +}); + +it("renders a DrawerListBase without crashing", () => { + shallow(); +}); + +const userSection = [ + { link: "/user/list", label: "navigation.list", icon: }, + { link: "/user/create", label: "navigation.create", icon: }, + { link: "/user/edit", label: "navigation.edit", icon: } +]; + +it("renders correct Links in the DrawerList", () => { + const component = shallow( + + ); + const Links = component.find("Link"); + expect(Links).toHaveLength(3); + const paths = Links.map(link => link.prop("to")); + expect(paths).toContain("/user/list"); + expect(paths).toContain("/user/create"); + expect(paths).toContain("/user/edit"); +}); diff --git a/src/components/Toolbar.js b/src/components/Toolbar.js new file mode 100644 index 0000000..d68cc76 --- /dev/null +++ b/src/components/Toolbar.js @@ -0,0 +1,184 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { + Menu, + MenuItem, + Toolbar as MuiToolbar, + withStyles, + IconButton, + Typography +} from "material-ui"; +import AccountCircle from "material-ui-icons/AccountCircle"; +import MenuIcon from "material-ui-icons/Menu"; +import { Link } from "react-router-dom"; +import { connect } from "react-redux"; +import { openDrawer, logout } from "../redux/actions"; +import { isDrawerOpen } from "../redux/selectors"; +import { translate } from "react-i18next"; +import { push } from "react-router-redux"; + +const styles = theme => { + return { + menuButton: { + marginLeft: 12, + marginRight: 36 + }, + hide: { + display: "none" + }, + left: { + display: "flex", + flexDirection: "row", + justifyContent: "flex-start", + alignItems: "center" + }, + guttersRight: { + paddingRight: 24 + }, + guttersLeft: { + paddingLeft: 24 + }, + right: { + display: "flex", + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center" + }, + toolbarContent: { + paddingRight: 24 + }, + main: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + width: "100%" + }, + title: { + textDecoration: "none", + color: theme.palette.primary.contrastText + } + }; +}; + +export class Toolbar extends React.Component { + state = { + auth: true, + anchorEl: null + }; + + handleChange = (event, checked) => { + this.setState({ auth: checked }); + }; + + handleMenu = event => { + this.setState({ anchorEl: event.currentTarget }); + }; + + handleClose = () => { + this.setState({ anchorEl: null }); + }; + + handleProfileClick = () => { + const { push } = this.props; + push("/profile"); + this.handleClose(); + }; + + handleLogout = () => { + const { logout } = this.props; + logout(); + this.handleClose(); + }; + + render = () => { + const { classes, className, openDrawer, t, open } = this.props; + const { anchorEl } = this.state; + const menuOpen = Boolean(anchorEl); + return ( + +
+
+ + + + + + {t("title")} + + +
+
+ + + + + Profile + Logout + +
+
+
+ ); + }; +} + +Toolbar.propTypes = { + t: PropTypes.func.isRequired, + classes: PropTypes.object.isRequired, + open: PropTypes.bool.isRequired, + openDrawer: PropTypes.func.isRequired, + logout: PropTypes.func.isRequired +}; + +const mapStateToProps = state => { + return { + open: isDrawerOpen(state) + }; +}; + +const mapDispatchToProps = { + openDrawer, + logout, + push +}; + +const ConnnectedToolbar = connect(mapStateToProps, mapDispatchToProps)( + withStyles(styles, { withTheme: true })(translate()(Toolbar)) +); + +export default ConnnectedToolbar; diff --git a/src/components/Toolbar.test.js b/src/components/Toolbar.test.js new file mode 100644 index 0000000..5dc3f8f --- /dev/null +++ b/src/components/Toolbar.test.js @@ -0,0 +1,34 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { Toolbar } from "./Toolbar"; + +const props = { + t: jest.fn(), + classes: {}, + theme: {}, + logout: jest.fn(), + openDrawer: jest.fn(), + open: false +}; + +it("renders without crashing", () => { + shallow(); +}); + +it("renders an multiple MenuItems", () => { + const component = shallow(); + const MenuItems = component.find("WithStyles(MenuItem)"); + expect(MenuItems.length).toBeGreaterThan(1); +}); + +it("renders a Menu", () => { + const wrapper = shallow(); + const component = wrapper.find("WithStyles(Menu)"); + expect(component).toHaveLength(1); +}); + +it("renders 2 IconButtons", () => { + const wrapper = shallow(); + const component = wrapper.find("WithStyles(IconButton)"); + expect(component).toHaveLength(2); +}); diff --git a/src/components/UserCreateForm.js b/src/components/UserCreateForm.js new file mode 100644 index 0000000..3300fd5 --- /dev/null +++ b/src/components/UserCreateForm.js @@ -0,0 +1,200 @@ +import { Field, reduxForm } from "redux-form"; +import { withStyles } from "material-ui/styles/index"; +import { Link } from "react-router-dom"; +import React from "react"; +import PropTypes from "prop-types"; +import Typography from "material-ui/Typography"; +import Paper from "material-ui/Paper"; +import { TextField, Button } from "material-ui"; +import "react-select/dist/react-select.css"; +import ReactSelect from "./ReactSelect"; +import Checkbox from "./Checkbox"; +import { translate } from "react-i18next"; + +const styles = theme => ({ + chip: { + margin: theme.spacing.unit / 4 + }, + root: theme.mixins.gutters({ + display: "flex", + flexDirection: "column", + alignItems: "center", + paddingTop: 16, + paddingBottom: 16, + margin: `${theme.spacing.unit * 3}px auto 0`, + width: 700 + }), + formColumn: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start" + }, + formRow: { + display: "flex", + flexDirection: "row", + width: "100%" + }, + formItem: { + margin: theme.spacing.unit, + width: "100%" + }, + buttonsRow: { + display: "flex", + flexDirection: "row", + alignItems: "center", + margin: 25 + }, + buttonRow: { + display: "flex", + flexDirection: "row", + margin: 5 + }, + cancelButton: { + "text-decoration": "none" + } +}); + +const renderTextField = ({ input, meta: { touched, error }, ...rest }) => ( + +); + +export class UserCreateForm extends React.Component { + render() { + const { + classes, + handleDelete, + handleSubmit, + roles, + groups, + contacts, + multiRoles, + multiGroups, + singleContact, + handleChange, + handleChangeSingle, + handleChangeCheckbox, + checkedAdmin, + checkedEnabled, + canSubmit, + id, + t + } = this.props; + var validID = !!id || id === 0; + return ( +
+ + + {!!validID + ? t("user.edit_user_header") + : t("user.create_user_header")} + +
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ + + +
+ {!!validID ? ( +
+ +
+ ) : null} +
+ +
+
+
+
+ ); + } +} + +UserCreateForm.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +export default reduxForm({ form: "UserCreateForm", enableReinitialize: true })( + withStyles(styles)(translate()(UserCreateForm)) +); diff --git a/src/components/UserCreateScreen.js b/src/components/UserCreateScreen.js new file mode 100644 index 0000000..6a75895 --- /dev/null +++ b/src/components/UserCreateScreen.js @@ -0,0 +1,353 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; +import { CircularProgress, withStyles, Snackbar } from "material-ui"; + +import UserCreateForm from "./UserCreateForm"; +import { + fetchList, + fetchCreateByObject, + fetchDeleteById, + fetchUpdateByObject, + fetchDetailById, + resetUserCreateState +} from "../redux/actions"; +import { + getUsers, + getTimeFetchedUsers, + isFetchingUsers, + getGroups, + getRoles, + getUserFormValues, + isCreatingUser, + isEditedUser, + getTimeFetchedUserList, + getCreateUserError, + getUserById, + isLoadedUsers, + isLoadedUser, + isUserWithErrors +} from "../redux/selectors"; +import Screen from "./Screen"; +import PropTypes from "prop-types"; +import { entity } from "../lib/entity"; +import { getContacts } from "../redux/selectors/contactsSelectors"; + +const styles = theme => ({ + progress: { + margin: theme.spacing.unit * 2 + }, + progressContainer: { + width: "100%", + flex: 1, + display: "flex", + flexDirection: "row", + justifyContent: "center" + } +}); + +export class UserCreateScreen extends Component { + state = { + singleContact: "", + multiRoles: [], + multiGroups: [], + username: "", + password: "", + checkedAdmin: false, + checkedEnabled: false, + setFormValues: false + }; + + handleChange = name => value => { + this.setState({ + [name]: !!value ? value.split(",").map(v => parseInt(v, 10)) : [] + }); + }; + + handleChangeSingle = name => value => { + this.setState({ + [name]: value + }); + }; + + handleChangeCheckbox = (name, checked) => { + this.setState({ [name]: checked }); + }; + + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleDelete = this.handleDelete.bind(this); + } + + componentWillMount = () => { + const { + fetchUsers, + fetchContacts, + fetchRoles, + fetchGroups, + fetchUser, + id + } = this.props; + fetchUsers({}); + fetchContacts({}); + fetchRoles({}); + fetchGroups({}); + if (!!id || id === 0) { + fetchUser(id); + } + }; + + componentWillUnmount = () => { + this.setState({ + singleContact: "", + multiRoles: [], + multiGroups: [], + username: "", + password: "", + checkedAdmin: false, + checkedEnabled: false, + setFormValues: false + }); + const { resetUserCreateState } = this.props; + resetUserCreateState(); + }; + + componentWillReceiveProps = props => { + const { id, userForEdit, isLoadedUser, isLoadedUsers } = props; + const { setFormValues } = this.state; + + if ( + (parseInt(id, 10) || parseInt(id, 10) === 0) && + !!isLoadedUser && + !!isLoadedUsers && + !setFormValues + ) { + this.setState({ + username: userForEdit.username || "", + singleContact: parseInt(userForEdit.contact, 10) || "", + multiRoles: userForEdit.roles || [], + multiGroups: userForEdit.groups || [], + checkedAdmin: userForEdit.admin || false, + checkedEnabled: userForEdit.enabled || false, + setFormValues: true + }); + } + }; + + handleDelete = () => { + const { fetchDeleteUser, id } = this.props; + fetchDeleteUser(id); + }; + + handleSubmit = () => { + const { + fetchCreateUser, + fetchEditUser, + values: { username, password }, + id + } = this.props; + + const { + singleContact, + multiRoles, + multiGroups, + checkedAdmin, + checkedEnabled + } = this.state; + + var data = { + username, + password, + enabled: checkedAdmin, + admin: checkedEnabled, + roles: multiRoles, + groups: multiGroups + }; + + //TODO replace enabled and admin with variables + if (!!id || id === 0) { + data.id = id; + if (!!singleContact || singleContact === 0) { + data.contact = parseInt(singleContact, 10); + } + fetchEditUser(data); + } else { + if (!!singleContact || singleContact === 0) { + data.contact = parseInt(singleContact, 10); + } + fetchCreateUser(data); + } + }; + + render() { + const { + contacts, + roles, + groups, + isLoaded, + classes, + isSent, + isEdited, + isUserWithErrors, + values, + id, + t + } = this.props; + const { + username, + singleContact, + multiRoles, + multiGroups, + checkedAdmin, + checkedEnabled + } = this.state; + + console.log(); + var alert = null; + if (!!id || id === 0) { + alert = isEdited ? ( +
+ {t("user.user_is_edited_msg")} +
+ ) : null; + } else { + alert = isEdited ? ( +
{t("user.user_created_msg")}
+ ) : null; + } + + const mappedRoles = (roles || []).map(role => { + return { + value: role.id, + label: role.name + }; + }); + + const mappedGroups = (groups || []).map(group => { + return { + value: group.id, + label: group.name + }; + }); + + const mappedContacts = (contacts || []).map(contact => { + return { + value: contact.id, + label: contact.lastName + ", " + contact.firstName + }; + }); + + const loadingScreen = ( + +
+ +
+
+ ); + + if (id) { + if (!isLoaded) { + return loadingScreen; + } + } + + if (!!id && !isSent && !isLoaded) { + return loadingScreen; + } + // we don't show password in edit form, so it's not necessary to update it + var canSubmit; + if (!!id || id === 0) { + canSubmit = values && values.username ? true : false; + } else { + canSubmit = values && values.username && values.password ? true : false; + } + + return ( + + (this.form = form)} + onSubmit={this.handleSubmit} + canSubmit={canSubmit} + id={id} + /> + {alert} + {t("user.error_message")}} + /> + + ); + } +} + +const mapStateToProps = (state, ownProps) => { + const id = parseInt(ownProps.match.params.id, 10); + const isFetching = isFetchingUsers(state); + //todo replace with timeFetchedUsers + const timeFetchedUsers = getTimeFetchedUsers(state); + + const isCreating = isCreatingUser(state); + const isEdited = isEditedUser(state); + const groupCreatedTime = getTimeFetchedUserList(state); + const createUserError = getCreateUserError(state); + return { + users: getUsers(state), + roles: getRoles(state), + groups: getGroups(state), + contacts: getContacts(state), + + values: getUserFormValues(state), + isFetching, + isLoaded: !isFetching && !!timeFetchedUsers, + isSent: !isCreating && !!groupCreatedTime && !createUserError, + isEdited, + id, + userForEdit: getUserById(id)(state), + isLoadedUsers: isLoadedUsers(state), + isLoadedUser: isLoadedUser(state), + isUserWithErrors: isUserWithErrors(state) + }; +}; + +const mapDispatchToProps = { + fetchUsers: fetchList(entity.user), + fetchRoles: fetchList(entity.role), + fetchGroups: fetchList(entity.group), + fetchContacts: fetchList(entity.contact), + + fetchCreateUser: fetchCreateByObject(entity.user), + fetchUser: fetchDetailById(entity.user), + fetchEditUser: fetchUpdateByObject(entity.user), + resetUserCreateState, + fetchDeleteUser: fetchDeleteById(entity.user) +}; + +UserCreateScreen.propTypes = { + t: PropTypes.func.isRequired +}; + +export const ConnectedUserCreateScreen = connect( + mapStateToProps, + mapDispatchToProps +)(UserCreateScreen); + +export default withStyles(styles)(translate()(ConnectedUserCreateScreen)); diff --git a/src/components/UserList.js b/src/components/UserList.js new file mode 100644 index 0000000..8897495 --- /dev/null +++ b/src/components/UserList.js @@ -0,0 +1,199 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { withStyles } from "material-ui/styles"; +import Table, { + TableBody, + TableCell, + TableHead, + TableRow, + TableFooter, + TablePagination +} from "material-ui/Table"; +import { connect } from "react-redux"; +import { fetchList } from "../redux/actions"; +import { getUsers as getUsersFromState, getUserById } from "../redux/selectors"; +import IconButton from "material-ui/IconButton"; +import KeyboardArrowLeft from "material-ui-icons/KeyboardArrowLeft"; +import KeyboardArrowRight from "material-ui-icons/KeyboardArrowRight"; +import Screen from "./Screen"; +import { entity } from "../lib/entity"; +import { Link } from "react-router-dom"; +import CreateIcon from "material-ui-icons/Create"; +import { translate } from "react-i18next"; +import Typography from "material-ui/Typography"; + +const actionsStyles = theme => ({ + root: { + flexShrink: 0, + color: theme.palette.text.secondary, + marginLeft: theme.spacing.unit * 2.5 + } +}); + +class TablePaginationActions extends React.Component { + handleBackButtonClick = event => { + this.props.onChangePage(event, this.props.page - 1); + }; + + handleNextButtonClick = event => { + this.props.onChangePage(event, this.props.page + 1); + }; + + render() { + const { classes, count, page, rowsPerPage, theme } = this.props; + + return ( +
+ + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="Next Page" + > + {theme.direction === "rtl" ? ( + + ) : ( + + )} + +
+ ); + } +} + +TablePaginationActions.propTypes = { + classes: PropTypes.object.isRequired, + count: PropTypes.number.isRequired, + onChangePage: PropTypes.func.isRequired, + page: PropTypes.number.isRequired, + rowsPerPage: PropTypes.number.isRequired, + theme: PropTypes.object.isRequired +}; + +const TablePaginationActionsWrapped = withStyles(actionsStyles, { + withTheme: true +})(TablePaginationActions); + +const styles = theme => ({ + root: { + width: "100%", + marginTop: theme.spacing.unit * 3, + overflowX: "auto" + }, + table: { + width: "30%" + }, + tableWrapper: { + overflowX: "auto" + } +}); + +class UserList extends Component { + componentWillMount = () => { + const { fetchUsers } = this.props; + fetchUsers(); + }; + + constructor(props, context) { + super(props, context); + this.state = { + page: 0, + rowsPerPage: 5 + }; + } + + handleChangePage = (event, page) => { + this.setState({ page }); + }; + + handleChangeRowsPerPage = event => { + this.setState({ rowsPerPage: event.target.value }); + }; + + render() { + const { classes, users, t } = this.props; + const { rowsPerPage, page } = this.state; + + //console.log("Render a component", users, this.props.user(6)); + return ( + + + {t("user_list.header")} + + {users ? ( +
+ + + + {t("user_list.name")} + {t("user_list.edit")} + + + + {users + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map(user => { + return ( + + {user.username} + + + + + + + ); + })} + + + + + + +
+
+ ) : null} +
+ ); + } +} + +UserList.propTypes = { + classes: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +const mapStateToProps = state => { + return { + users: getUsersFromState(state), + user: id => getUserById(id)(state) + }; +}; + +const mapDispatchToProps = { + fetchUsers: fetchList(entity.user) +}; + +export const ConnectedUserList = connect(mapStateToProps, mapDispatchToProps)( + UserList +); + +export default withStyles(styles)(translate()(ConnectedUserList)); diff --git a/src/config/groupPermissionTypes.js b/src/config/groupPermissionTypes.js new file mode 100644 index 0000000..3f9e08b --- /dev/null +++ b/src/config/groupPermissionTypes.js @@ -0,0 +1,22 @@ +export const permission = [ + { + value: "READ", + label: "Read" + }, + { + value: "WRITE", + label: "Write" + }, + { + value: "UPDATE", + label: "Update" + }, + { + value: "DELETE", + label: "Delete" + }, + { + value: "ADMINISTRATION", + label: "Administration" + } +]; diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..2cfae72 --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,34 @@ +export const apiMethod = { + list: "list", + detail: "detail", + create: "create", + update: "update", + delete: "delete" +}; + +export const apiHttpMethodMapping = { + list: "get", + detail: "get", + create: "post", + update: "put", + delete: "del" +}; + +export const apiActionType = { + request: "REQUEST", + success: "SUCCESS", + failure: "FAILURE" +}; + +export const config = { + apiBaseUrl: + process.env.NODE_ENV === "development" + ? "https://api.swt.leoek.eu" + : "https://api.swt.leoek.eu", + ERROR: { + NOCONNECTION: "NOCONNECTION", + UNAUTHORIZED: "UNAUTHORIZED" + } +}; + +export default config; diff --git a/src/i18n/de.js b/src/i18n/de.js new file mode 100644 index 0000000..af87d36 --- /dev/null +++ b/src/i18n/de.js @@ -0,0 +1,8 @@ +export default { + translation: { + hello: "Hallo", + login: { + button_login: "Einloggen" + } + } +}; diff --git a/src/i18n/en.js b/src/i18n/en.js new file mode 100644 index 0000000..c369389 --- /dev/null +++ b/src/i18n/en.js @@ -0,0 +1,100 @@ +export default { + translation: { + hello: "Hello", + title: "Mitgliederverwaltung", + cancel: "Cancel", + login: { + button_login: "Login", + label_username: "Email", + label_password: "Password" + }, + navigation: { + header: { + user: "User", + group: "Group", + contact: "Contact" + }, + create: "Create", + edit: "Edit", + list: "List" + }, + user_create_screen: { + create_user: "Create User" + }, + user: { + cancel: "Cancel", + delete: "Delete", + error_message: "Oops! Something went wrong", + edit_user_header: "Edit user", + create_user_header: "Create new user", + save_user: "Save user", + add_user: "Add user", + user_is_edited_msg: "User is edited!", + user_created_msg: "New User created!", + username: "Username (Email)", + password: "Password", + password_msg_edit: "A new password if needed", + roles: "Select roles", + select_groups: "Select groups", + select_one_contact: "Select one contact", + checkBoxAdmin: "Admin", + checkBoxEnabled: "Enabled" + }, + role: { + cancel: "Cancel", + delete: "Delete", + error_message: "Oops! Something went wrong", + edit_role_header: "Edit role", + create_role_header: "Create new role", + save_role: "Save role", + add_role: "Add role", + role_is_edited_msg: "Role is edited!", + role_created_msg: "New Role created!", + role_name: "Role name", + role_description: "Role description", + select_permissions: "Select permissions", + select_users: "Select users" + }, + role_list: { + header: "Role list", + name: "Name", + description: "Description", + permissions: "Permissions", + users: "Users", + edit: "Edit", + delete: "Delete" + }, + group_list: { + header: "Group list", + name: "Name", + permission: "Permission", + contacts: "Contacts", + responsibles: "Responsibles", + users: "Users", + edit: "Edit", + delete: "Delete" + }, + group: { + cancel: "Cancel", + delete: "Delete", + error_message: "Oops! Something went wrong", + edit_group_header: "Edit group", + create_group_header: "Create new group", + save_group: "Save group", + add_group: "Add group", + group_is_edited_msg: "Group is edited!", + group_created_msg: "New Group created!", + group_name: "Group name", + group_description: "Group description", + select_one_permission: "Select one permission", + select_contacts: "Select contacts", + select_responsibles: "Select responsibles", + select_users: "Select users" + }, + user_list: { + header: "User List", + name: "Name", + edit: "Edit" + } + } +}; diff --git a/src/i18n/i18n.js b/src/i18n/i18n.js new file mode 100644 index 0000000..935ac7b --- /dev/null +++ b/src/i18n/i18n.js @@ -0,0 +1,30 @@ +import i18n from "i18next"; +import XHR from "i18next-xhr-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; +import translations from "./translations"; + +i18n + .use(XHR) + .use(LanguageDetector) + .init({ + fallbackLng: "en", + debug: process.env.NODE_ENV === "development", + + interpolation: { + escapeValue: false // not needed for react!! + }, + + // react i18next special options (optional) + react: { + wait: false, // set to true if you like to wait for loaded in every translated hoc + nsMode: "default" // set it to fallback to let passed namespaces to translated hoc act as fallbacks + }, + + resources: translations + }); + +export const changeLanguage = lng => { + i18n.changeLanguage(lng); +}; + +export default i18n; diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000..70e85a7 --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,6 @@ +import i18n, { changeLanguage } from "./i18n"; // initialized i18next instance +import translations from "./translations"; + +export { i18n, translations, changeLanguage }; + +export default i18n; diff --git a/src/i18n/translations.js b/src/i18n/translations.js new file mode 100644 index 0000000..0d064ce --- /dev/null +++ b/src/i18n/translations.js @@ -0,0 +1,7 @@ +import en from "./en.js"; +import de from "./de.js"; + +export default { + en, + de +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b4cc725 --- /dev/null +++ b/src/index.css @@ -0,0 +1,5 @@ +body { + margin: 0; + padding: 0; + font-family: sans-serif; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7f8f9bd --- /dev/null +++ b/src/index.js @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import registerServiceWorker from "./registerServiceWorker"; + +import "./index.css"; +import App from "./App"; + +import "typeface-roboto"; + +ReactDOM.render(, document.getElementById("root")); +registerServiceWorker(); diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..816e7bc --- /dev/null +++ b/src/lib/api.js @@ -0,0 +1,114 @@ +import config from "../config"; + +const getErrorPromise = error => { + console.log("Handled Connection Error", error); + return new Promise((resolve, reject) => { + resolve({ + ok: false, + json: () => + new Promise((resolve, reject) => { + resolve(error); + }) + }); + }); +}; + +const handleFetchErrors = (errorMessage, statusCode) => { + const error = { + statusCode: 503, + devMessage: "An error ocurred while trying to connect to the API.", + error: config.ERROR.NOCONNECTION + }; + return getErrorPromise(error); +}; + +const getQueryFromParams = (parameters = {}) => + Object.keys(parameters || {}).reduce((result, parameter) => { + if (!parameter || !parameters[parameter]) return result; + return result === "" + ? `${parameter}=${parameters[parameter]}` + : `${result}&${parameter}=${parameters[parameter]}`; + }, ""); + +const getHeaders = token => { + const headers = { + "Content-Type": "application/json; charset=utf-8" + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return new Headers(headers); +}; + +const get = async (request, token) => { + const { endpoint, parameters } = request; + const query = getQueryFromParams(parameters); + return fetch(`${config.apiBaseUrl}/${endpoint}?${query}`, { + method: "get", + headers: getHeaders(token) + }).catch(handleFetchErrors); +}; + +const post = async (request, token) => { + const { endpoint, parameters, data } = request; + const query = getQueryFromParams(parameters); + return fetch(`${config.apiBaseUrl}/${endpoint}?${query}`, { + method: "post", + body: JSON.stringify(data), + headers: getHeaders(token) + }).catch(handleFetchErrors); +}; + +const put = async (request, token) => { + const { endpoint, parameters, data } = request; + const query = getQueryFromParams(parameters); + return fetch(`${config.apiBaseUrl}/${endpoint}?${query}`, { + method: "put", + body: JSON.stringify(data), + headers: getHeaders(token) + }).catch(handleFetchErrors); +}; + +const del = async (request, token) => { + const { endpoint, parameters, data } = request; + const query = getQueryFromParams(parameters); + return fetch(`${config.apiBaseUrl}/${endpoint}?${query}`, { + method: "delete", + body: JSON.stringify(data), + headers: getHeaders(token) + }).catch(handleFetchErrors); +}; + +/** + * use this function to create a new api template which stores username and password + */ +const createApi = token => ({ + setToken: token => { + this.token = token; + }, + getToken: () => this.token, + get: request => get(request, this.token), + post: request => post(request, this.token), + put: request => put(request, this.token), + del: request => del(request, this.token) +}); + +/** + * use this function to create a new api objet. + * @param {*} baseUrl + * @param {*} username + * @param {*} password + */ +const create = (baseUrl = config.apiBaseUrl, token) => { + const api = createApi(); + + if (token) { + api.setToken(token); + } + + return api; +}; + +export default { + create +}; diff --git a/src/lib/entity.js b/src/lib/entity.js new file mode 100644 index 0000000..793bf85 --- /dev/null +++ b/src/lib/entity.js @@ -0,0 +1,97 @@ +import { isEmpty, unionBy } from "lodash"; +import { apiMethod } from "../config"; + +export const checkEntity = (entity1 = {}) => (entity2 = {}) => + entity1.name && !isEmpty(entity1.name) && entity1.name === entity2.name; + +export const getInitialEntityState = entity => { + const state = { + entity, + items: [] + }; + Object.values(apiMethod).forEach(method => { + state[method] = { + isFetching: false, + timeFetched: null, + items: null, + meta: null, + edited: null + }; + }); + return state; +}; + +export const mergefetchRequest = action => state => { + const { method } = action; + if (apiMethod[method]) { + return { + ...state, + [method]: { + ...state[method], + isFetching: true, + error: null + } + }; + } + return state; +}; +export const mergefetchSuccess = action => state => { + const { method, payload = {} } = action; + const { data: { content, ...rest }, timeFetched } = payload; + const items = Array.isArray(content) ? content : [content]; + if (apiMethod[method]) { + return { + ...state, + items: unionBy(items, state.items, "id"), + [method]: { + ...state[method], + isFetching: false, + items: items.map(item => item && item.id), + meta: Array.isArray(content) ? rest : null, + timeFetched + } + }; + } + return state; +}; +export const mergefetchFailure = action => state => { + const { method, payload = {} } = action; + const { error, timeFetched } = payload; + if (apiMethod[method]) { + return { + ...state, + [method]: { + ...state[method], + isFetching: false, + error, + timeFetched + } + }; + } + return state; +}; + +export const entity = { + user: { + name: "user", + endpoint: "users" + }, + contact: { + name: "contact", + endpoint: "contacts" + }, + permission: { + name: "permission", + endpoint: "permissions" + }, + role: { + name: "role", + endpoint: "roles" + }, + group: { + name: "group", + endpoint: "groups" + } +}; + +export default entity; diff --git a/src/logo.svg b/src/logo.svg new file mode 100644 index 0000000..6b60c10 --- /dev/null +++ b/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/mocks/apiResponses.js b/src/mocks/apiResponses.js new file mode 100644 index 0000000..8d38f66 --- /dev/null +++ b/src/mocks/apiResponses.js @@ -0,0 +1,119 @@ +import config from "../config"; + +export const apiResponse_ErrorNoConnection = { + code: 503, + devMessage: "An error ocurred while trying to connect to the API.", + error: config.ERROR.NOCONNECTION +}; + +export const apiResponse_user = { + last: false, + totalPages: 11, + totalElements: 51, + sort: null, + numberOfElements: 5, + first: true, + size: 5, + number: 0, + content: [ + { + id: 0, + username: "user0", + firstName: "Max", + lastName: "Mustermann", + email: "max.mustermann@mail.de", + password: "string", + phone: "string", + userStatus: 0, + groups: [ + { + id: 0, + name: "string", + permissions: [ + { + id: 0, + name: "string" + } + ] + } + ] + }, + { + id: 1, + username: "user1", + firstName: "Max", + lastName: "Mustermann", + email: "max.mustermann@mail.de", + password: "string", + phone: "string", + userStatus: 0, + groups: [ + { + id: 0, + name: "string", + permissions: [ + { + id: 0, + name: "string" + } + ] + } + ] + } + ] +}; + +export const apiResponse_role = { + content: [ + { + id: 0, + name: "Test Role 1", + description: "Description 1", + permissions: [ + { + id: 0, + name: "Permission 1" + }, + { + id: 1, + name: "Permission 2" + } + ] + }, + { + id: 1, + name: "Test Role 2", + description: "Description 2", + permissions: [ + { + id: 4, + name: "Permission 5" + }, + { + id: 5, + name: "Permission 6" + } + ] + } + ] +}; + +export const apiResponse_createRole = { + content: [ + { + id: 0, + name: "Test Role 1", + description: "Description 1", + permissions: [ + { + id: 0, + name: "Permission 1" + }, + { + id: 1, + name: "Permission 2" + } + ] + } + ] +}; diff --git a/src/mocks/index.js b/src/mocks/index.js new file mode 100644 index 0000000..cdac5c7 --- /dev/null +++ b/src/mocks/index.js @@ -0,0 +1,32 @@ +export * from "./apiResponses"; + +export const initialState = { + login: { + isLoggedIn: false + }, + ui: { + drawer: { + open: true + } + }, + user: { + isFetching: false, + timeFetched: null, + items: null, + meta: null, + error: null + }, + form: {}, + role: { + isFetching: false, + timeFetched: null, + items: null, + meta: null, + create: { + isFetching: false, + timeFetched: null, + items: null, + edited: null + } + } +}; diff --git a/src/redux/actions/apiActions.js b/src/redux/actions/apiActions.js new file mode 100644 index 0000000..6f4c814 --- /dev/null +++ b/src/redux/actions/apiActions.js @@ -0,0 +1,85 @@ +import { apiActionType, apiMethod } from "../../config"; + +//API Actions +export const CONNECTION_FAILURE = "CONNECTION_FAILURE"; +export const RESET_NO_CONNECTION = "RESET_NO_CONNECTION"; + +export const FETCH_LIST_REQUEST = "API/FETCH_LIST_REQUEST"; +export const FETCH_LIST_SUCCESS = "API/FETCH_LIST_SUCCESS"; +export const FETCH_LIST_FAILURE = "API/FETCH_LIST_FAILURE"; + +// login actions +export const FETCH_LOGIN_REQUEST = "FETCH_LOGIN_REQUEST"; +export const FETCH_LOGIN_SUCCESS = "FETCH_LOGIN_SUCCESS"; +export const FETCH_LOGIN_FAILURE = "FETCH_LOGIN_FAILURE"; + +export const FETCH_GENERIC = (type = apiActionType.request) => + `API/FETCH_GENERIC_${type}`; +export const FETCH_GENERIC_REQUEST = FETCH_GENERIC(apiActionType.request); +export const fetchGeneric = method => entity => payload => ({ + type: FETCH_GENERIC_REQUEST, + method, + entity, + payload +}); +export const FETCH_GENERIC_SUCCESS = FETCH_GENERIC(apiActionType.success); +export const fetchGenericSuccess = method => entity => data => ({ + type: FETCH_GENERIC_SUCCESS, + method, + entity, + payload: { + timeFetched: new Date(), + data + } +}); +export const FETCH_GENERIC_FAILURE = FETCH_GENERIC(apiActionType.failure); +export const fetchGenericFailure = method => entity => error => ({ + type: FETCH_GENERIC_FAILURE, + method, + entity, + payload: { + timeFetched: new Date(), + error + } +}); + +export const fetchList = entity => parameters => + fetchGeneric(apiMethod.list)(entity)({ parameters }); +export const fetchListSuccess = fetchGenericSuccess(apiMethod.list); +export const fetchListFailure = fetchGenericFailure(apiMethod.list); +export const fetchDetail = fetchGeneric(apiMethod.detail); +export const fetchDetailById = entity => id => + fetchGeneric(apiMethod.detail)(entity)({ data: { id } }); +export const fetchUpdate = fetchGeneric(apiMethod.update); +export const fetchUpdateByObject = entity => data => + fetchGeneric(apiMethod.update)(entity)({ data }); +export const fetchDelete = fetchGeneric(apiMethod.delete); +export const fetchDeleteById = entity => id => + fetchGeneric(apiMethod.delete)(entity)({ data: { id } }); +export const fetchCreate = fetchGeneric(apiMethod.create); +export const fetchCreateByObject = entity => data => + fetchGeneric(apiMethod.create)(entity)({ data }); + +export const fetchLogin = (username, password) => ({ + type: FETCH_LOGIN_REQUEST, + payload: { + username, + password + } +}); + +export const fetchLoginSuccess = data => ({ + type: FETCH_LOGIN_SUCCESS, + payload: { + timeFetched: new Date(), + data + } +}); + +export const fetchLoginFailure = error => ({ + type: FETCH_LOGIN_FAILURE, + payload: { + timeFetched: new Date(), + error + } +}); diff --git a/src/redux/actions/groupActions.js b/src/redux/actions/groupActions.js new file mode 100644 index 0000000..979bee1 --- /dev/null +++ b/src/redux/actions/groupActions.js @@ -0,0 +1,4 @@ +export const RESET_GROUP_CREATE_STATE = "RESET_GROUP_CREATE_STATE"; +export const resetGroupCreateState = () => ({ + type: RESET_GROUP_CREATE_STATE +}); diff --git a/src/redux/actions/index.js b/src/redux/actions/index.js new file mode 100644 index 0000000..16e0cff --- /dev/null +++ b/src/redux/actions/index.js @@ -0,0 +1,11 @@ +export * from "./apiActions"; +export * from "./loginActions"; +export * from "./uiActions"; +export * from "./groupActions.js"; +export * from "./roleActions.js"; +export * from "./userActions.js"; + +export const REDUX_REHYDRATION_COMPLETED = "REDUX_REHYDRATION_COMPLETED"; +export const reduxRehydrationCompleted = () => ({ + type: REDUX_REHYDRATION_COMPLETED +}); diff --git a/src/redux/actions/loginActions.js b/src/redux/actions/loginActions.js new file mode 100644 index 0000000..b0dae75 --- /dev/null +++ b/src/redux/actions/loginActions.js @@ -0,0 +1,6 @@ +export const LOGOUT = "LOGOUT"; +export const LOGIN = "LOGIN"; + +export const logout = () => ({ + type: LOGOUT +}); diff --git a/src/redux/actions/roleActions.js b/src/redux/actions/roleActions.js new file mode 100644 index 0000000..78f0899 --- /dev/null +++ b/src/redux/actions/roleActions.js @@ -0,0 +1,4 @@ +export const RESET_ROLE_CREATE_STATE = "RESET_ROLE_CREATE_STATE"; +export const resetRoleCreateState = () => ({ + type: RESET_ROLE_CREATE_STATE +}); diff --git a/src/redux/actions/uiActions.js b/src/redux/actions/uiActions.js new file mode 100644 index 0000000..09f7694 --- /dev/null +++ b/src/redux/actions/uiActions.js @@ -0,0 +1,9 @@ +export const OPEN_DRAWER = "UI/OPEN_DRAWER"; +export const openDrawer = () => ({ + type: OPEN_DRAWER +}); + +export const CLOSE_DRAWER = "UI/CLOSE_DRAWER"; +export const closeDrawer = () => ({ + type: CLOSE_DRAWER +}); diff --git a/src/redux/actions/userActions.js b/src/redux/actions/userActions.js new file mode 100644 index 0000000..9757677 --- /dev/null +++ b/src/redux/actions/userActions.js @@ -0,0 +1,4 @@ +export const RESET_USER_CREATE_STATE = "RESET_USER_CREATE_STATE"; +export const resetUserCreateState = () => ({ + type: RESET_USER_CREATE_STATE +}); diff --git a/src/redux/createStore.js b/src/redux/createStore.js new file mode 100644 index 0000000..18d2750 --- /dev/null +++ b/src/redux/createStore.js @@ -0,0 +1,60 @@ +import { createStore, applyMiddleware, compose } from "redux"; +import { persistStore, persistCombineReducers } from "redux-persist"; +import storage from "redux-persist/lib/storage"; +import { createBlacklistFilter } from "redux-persist-transform-filter"; +import createSagaMiddleware from "redux-saga"; + +import createHistory from "history/createBrowserHistory"; +import { routerReducer, routerMiddleware } from "react-router-redux"; + +import reducers from "./reducers"; +import rootSaga from "./sagas"; + +//https://github.com/zalmoxisus/redux-devtools-extension +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + +/** + * documentation on redux persist filters + * https://github.com/edy/redux-persist-transform-filter + */ +const loginFilter = createBlacklistFilter("login", ["error"]); +const persistConfig = { + key: "root", + storage, + whitelist: ["login"], + transforms: [loginFilter] +}; + +// creates the store +export default () => { + // Create a history of your choosing (we're using a browser history in this case) + const history = createHistory(); + + //create the rootReducer + const rootReducer = persistCombineReducers(persistConfig, { + ...reducers, + router: routerReducer + }); + + // create the saga middleware + const sagaMiddleware = createSagaMiddleware(); + + // create the touter middleware, aggregate the middlewares + const middlewares = [sagaMiddleware, routerMiddleware(history)]; + const enhancers = []; + enhancers.push(applyMiddleware(...middlewares)); + + const store = createStore( + rootReducer, + undefined, + composeEnhancers(...enhancers) + ); + + //run sagas + sagaMiddleware.run(rootSaga); + + //start persisting the store + const persistor = persistStore(store, null, () => store.getState()); + + return { persistor, store, history }; +}; diff --git a/src/redux/reducers/apiReducer.js b/src/redux/reducers/apiReducer.js new file mode 100644 index 0000000..6c18661 --- /dev/null +++ b/src/redux/reducers/apiReducer.js @@ -0,0 +1,41 @@ +import { + FETCH_GENERIC_FAILURE, + FETCH_GENERIC_SUCCESS, + FETCH_GENERIC_REQUEST +} from "../actions"; +import { + entity, + checkEntity, + getInitialEntityState, + mergefetchRequest, + mergefetchSuccess, + mergefetchFailure +} from "../../lib/entity"; + +export const apiReducerTemplate = entity => (state, action) => { + if (checkEntity(entity)(action.entity)) { + if (action.type === FETCH_GENERIC_REQUEST) { + return mergefetchRequest(action)(state); + } else if (action.type === FETCH_GENERIC_SUCCESS) { + return mergefetchSuccess(action)(state); + } else if (action.type === FETCH_GENERIC_FAILURE) { + return mergefetchFailure(action)(state); + } + } else { + return undefined; + } +}; + +const apiReducer = entity => (state = getInitialEntityState(entity), action) => + apiReducerTemplate(entity)(state, action) || state; + +export default apiReducer; + +export const addApiReducers = reducers => { + Object.values(entity).forEach(e => { + if (!reducers[e.name]) { + reducers[e.name] = apiReducer(e); + } + }); + return reducers; +}; diff --git a/src/redux/reducers/group.js b/src/redux/reducers/group.js new file mode 100644 index 0000000..754f533 --- /dev/null +++ b/src/redux/reducers/group.js @@ -0,0 +1,24 @@ +import { RESET_GROUP_CREATE_STATE } from "../actions"; +import { apiReducerTemplate } from "./apiReducer"; +import { entity, getInitialEntityState } from "../../lib/entity"; +import { apiMethod } from "../../config"; + +const getInitialState = () => getInitialEntityState(entity.group); + +const group = (state = getInitialState(), action) => { + let newState = apiReducerTemplate(entity.group)(state, action); + if (newState) { + return newState; + } + if (action.type === RESET_GROUP_CREATE_STATE) { + return { + ...state, + [apiMethod.create]: getInitialState()[apiMethod.create], + [apiMethod.detail]: getInitialState()[apiMethod.detail], + [apiMethod.update]: getInitialState()[apiMethod.update] + }; + } + return state; +}; + +export default group; diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js new file mode 100644 index 0000000..b59ab8b --- /dev/null +++ b/src/redux/reducers/index.js @@ -0,0 +1,24 @@ +import { combineReducers } from "redux"; +import { reducer as formReducer } from "redux-form"; + +import login from "./login"; +import ui from "./ui"; +import { addApiReducers } from "./apiReducer"; +import group from "./group"; +import role from "./role"; +import user from "./user"; + +const reducers = { + login, + ui, + group, + role, + user, + form: formReducer +}; + +const enhancedReducers = addApiReducers(reducers); + +export const getRootReducer = combineReducers(enhancedReducers); + +export default enhancedReducers; diff --git a/src/redux/reducers/login.js b/src/redux/reducers/login.js new file mode 100644 index 0000000..6dc686e --- /dev/null +++ b/src/redux/reducers/login.js @@ -0,0 +1,49 @@ +import { + LOGOUT, + FETCH_LOGIN_REQUEST, + FETCH_LOGIN_SUCCESS, + FETCH_LOGIN_FAILURE +} from "../actions"; + +const initialState = { + isLoggedIn: false, + isFetching: false, + timeFetched: null, + access_token: null, + token_type: null, + expires_in: null, + decodedJwt: null +}; + +const login = (state = initialState, action) => { + if (action.type === FETCH_LOGIN_REQUEST) { + return { + ...state, + isFetching: true + }; + } else if (action.type === FETCH_LOGIN_SUCCESS) { + const { data, timeFetched } = action.payload; + return { + ...state, + ...data, + timeFetched, + isLoggedIn: true, + isFetching: false + }; + } else if (action.type === FETCH_LOGIN_FAILURE) { + const { error, timeFetched } = action.payload; + return { + ...state, + error, + timeFetched, + isLoggedIn: false, + isFetching: false + }; + } else if (action.type === LOGOUT) { + return initialState; + } else { + return state; + } +}; + +export default login; diff --git a/src/redux/reducers/role.js b/src/redux/reducers/role.js new file mode 100644 index 0000000..5dc47d6 --- /dev/null +++ b/src/redux/reducers/role.js @@ -0,0 +1,24 @@ +import { RESET_ROLE_CREATE_STATE } from "../actions"; +import { apiReducerTemplate } from "./apiReducer"; +import { entity, getInitialEntityState } from "../../lib/entity"; +import { apiMethod } from "../../config"; + +const getInitialState = () => getInitialEntityState(entity.role); + +const role = (state = getInitialState(), action) => { + let newState = apiReducerTemplate(entity.role)(state, action); + if (newState) { + return newState; + } + if (action.type === RESET_ROLE_CREATE_STATE) { + return { + ...state, + [apiMethod.create]: getInitialState()[apiMethod.create], + [apiMethod.detail]: getInitialState()[apiMethod.detail], + [apiMethod.update]: getInitialState()[apiMethod.update] + }; + } + return state; +}; + +export default role; diff --git a/src/redux/reducers/ui.js b/src/redux/reducers/ui.js new file mode 100644 index 0000000..b290ba3 --- /dev/null +++ b/src/redux/reducers/ui.js @@ -0,0 +1,31 @@ +import { CLOSE_DRAWER, OPEN_DRAWER } from "../actions"; + +const initialState = { + drawer: { + open: false + } +}; + +const user = (state = initialState, action) => { + if (action.type === OPEN_DRAWER) { + return { + ...state, + drawer: { + ...state.drawer, + open: true + } + }; + } else if (action.type === CLOSE_DRAWER) { + return { + ...state, + drawer: { + ...state.drawer, + open: false + } + }; + } else { + return state; + } +}; + +export default user; diff --git a/src/redux/reducers/ui.test.js b/src/redux/reducers/ui.test.js new file mode 100644 index 0000000..9a93428 --- /dev/null +++ b/src/redux/reducers/ui.test.js @@ -0,0 +1,29 @@ +import ui from "./ui"; +import { openDrawer, closeDrawer } from "../actions"; +import { initialState } from "../../mocks"; + +it("Correctly sets the openDrawer value", () => { + const previousState = initialState.ui; + previousState.drawer.open = false; + const actions = [openDrawer()]; + const nextState = actions.reduce( + (prev, action) => ui(prev, action), + previousState + ); + expect(nextState.drawer).toEqual({ + open: true + }); +}); + +it("Correctly sets the closeDrawer value", () => { + const previousState = initialState.ui; + previousState.drawer.open = true; + const actions = [closeDrawer()]; + const nextState = actions.reduce( + (prev, action) => ui(prev, action), + previousState + ); + expect(nextState.drawer).toEqual({ + open: false + }); +}); diff --git a/src/redux/reducers/user.js b/src/redux/reducers/user.js new file mode 100644 index 0000000..cdb95bc --- /dev/null +++ b/src/redux/reducers/user.js @@ -0,0 +1,24 @@ +import { RESET_USER_CREATE_STATE } from "../actions"; +import { apiReducerTemplate } from "./apiReducer"; +import { entity, getInitialEntityState } from "../../lib/entity"; +import { apiMethod } from "../../config"; + +const getInitialState = () => getInitialEntityState(entity.user); + +const user = (state = getInitialState(), action) => { + let newState = apiReducerTemplate(entity.user)(state, action); + if (newState) { + return newState; + } + if (action.type === RESET_USER_CREATE_STATE) { + return { + ...state, + [apiMethod.create]: getInitialState()[apiMethod.create], + [apiMethod.detail]: getInitialState()[apiMethod.detail], + [apiMethod.update]: getInitialState()[apiMethod.update] + }; + } + return state; +}; + +export default user; diff --git a/src/redux/sagas/index.js b/src/redux/sagas/index.js new file mode 100644 index 0000000..a03e051 --- /dev/null +++ b/src/redux/sagas/index.js @@ -0,0 +1,194 @@ +import { all, put, takeLatest, call } from "redux-saga/effects"; +import { push } from "react-router-redux"; +import { get } from "lodash"; +import apiCreator from "../../lib/api"; +import config, { apiMethod, apiHttpMethodMapping } from "../../config"; +import jwtDecode from "jwt-decode"; +import { REHYDRATE } from "redux-persist"; +import { + fetchLoginSuccess, + fetchLoginFailure, + reduxRehydrationCompleted, + fetchGenericSuccess, + fetchGenericFailure +} from "../actions"; +import { + LOGOUT, + FETCH_LOGIN_REQUEST, + FETCH_LOGIN_SUCCESS, + FETCH_GENERIC_REQUEST, + FETCH_GENERIC_SUCCESS +} from "../actions"; +import { takeEvery } from "redux-saga"; + +/** + * create the default api here. it will be replaced + * once the user logs in successfully + */ +let api = apiCreator.create(); + +const handleResponseJsonError = (errorMessage, statusCode) => { + return new Promise((resolve, reject) => { + const error = { + error: config.ERROR.UNPARSABLE_RESPONSE + }; + resolve(error); + }); +}; + +export function* handleResponse( + response, + actionSuccess, + actionFailure, + callIfSuccess, + ...rest +) { + let data = yield response.json().catch(handleResponseJsonError); + if (response.ok) { + //This is temporary.. the api is not responding as expected.. + if (!data.content && !data.access_token) { + data = { + content: data + }; + } + if (actionSuccess) { + yield put(actionSuccess(data)); + } + if (callIfSuccess) { + yield call(callIfSuccess, data, ...rest); + } + } else { + if (actionFailure) { + yield put(actionFailure(data)); + } + if (response.status === 401) { + yield put(push("/login")); + } + } +} + +export function* fetchSaga(api, action) { + const { payload, method, entity } = action; + const { parameters, data } = payload || {}; + const request = { + endpoint: entity.endpoint, + parameters, + data + }; + if (method === apiMethod.list) { + // no todo yet + } else if (method === apiMethod.detail) { + request.endpoint = `${entity.endpoint}/${data.id}`; + } else if (method === apiMethod.create) { + // no todo yet + } else if (method === apiMethod.update) { + request.endpoint = `${entity.endpoint}/${data.id}`; + } else if (method === apiMethod.delete) { + request.endpoint = `${entity.endpoint}/${data.id}`; + } else { + console.error("Unrecognized API Action"); + return; + } + const response = yield call(api[apiHttpMethodMapping[method]], request); + yield call( + handleResponse, + response, + fetchGenericSuccess(method)(entity), + fetchGenericFailure(method)(entity) + ); + + // after delete we need to update state containing all entities of a kind (e.g. all roles, all users) + // so we do it manually + if (method === apiMethod.delete) { + const response = yield call( + api[apiHttpMethodMapping[apiMethod.list]], + Object.assign({}, request, { + endpoint: entity.endpoint + }) + ); + yield call( + handleResponse, + response, + fetchGenericSuccess(apiMethod.list)(entity), + fetchGenericFailure(apiMethod.list)(entity) + ); + } +} + +export function* fetchSuccessSaga(action) { + const { method, entity } = action; + if (method === apiMethod.delete || method === apiMethod.create) { + yield put(push(`/${entity.name}/list`)); + } +} + +export function* fetchLoginSuccessCallback(data) { + const { access_token } = data; + let decodedJwt = null; + try { + decodedJwt = jwtDecode(access_token); + } catch (e) { + console.error("Error decoding the received JWT Token: ", e); + } + if (!decodedJwt) { + const error = { + statusCode: 403, + devMessage: "Couldn't decode JWT.", + error: config.ERROR.UNAUTHORIZED + }; + yield put(fetchLoginFailure(error)); + } else { + yield put(fetchLoginSuccess({ ...data, decodedJwt })); + } +} + +export function* fetchLoginSaga(api, action) { + const { username, password } = action.payload || {}; + const request = { + endpoint: "login", + parameters: null, + data: { + username, + password + } + }; + const response = yield call(api.post, request); + yield call( + handleResponse, + response, + null, + fetchLoginFailure, + fetchLoginSuccessCallback + ); +} + +export function* handleSuccessfulLogin(action) { + const { data: { access_token } } = action.payload; + api.setToken(access_token); + //redirect the user to the main page + yield put(push("/role/create")); +} + +export function* reduxRehydrateSaga(api, action) { + const { payload = {} } = action; + const access_token = get(payload, "login.access_token"); + if (!api.getToken() && access_token) { + api.setToken(access_token); + } + yield put(reduxRehydrationCompleted()); +} + +export function* handleLogout(action) { + yield put(push("/login")); +} + +export default function* root() { + yield all([ + takeLatest(REHYDRATE, reduxRehydrateSaga, api), + takeEvery(FETCH_GENERIC_REQUEST, fetchSaga, api), + takeEvery(FETCH_GENERIC_SUCCESS, fetchSuccessSaga), + takeLatest(FETCH_LOGIN_REQUEST, fetchLoginSaga, api), + takeLatest(FETCH_LOGIN_SUCCESS, handleSuccessfulLogin), + takeLatest(LOGOUT, handleLogout) + ]); +} diff --git a/src/redux/selectors/contactsSelectors.js b/src/redux/selectors/contactsSelectors.js new file mode 100644 index 0000000..5b401d7 --- /dev/null +++ b/src/redux/selectors/contactsSelectors.js @@ -0,0 +1,17 @@ +import { + getItems, + getItemById, + getIsFetching, + getTimeFetched +} from "./entitySelectors"; +import { entity } from "../../lib/entity"; +import { apiMethod } from "../../config"; + +export const getContacts = getItems(apiMethod.list)(entity.contact); +export const getContactById = getItemById(apiMethod.list)(entity.contact); +export const isFetchingContactList = getIsFetching(apiMethod.list)( + entity.contact +); +export const getTimeFetchedContactList = getTimeFetched(apiMethod.list)( + entity.contact +); diff --git a/src/redux/selectors/entitySelectors.js b/src/redux/selectors/entitySelectors.js new file mode 100644 index 0000000..aa787c0 --- /dev/null +++ b/src/redux/selectors/entitySelectors.js @@ -0,0 +1,24 @@ +import { find, get } from "lodash"; + +export const getEntity = ({ name }) => state => state[name]; +export const getEntityMethod = method => entity => state => + (getEntity(entity)(state) || {})[method]; +export const getEntityMethodValue = key => method => entity => state => + (getEntityMethod(method)(entity)(state) || {})[key]; +export const getEntityItems = entity => state => + get(getEntity(entity)(state), "items") || []; +// Use preferably the ones below to create selectors. +export const getItems = method => entity => state => { + const allItems = getEntityItems(entity)(state); + const itemIds = getEntityMethodValue("items")(method)(entity)(state) || []; + return allItems.filter(item => itemIds.includes(item.id)); +}; +export const getMeta = getEntityMethodValue("meta"); +export const getError = getEntityMethodValue("error"); +export const getTimeFetched = getEntityMethodValue("timeFetched"); +export const getIsFetching = getEntityMethodValue("isFetching"); + +export const getItemById = method => entity => id => state => { + const items = getItems(method)(entity)(state); + return find(items, item => item.id === id); +}; diff --git a/src/redux/selectors/formSelectors.js b/src/redux/selectors/formSelectors.js new file mode 100644 index 0000000..dd532ff --- /dev/null +++ b/src/redux/selectors/formSelectors.js @@ -0,0 +1,11 @@ +//Form values +export const getForm = (formName = "default") => state => state.form[formName]; +export const getFormValues = formName => state => + (getForm(formName)(state) || {}).values; +export const getLoginFormValues = state => getFormValues("login")(state); +export const getUserFormValues = state => + getFormValues("UserCreateForm")(state); +export const getGroupFormValues = state => + getFormValues("GroupCreateForm")(state); +export const getRoleFormValues = state => + getFormValues("RoleCreateForm")(state); diff --git a/src/redux/selectors/groupSelectors.js b/src/redux/selectors/groupSelectors.js new file mode 100644 index 0000000..a030177 --- /dev/null +++ b/src/redux/selectors/groupSelectors.js @@ -0,0 +1,32 @@ +import { get } from "lodash"; +import { apiMethod } from "../../config"; +import { entity } from "../../lib/entity"; +import { getItems, getItemById } from "./entitySelectors"; + +export const getGroups = getItems(apiMethod.list)(entity.group); +export const getGroupById = getItemById(apiMethod.list)(entity.group); + +export const isLoadedGroups = state => + !get(state, "group.list.isFetching", false) && + get(state, "group.list.timeFetched", true); + +export const isLoadedGroup = state => + !get(state, "group.detail.isFetching", false) && + get(state, "group.detail.timeFetched", true); + +export const isCreatingGroup = state => + get(state, "group.create.isFetching", false); + +export const getCreateGroupTimeFetched = state => + get(state, "group.create.timeFetched", null); + +export const getCreateGroupError = state => + get(state, "group.create.error", null); + +export const isEditedGroup = state => + get(state, "group.update.timeFetched", false) && + !get(state, "group.update.error", false); + +export const isGroupWithErrors = state => + get(state, "group.list.error", null) || + get(state, "group.detail.error", null); diff --git a/src/redux/selectors/index.js b/src/redux/selectors/index.js new file mode 100644 index 0000000..43f2406 --- /dev/null +++ b/src/redux/selectors/index.js @@ -0,0 +1,10 @@ +//login + +export * from "./entitySelectors"; +export * from "./loginSelectors"; +export * from "./formSelectors"; +export * from "./uiSelectors"; +export * from "./permissionSelectors"; +export * from "./roleSelectors"; +export * from "./userSelectors"; +export * from "./groupSelectors"; diff --git a/src/redux/selectors/loginSelectors.js b/src/redux/selectors/loginSelectors.js new file mode 100644 index 0000000..970fdd2 --- /dev/null +++ b/src/redux/selectors/loginSelectors.js @@ -0,0 +1,2 @@ +export const getLogin = (key = "isLoggedIn") => state => state.login[key]; +export const getLoginError = state => getLogin("error")(state); diff --git a/src/redux/selectors/permissionSelectors.js b/src/redux/selectors/permissionSelectors.js new file mode 100644 index 0000000..46c8623 --- /dev/null +++ b/src/redux/selectors/permissionSelectors.js @@ -0,0 +1,13 @@ +import { get } from "lodash"; +import { apiMethod } from "../../config"; +import { entity } from "../../lib/entity"; +import { getItems, getItemById } from "./entitySelectors"; + +export const getPermissions = getItems(apiMethod.list)(entity.permission); +export const getPermissionById = getItemById(apiMethod.list)(entity.permission); + +export const isFetchingPermissions = state => + !!get(state, `${entity.permission.name}.list.isFetching`); + +export const getTimeFetchedPermissions = state => + get(state, `${entity.permission.name}.list.timeFetched`); diff --git a/src/redux/selectors/roleSelectors.js b/src/redux/selectors/roleSelectors.js new file mode 100644 index 0000000..2864f1b --- /dev/null +++ b/src/redux/selectors/roleSelectors.js @@ -0,0 +1,33 @@ +import { get } from "lodash"; +import { apiMethod } from "../../config"; +import { entity } from "../../lib/entity"; +import { getItems, getItemById } from "./entitySelectors"; + +export const getRoles = getItems(apiMethod.list)(entity.role); +export const getRoleById = getItemById(apiMethod.list)(entity.role); + +export const getCreateRole = state => get(state, "role.create"); + +export const isLoadedRoles = state => + !get(state, "role.list.isFetching", false) && + get(state, "role.list.timeFetched", true); + +export const isLoadedRole = state => + !get(state, "role.detail.isFetching", false) && + get(state, "role.detail.timeFetched", true); + +export const isCreatingRole = state => + get(state, "role.create.isFetching", false); + +export const isEditedRole = state => + get(state, "role.update.timeFetched", false) && + !get(state, "role.update.error", false); + +export const getCreateRoleTimeFetched = state => + get(state, "role.create.timeFetched", null); + +export const getCreateRoleError = state => + get(state, "role.create.error", null); + +export const isRoleWithErrors = state => + get(state, "role.list.error", null) || get(state, "role.detail.error", null); diff --git a/src/redux/selectors/uiSelectors.js b/src/redux/selectors/uiSelectors.js new file mode 100644 index 0000000..67a4aad --- /dev/null +++ b/src/redux/selectors/uiSelectors.js @@ -0,0 +1,7 @@ +export const getDrawer = state => state.ui.drawer; + +export const isDrawerOpen = state => { + const drawer = getDrawer(state); + if (!drawer) return false; + return !!drawer.open; +}; diff --git a/src/redux/selectors/uiSelectors.test.js b/src/redux/selectors/uiSelectors.test.js new file mode 100644 index 0000000..19f72d4 --- /dev/null +++ b/src/redux/selectors/uiSelectors.test.js @@ -0,0 +1,29 @@ +import { getDrawer, isDrawerOpen } from "./uiSelectors"; + +const state = { + ui: { + drawer: { + open: true + } + } +}; + +const state2 = { + ui: {} +}; + +it("getDrawer returns the correct part of the state", () => { + const drawer = getDrawer(state); + expect(drawer).toEqual({ + open: true + }); + const drawer2 = getDrawer(state2); + expect(drawer2).toEqual(undefined); +}); + +it("isDrawerOpen returns the correct value", () => { + const drawer = isDrawerOpen(state); + expect(drawer).toEqual(true); + const drawer2 = isDrawerOpen(state2); + expect(drawer2).toEqual(false); +}); diff --git a/src/redux/selectors/userSelectors.js b/src/redux/selectors/userSelectors.js new file mode 100644 index 0000000..9f5e5d9 --- /dev/null +++ b/src/redux/selectors/userSelectors.js @@ -0,0 +1,44 @@ +import { + getItems, + getItemById, + getIsFetching, + getTimeFetched +} from "./entitySelectors"; +import { entity } from "../../lib/entity"; +import { apiMethod } from "../../config"; +import { get } from "lodash"; + +export const getUsers = getItems(apiMethod.list)(entity.user); +export const getUserById = getItemById(apiMethod.list)(entity.user); +export const isFetchingUserList = getIsFetching(apiMethod.list)(entity.user); +export const getTimeFetchedUserList = getTimeFetched(apiMethod.list)( + entity.user +); + +export const isCreatingUser = state => + get(state, "user.create.isFetching", false); + +export const isEditedUser = state => { + return ( + get(state, "user.update.timeFetched", false) && + !get(state, "user.update.error", false) + ); +}; + +export const getCreateUserError = state => + get(state, "user.create.error", null); + +export const isUserWithErrors = state => + get(state, "user.list.error", null) || get(state, "user.detail.error", null); + +export const isLoadedUsers = state => + !get(state, "user.list.isFetching", false) && + get(state, "user.list.timeFetched", true); + +export const isLoadedUser = state => + !get(state, "user.detail.isFetching", false) && + get(state, "user.detail.timeFetched", true); + +//keep for legacy code +export const isFetchingUsers = isFetchingUserList; +export const getTimeFetchedUsers = getTimeFetchedUserList; diff --git a/src/registerServiceWorker.js b/src/registerServiceWorker.js new file mode 100644 index 0000000..f78c2f1 --- /dev/null +++ b/src/registerServiceWorker.js @@ -0,0 +1,108 @@ +// In production, we register a service worker to serve assets from local cache. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on the "N+1" visit to a page, since previously +// cached resources are updated in the background. + +// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. +// This link also includes instructions on opting out of this behavior. + +const isLocalhost = Boolean( + window.location.hostname === "localhost" || + // [::1] is the IPv6 localhost address. + window.location.hostname === "[::1]" || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export default function register() { + if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 + return; + } + + window.addEventListener("load", () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Lets check if a service worker still exists or not. + checkValidServiceWorker(swUrl); + } else { + // Is not local host. Just register service worker + registerValidSW(swUrl); + } + }); + } +} + +function registerValidSW(swUrl) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + installingWorker.onstatechange = () => { + if (installingWorker.state === "installed") { + if (navigator.serviceWorker.controller) { + // At this point, the old content will have been purged and + // the fresh content will have been added to the cache. + // It's the perfect time to display a "New content is + // available; please refresh." message in your web app. + console.log("New content is available; please refresh."); + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log("Content is cached for offline use."); + } + } + }; + }; + }) + .catch(error => { + console.error("Error during service worker registration:", error); + }); +} + +function checkValidServiceWorker(swUrl) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + if ( + response.status === 404 || + response.headers.get("content-type").indexOf("javascript") === -1 + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl); + } + }) + .catch(() => { + console.log( + "No internet connection found. App is running in offline mode." + ); + }); +} + +export function unregister() { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.ready.then(registration => { + registration.unregister(); + }); + } +} diff --git a/src/setupTests.js b/src/setupTests.js new file mode 100644 index 0000000..08556a6 --- /dev/null +++ b/src/setupTests.js @@ -0,0 +1,4 @@ +import enzyme from "enzyme"; +import Adapter from "enzyme-adapter-react-16"; + +enzyme.configure({ adapter: new Adapter() }); diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..92eaf52 --- /dev/null +++ b/src/version.js @@ -0,0 +1,3 @@ +export default { + version: "0.5.0" +};