initial copy from uni gitlab

This commit is contained in:
Leonard Krause
2018-04-27 01:02:14 +02:00
parent 0a84a9e2ae
commit 93a8735945
73 changed files with 5494 additions and 0 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
# dependencies
/node_modules

25
.gitignore vendored Normal file
View File

@@ -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

32
src/App.css Normal file
View File

@@ -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);
}
}

104
src/App.js Normal file
View File

@@ -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 (
<PersistGate loading={null} persistor={persistor}>
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<MuiThemeProvider theme={theme}>
<ConnectedRouter history={history}>
<div>
<Route exact path="/" component={LandingPage} />
<Route exact path="/login" component={Login} />
<Route exact path="/contact/list" component={ContactList} />
<Route exact path="/user/list" component={UserList} />
<Route
exact
path="/user/edit/:id"
component={UserCreateScreen}
/>
<Route
exact
path="/user/create"
component={UserCreateScreen}
/>
<Route exact path="/profile" component={UserCreateScreen} />
<Route exact path="/role/list" component={RoleList} />
<Route
exact
path="/role/edit/:id"
component={RoleCreateScreen}
/>
<Route
exact
path="/role/create"
component={RoleCreateScreen}
/>
<Route exact path="/group/list" component={GroupList} />
<Route
exact
path="/group/edit/:id"
component={GroupCreateScreen}
/>
<Route
exact
path="/group/create"
component={GroupCreateScreen}
/>
</div>
</ConnectedRouter>
</MuiThemeProvider>
</I18nextProvider>
</Provider>
</PersistGate>
);
}
}
export default App;

8
src/App.test.js Normal file
View File

@@ -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(<App />);
});

View File

@@ -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 (
<FormGroup row>
<FormControlLabel
control={
<Checkbox
checked={checked}
onChange={event => onChange(name, event.target.checked)}
value="checked"
color="primary"
/>
}
label={label}
/>
</FormGroup>
);
}
}
CheckboxLabels.propTypes = {
classes: PropTypes.object.isRequired
};
export default withStyles(styles)(CheckboxLabels);

View File

@@ -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 (
<div className={classes.root}>
<IconButton
onClick={this.handleBackButtonClick}
disabled={page === 0}
aria-label="Previous Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowRight />
) : (
<KeyboardArrowLeft />
)}
</IconButton>
<IconButton
onClick={this.handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="Next Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowLeft />
) : (
<KeyboardArrowRight />
)}
</IconButton>
</div>
);
}
}
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 (
<Screen>
<h2 align="center">Übersicht aller Mitglieder</h2>
{users ? (
<div>
<Table className={classes.table} align="center">
<TableHead>
<TableRow>
<TableCell numeric>Mitgliedsnummer</TableCell>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell style={{ width: 250 }}>Rollen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map(user => {
return (
<TableRow key={user.id}>
<TableCell numeric>{user.id}</TableCell>
<TableCell>
{user.firstName} {user.lastName}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{user.roles ? (
<div>
{user.roles.map(role => {
return <p key={role.id}>{role.name}</p>;
})}
</div>
) : null}
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
colSpan={3}
count={users.length}
rowsPerPage={rowsPerPage}
page={page}
onChangePage={this.handleChangePage}
onChangeRowsPerPage={this.handleChangeRowsPerPage}
Actions={TablePaginationActionsWrapped}
/>
</TableRow>
</TableFooter>
</Table>
</div>
) : (
<div align="center">Es existieren keine Mitglieder!</div>
)}
</Screen>
);
}
}
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);

View File

@@ -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 }) => (
<TextField {...rest} {...input} error={touched && error} />
);
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 (
<form onSubmit={handleSubmit} className={classes.userCreateForm}>
<Paper className={classes.root} elevation={4}>
<Typography type="headline" component="h2">
{!!validID
? t("group.edit_group_header")
: t("group.create_group_header")}
</Typography>
<div className={classes.formRow}>
<Field
label={t("group.group_name")}
name="name"
component={renderTextField}
className={classes.formItem}
/>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectPermission"}
options={permissionToChoose}
handleChangeMulti={handleChangeSingle("singlePermission")}
multi={singlePermission}
canAddMultipleValues={false}
placeholder={t("group.select_one_permission")}
/>
</div>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectUsers"}
options={users}
canAddMultipleValues={true}
handleChangeMulti={handleChange("multiUsers")}
multi={multiUsers}
placeholder={t("group.select_users")}
/>
</div>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectContacts"}
options={contacts}
canAddMultipleValues={true}
handleChangeMulti={handleChange("multiContacts")}
multi={multiContacts}
placeholder={t("group.select_contacts")}
/>
</div>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectResponsibles"}
options={contacts}
canAddMultipleValues={true}
handleChangeMulti={handleChange("multiResponsibles")}
multi={multiResponsibles}
placeholder={t("group.select_responsibles")}
/>
</div>
</div>
<div className={classes.buttonsRow}>
<div className={classes.buttonRow}>
<Link className={classes.cancelButton} to="/group/list">
<Button color="primary">{t("group.cancel")}</Button>
</Link>
</div>
{!!validID ? (
<div className={classes.buttonRow}>
<Button onClick={handleDelete} color="primary">
{t("group.delete")}
</Button>
</div>
) : null}
<div className={classes.buttonRow}>
<Button color="primary" type="submit" disabled={!canSubmit}>
{!!validID ? t("group.save_group") : t("group.add_group")}
</Button>
</div>
</div>
</Paper>
</form>
);
}
}
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))
);

View File

@@ -0,0 +1,21 @@
import React from "react";
import { shallow } from "enzyme";
import { GroupCreateForm } from "./GroupCreateForm";
it("renders without crashing", () => {
shallow(
<GroupCreateForm
t={jest.fn()}
classes={{}}
theme={{}}
multi={{}}
initialValues={{}}
options={{}}
handleChangeMulti={jest.fn}
handleChangeSingle={jest.fn}
handleChange={jest.fn}
onSubmit={jest.fn}
id={{}}
/>
);
});

View File

@@ -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 ? (
<div style={{ textAlign: "center" }}>
{t("group.group_is_edited_msg")}
</div>
) : null;
} else {
alert = isEdited ? (
<div style={{ textAlign: "center" }}>
{t("group.group_created_msg")}
</div>
) : 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 = (
<Screen>
<div className={classes.progressContainer}>
<CircularProgress className={classes.progress} />
</div>
</Screen>
);
if (id) {
if (!isLoaded) {
return loadingScreen;
}
}
if (!!id && !isSent && !isLoaded) {
return loadingScreen;
}
const canSubmit = values && values.name ? true : false;
return (
<Screen>
<GroupCreateForm
initialValues={{ name: groupName, description: groupDescription }}
multiUsers={multiUsers}
multiContacts={multiContacts}
multiResponsibles={multiResponsibles}
singlePermission={singlePermission}
handleChange={this.handleChange}
handleChangeSingle={this.handleChangeSingle}
users={mappedUsers}
contacts={mappedContacts}
permissionToChoose={mappedPermission}
handleDelete={this.handleDelete}
ref={form => (this.form = form)}
onSubmit={this.handleSubmit}
canSubmit={canSubmit}
id={id}
/>
{alert}
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
open={isGroupWithErrors}
onClose={this.handleClose}
SnackbarContentProps={{
"aria-describedby": "message-id"
}}
//TODO replace displayed text with the message from the error object.
message={<span id="message-id">{t("group.error_message")}</span>}
/>
</Screen>
);
}
}
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));

327
src/components/GroupList.js Normal file
View File

@@ -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 (
<div className={classes.root}>
<IconButton
onClick={this.handleFirstPageButtonClick}
disabled={page === 0}
aria-label="First Page"
>
{theme.direction === "rtl" ? <LastPageIcon /> : <FirstPageIcon />}
</IconButton>
<IconButton
onClick={this.handleBackButtonClick}
disabled={page === 0}
aria-label="Previous Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowRight />
) : (
<KeyboardArrowLeft />
)}
</IconButton>
<IconButton
onClick={this.handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="Next Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowLeft />
) : (
<KeyboardArrowRight />
)}
</IconButton>
<IconButton
onClick={this.handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="Last Page"
>
{theme.direction === "rtl" ? <FirstPageIcon /> : <LastPageIcon />}
</IconButton>
</div>
);
}
}
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 (
<Screen>
<Typography type="headline" component="h2" align="center">
{t("group_list.header")}
</Typography>
<Paper className={classes.root}>
<div className={classes.tableWrapper}>
<Table className={classes.table} align="center">
<TableHead>
<TableRow>
<TableCell>{t("group_list.name")}</TableCell>
<TableCell>{t("group_list.permission")}</TableCell>
<TableCell style={{ width: 250 }}>
{t("group_list.users")}
</TableCell>
<TableCell style={{ width: 250 }}>
{t("group_list.contacts")}
</TableCell>
<TableCell style={{ width: 250 }}>
{t("group_list.responsibles")}
</TableCell>
<TableCell>{t("group_list.edit")}</TableCell>
<TableCell>{t("group_list.delete")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{newGroups
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map(group => {
return (
<TableRow key={group.id}>
<TableCell>{group.name}</TableCell>
<TableCell>{group.permission}</TableCell>
<TableCell>
{group.users ? (
<div>
{group.users.map(user => {
return <p key={user.id}>{user.username}</p>;
})}
</div>
) : null}
</TableCell>
<TableCell>
{group.contacts ? (
<div>
{group.contacts.map(contact => {
return (
<p key={contact.id}>{`${contact.lastName}, ${
contact.firstName
}`}</p>
);
})}
</div>
) : null}
</TableCell>
<TableCell>
{group.responsibles ? (
<div>
{group.responsibles.map(responsibility => {
return (
<p key={responsibility.id}>{`${
responsibility.lastName
}, ${responsibility.firstName}`}</p>
);
})}
</div>
) : null}
</TableCell>
<TableCell>
<Link to={`/group/edit/${group.id}`}>
<CreateIcon />
</Link>
</TableCell>
<TableCell>
<div
style={{ cursor: "pointer" }}
onClick={() => this.handleDelete(group.id)}
>
<DeleteIcon />
</div>
</TableCell>
</TableRow>
);
})}
{emptyRows > 0 && (
<TableRow style={{ height: 48 * emptyRows }}>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
colSpan={3}
count={Object.keys(groups).length}
rowsPerPage={rowsPerPage}
page={page}
onChangePage={this.handleChangePage}
onChangeRowsPerPage={this.handleChangeRowsPerPage}
Actions={TablePaginationActionsWrapped}
/>
</TableRow>
</TableFooter>
</Table>
</div>
</Paper>
</Screen>
);
}
}
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));

View File

@@ -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 <Screen />;
}
}
export default connect(null, { push: push })(LandingPage);

92
src/components/Login.js Normal file
View File

@@ -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 (
<div>
<LoginForm
ref={form => (this.form = form)}
initialValues={{ username: "", password: "" }}
onSubmit={this.submit}
canSubmit={canSubmit}
/>
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
open={!!error}
onClose={this.handleClose}
SnackbarContentProps={{
"aria-describedby": "message-id"
}}
//TODO replace displayed tex with the message from the error object.
message={<span id="message-id">Failed to Login</span>}
/>
</div>
);
}
}
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);

View File

@@ -0,0 +1,16 @@
import React from "react";
import { shallow } from "enzyme";
import { Login } from "./Login";
it("renders without crashing", () => {
shallow(<Login t={jest.fn()} />);
});
it("has exactly one LoginForm", () => {
const component = shallow(<Login t={jest.fn()} />);
const initialValues = component
.find("ReduxForm")
.map(form => form.prop("initialValues"));
expect(initialValues).toHaveLength(1);
expect(initialValues[0]).toEqual({ username: "", password: "" });
});

View File

@@ -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 }) => (
<TextField {...rest} {...input} error={touched && error} />
);
export class LoginForm extends Component {
render() {
const { handleSubmit, canSubmit, classes, t } = this.props;
return (
<form onSubmit={handleSubmit} className={classes.loginForm}>
<div className={classes.formRow}>
<Field
label={t("login.label_username")}
name="username"
component={renderTextField}
className={classes.formItem}
/>
</div>
<div className={classes.formRow}>
<Field
label={t("login.label_password")}
name="password"
type="password"
component={renderTextField}
className={classes.formItem}
/>
</div>
<div className={classes.formRow}>
<Button
raised
color="primary"
type="submit"
disabled={!canSubmit}
className={classes.formItem}
>
{t("login.button_login")}
</Button>
</div>
</form>
);
}
}
LoginForm.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired
};
export default reduxForm({ form: "login" })(
withStyles(styles)(translate()(LoginForm))
);

View File

@@ -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 (
<AppBar position="static">
<Toolbar>
<IconButton
className={classes.menuButton}
color="inherit"
aria-label="Menu"
>
<MenuIcon />
</IconButton>
<Typography type="title" color="inherit" className={classes.flex}>
Mitgliederverwaltung
</Typography>
{auth && (
<div>
<IconButton
aria-owns={open ? "menu-appbar" : null}
aria-haspopup="true"
onClick={this.handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right"
}}
transformOrigin={{
vertical: "top",
horizontal: "right"
}}
open={open}
onClose={this.handleClose}
>
<MenuItem onClick={this.handleClose}>Profile</MenuItem>
<MenuItem onClick={this.handleLogout}>Logout</MenuItem>
</Menu>
</div>
)}
</Toolbar>
</AppBar>
);
}
}
MenuAppBar.propTypes = {
classes: PropTypes.object.isRequired
};
const mapStateToProps = state => {
return {};
};
const mapDispatchToProps = {
logout
};
const ConnectedMenuAppBar = connect(mapStateToProps, mapDispatchToProps)(
MenuAppBar
);
export default withStyles(styles)(ConnectedMenuAppBar);

View File

@@ -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 (
<MenuItem
onFocus={onFocus}
selected={isFocused}
onClick={this.handleClick}
component="div"
style={{
fontWeight: isSelected ? 500 : 400
}}
>
{children}
</MenuItem>
);
}
}
function SelectWrapped(props) {
const { classes, ...other } = props;
return (
<Select
optionComponent={Option}
noResultsText={<Typography>{"No results found"}</Typography>}
arrowRenderer={arrowProps => {
return arrowProps.isOpen ? <ArrowDropUpIcon /> : <ArrowDropDownIcon />;
}}
clearRenderer={() => <ClearIcon />}
valueComponent={valueProps => {
const { value, children, onRemove } = valueProps;
const onDelete = event => {
event.preventDefault();
event.stopPropagation();
onRemove(value);
};
if (onRemove) {
return (
<Chip
tabIndex={-1}
label={children}
className={classes.chip}
deleteIcon={<CancelIcon onTouchEnd={onDelete} />}
onDelete={onDelete}
/>
);
}
return <div className="Select-value">{children}</div>;
}}
{...other}
/>
);
}
/**
* The class handling used multi-values should have and give to React-Select following lines of code:
* state = {
multi: null
};
handleChangeMulti = multi => {
this.setState({
multi
});
};
The selected values will then be available in this.state.multi
*/
export class ReactSelect extends React.Component {
render() {
const {
classes,
name,
options,
placeholder,
handleChangeMulti,
multi,
canAddMultipleValues
} = this.props;
return (
<Input
fullWidth
inputComponent={SelectWrapped}
name={name}
inputProps={{
classes,
value: multi,
multi: canAddMultipleValues,
onChange: handleChangeMulti,
placeholder: placeholder ? placeholder : "Select multi-value...",
instanceId: "react-select-chip",
id: "react-select-chip",
name: "react-select-chip",
simpleValue: true,
options
}}
/>
);
}
}
ReactSelect.propTypes = {
classes: PropTypes.object.isRequired,
multi: PropTypes.object,
options: PropTypes.array.isRequired,
handleChangeMulti: PropTypes.func.isRequired
};
export default withStyles(styles)(translate()(ReactSelect));

View File

@@ -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 }) => (
<TextField {...rest} {...input} error={touched && error} />
);
export class RoleCreateForm extends React.Component {
render() {
const {
classes,
optionsPermissions,
optionsUsers,
handleDelete,
handleSubmit,
handleChangeMultiPermissions,
multiPermissions,
handleChangeMultiUsers,
multiUsers,
initialValues,
canSubmit,
id,
t
} = this.props;
return (
<form onSubmit={handleSubmit} className={classes.userCreateForm}>
<Paper className={classes.root} elevation={4}>
<Typography type="headline" component="h2">
{!!id || id === 0
? t("role.edit_role_header")
: t("role.create_role_header")}
</Typography>
<div className={classes.formRow}>
<Field
label={t("role.role_name")}
name="name"
initialValues={initialValues.name}
component={renderTextField}
className={classes.formItem}
/>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectPermissions"}
options={optionsPermissions}
handleChangeMulti={handleChangeMultiPermissions}
multi={multiPermissions}
canAddMultipleValues={true}
placeholder={t("role.select_permissions")}
/>
</div>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectUsers"}
options={optionsUsers}
handleChangeMulti={handleChangeMultiUsers}
multi={multiUsers}
canAddMultipleValues={true}
placeholder={t("role.select_users")}
/>
</div>
</div>
<div className={classes.buttonsRow}>
<div className={classes.buttonRow}>
<Link className={classes.cancelButton} to="/role/list">
<Button color="primary">{t("role.cancel")}</Button>
</Link>
</div>
{!!id || id === 0 ? (
<div className={classes.buttonRow}>
<Button onClick={handleDelete} color="primary">
{t("role.delete")}
</Button>
</div>
) : null}
<div className={classes.buttonRow}>
<Button color="primary" type="submit" disabled={!canSubmit}>
{!!id || id === 0 ? t("role.save_role") : t("role.add_role")}
</Button>
</div>
</div>
</Paper>
</form>
);
}
}
RoleCreateForm.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired
};
export default reduxForm({ form: "RoleCreateForm", enableReinitialize: true })(
withStyles(styles)(translate()(RoleCreateForm))
);

View File

@@ -0,0 +1,19 @@
import React from "react";
import { shallow } from "enzyme";
import { RoleCreateForm } from "./RoleCreateForm";
it("renders without crashing", () => {
shallow(
<RoleCreateForm
t={jest.fn()}
classes={{}}
theme={{}}
multi={{}}
initialValues={{}}
options={{}}
handleChangeMulti={jest.fn}
onSubmit={jest.fn}
id={{}}
/>
);
});

View File

@@ -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 ? (
<div style={{ textAlign: "center" }}>
{t("role.role_is_edited_msg")}
</div>
) : null;
} else {
alert = isEdited ? (
<div style={{ textAlign: "center" }}>{t("role.role_created_msg")}</div>
) : null;
}
const loadingScreen = (
<Screen>
<div className={classes.progressContainer}>
<CircularProgress className={classes.progress} />
</div>
</Screen>
);
/*
if (!!id || id===0) {
if (!isLoaded) {
return loadingScreen;
}
}
*/
if ((!!id || id === 0) && !isSent && !isLoaded) {
return loadingScreen;
}
const canSubmit = values && values.name ? true : false;
return (
<Screen>
<RoleCreateForm
multiPermissions={this.state.multiPermissions}
multiUsers={this.state.multiUsers}
initialValues={{ name: roleName }}
optionsPermissions={optionsPermissions}
optionsUsers={optionsUsers}
handleChangeMultiPermissions={this.handleChangeMultiPermissions}
handleChangeMultiUsers={this.handleChangeMultiUsers}
ref={form => (this.form = form)}
handleDelete={this.handleDelete}
onSubmit={this.handleSubmit}
canSubmit={canSubmit}
id={id}
/>
{alert}
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
open={isRoleWithErrors}
onClose={this.handleClose}
SnackbarContentProps={{
"aria-describedby": "message-id"
}}
//TODO replace displayed text with the message from the error object.
message={<span id="message-id">{t("role.error_message")}</span>}
/>
</Screen>
);
}
}
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));

328
src/components/RoleList.js Normal file
View File

@@ -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 (
<div className={classes.root}>
<IconButton
onClick={this.handleFirstPageButtonClick}
disabled={page === 0}
aria-label="First Page"
>
{theme.direction === "rtl" ? <LastPageIcon /> : <FirstPageIcon />}
</IconButton>
<IconButton
onClick={this.handleBackButtonClick}
disabled={page === 0}
aria-label="Previous Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowRight />
) : (
<KeyboardArrowLeft />
)}
</IconButton>
<IconButton
onClick={this.handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="Next Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowLeft />
) : (
<KeyboardArrowRight />
)}
</IconButton>
<IconButton
onClick={this.handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="Last Page"
>
{theme.direction === "rtl" ? <FirstPageIcon /> : <LastPageIcon />}
</IconButton>
</div>
);
}
}
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 = (
<Screen>
<div className={classes.progressContainer}>
<CircularProgress className={classes.progress} />
</div>
</Screen>
);
if (isLoading) {
return loadingScreen;
}
return (
<Screen>
<Typography type="headline" component="h2" align="center">
{t("role_list.header")}
</Typography>
<Paper className={classes.root}>
<div className={classes.tableWrapper}>
<Table className={classes.table} align="center">
<TableHead>
<TableRow>
<TableCell>{t("role_list.name")}</TableCell>
<TableCell style={{ width: 250 }}>
{t("role_list.permissions")}
</TableCell>
<TableCell style={{ width: 250 }}>
{t("role_list.users")}
</TableCell>
<TableCell>{t("role_list.edit")}</TableCell>
<TableCell>{t("role_list.delete")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(newRoles || [])
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map(role => {
return (
<TableRow key={role.id}>
<TableCell>{role.name}</TableCell>
<TableCell>
{role.permissions ? (
<div>
{role.permissions.map(permission => {
return (
<p key={permission.id}>{permission.name}</p>
);
})}
</div>
) : null}
</TableCell>
<TableCell>
{role.users ? (
<div>
{role.users.map(user => {
return <p key={user.id}>{user.username}</p>;
})}
</div>
) : null}
</TableCell>
<TableCell>
<Link to={`/role/edit/${role.id}`}>
<CreateIcon />
</Link>
</TableCell>
<TableCell>
<div
style={{ cursor: "pointer" }}
onClick={() => this.handleDelete(role.id)}
>
<DeleteIcon />
</div>
</TableCell>
</TableRow>
);
})}
{emptyRows > 0 && (
<TableRow style={{ height: 48 * emptyRows }}>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
colSpan={3}
count={(roles || []).length}
rowsPerPage={rowsPerPage}
page={page}
onChangePage={this.handleChangePage}
onChangeRowsPerPage={this.handleChangeRowsPerPage}
Actions={TablePaginationActionsWrapped}
/>
</TableRow>
</TableFooter>
</Table>
</div>
</Paper>
</Screen>
);
}
}
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));

263
src/components/Screen.js Normal file
View File

@@ -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
}) => (
<ListItem button>
{icon ? (
<ListItemIcon className={open ? undefined : classes.fullWidth}>
{icon}
</ListItemIcon>
) : null}
{open ? <ListItemText primary={label} /> : null}
</ListItem>
);
DrawerItem.propTypes = {
classes: PropTypes.object.isRequired,
open: PropTypes.bool
};
export const DrawerListBase = props => {
const { translate = true, header, items = [], t, classes, open } = props;
return (
<List>
{header ? (
<ListSubheader>
{translate ? t(header.label) : header.label}
</ListSubheader>
) : null}
{items.map((item, index) => {
const { label, link, ...rest } = item;
return (
<Link className={classes.noTextDecoration} key={index} to={link}>
<DrawerItem
classes={classes}
open={open}
label={translate ? t(label) : label}
{...rest}
/>
</Link>
);
})}
</List>
);
};
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: <ListIcon /> },
{ link: "/contact/create", label: "navigation.create", icon: <AddIcon /> }
];
const userSection = [
{ link: "/user/list", label: "navigation.list", icon: <ListIcon /> },
{ link: "/user/create", label: "navigation.create", icon: <AddIcon /> }
];
const roleSection = [
{ link: "/role/list", label: "navigation.list", icon: <ListIcon /> },
{ link: "/role/create", label: "navigation.create", icon: <AddIcon /> }
];
const groupSection = [
{ link: "/group/list", label: "navigation.list", icon: <ListIcon /> },
{ link: "/group/create", label: "navigation.create", icon: <AddIcon /> }
];
export class Screen extends React.Component {
render() {
const { classes, theme, children, closeDrawer, open } = this.props;
return (
<div className={classes.root}>
<AppBar
position="absolute"
className={classNames(classes.appBar, open && classes.appBarShift)}
>
<Toolbar />
</AppBar>
<Drawer
variant="permanent"
classes={{
paper: classNames(
classes.drawerPaper,
!open && classes.drawerPaperClose
)
}}
open={open}
>
<div className={classes.toolbar}>
<IconButton onClick={closeDrawer}>
{theme.direction === "rtl" ? (
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton>
</div>
<Divider />
<DrawerList
header={{ label: "navigation.header.contact" }}
items={contactSection}
open={open}
/>
<DrawerList
header={{ label: "navigation.header.user" }}
items={userSection}
open={open}
/>
<DrawerList
header={{ label: "Role" }}
items={roleSection}
open={open}
/>
<DrawerList
header={{ label: "navigation.header.group" }}
items={groupSection}
open={open}
/>
</Drawer>
<main className={classes.content}>
<div className={classes.toolbar} />
{children}
</main>
</div>
);
}
}
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;

View File

@@ -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(<Screen t={jest.fn()} classes={{}} theme={{}} />);
});
it("renders an AppBar", () => {
const component = shallow(<Screen t={jest.fn()} classes={{}} theme={{}} />);
const Appbar = component.find("WithStyles(AppBar)");
expect(Appbar).toHaveLength(1);
});
it("renders a useful Drawer with DrawerLists", () => {
const component = shallow(<Screen t={jest.fn()} classes={{}} theme={{}} />);
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(<DrawerItem classes={{}} />);
});
it("DrawerItem renders a ListItem", () => {
const component = shallow(<DrawerItem classes={{}} />);
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(
<DrawerItem icon={AddIcon} classes={{}} open={false} />
);
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(
<DrawerItem icon={AddIcon} classes={{}} open={true} />
);
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(<DrawerListBase t={jest.fn()} classes={{}} theme={{}} />);
});
const userSection = [
{ link: "/user/list", label: "navigation.list", icon: <ListIcon /> },
{ link: "/user/create", label: "navigation.create", icon: <AddIcon /> },
{ link: "/user/edit", label: "navigation.edit", icon: <CreateIcon /> }
];
it("renders correct Links in the DrawerList", () => {
const component = shallow(
<DrawerListBase
t={jest.fn()}
classes={{}}
theme={{}}
header={{ label: "navigation.header.user" }}
items={userSection}
/>
);
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");
});

184
src/components/Toolbar.js Normal file
View File

@@ -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 (
<MuiToolbar
disableGutters={true}
className={classNames(
classes.guttersRight,
open && classes.guttersLeft
)}
>
<div className={classes.main}>
<div className={classes.left}>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={openDrawer}
className={classNames(
classes.menuButton,
open && classes.hide,
className
)}
>
<MenuIcon />
</IconButton>
<Link className={classes.title} to="/">
<Typography variant="title" color="inherit" noWrap>
{t("title")}
</Typography>
</Link>
</div>
<div className={classes.right}>
<IconButton
aria-owns={menuOpen ? "menu-appbar" : null}
aria-haspopup="true"
onClick={this.handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right"
}}
transformOrigin={{
vertical: "top",
horizontal: "right"
}}
open={menuOpen}
onClose={this.handleClose}
>
<MenuItem onClick={this.handleProfileClick}>Profile</MenuItem>
<MenuItem onClick={this.handleLogout}>Logout</MenuItem>
</Menu>
</div>
</div>
</MuiToolbar>
);
};
}
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;

View File

@@ -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(<Toolbar {...props} />);
});
it("renders an multiple MenuItems", () => {
const component = shallow(<Toolbar {...props} />);
const MenuItems = component.find("WithStyles(MenuItem)");
expect(MenuItems.length).toBeGreaterThan(1);
});
it("renders a Menu", () => {
const wrapper = shallow(<Toolbar {...props} />);
const component = wrapper.find("WithStyles(Menu)");
expect(component).toHaveLength(1);
});
it("renders 2 IconButtons", () => {
const wrapper = shallow(<Toolbar {...props} />);
const component = wrapper.find("WithStyles(IconButton)");
expect(component).toHaveLength(2);
});

View File

@@ -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 }) => (
<TextField {...rest} {...input} error={touched && error} />
);
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 (
<form onSubmit={handleSubmit} className={classes.userCreateForm}>
<Paper className={classes.root} elevation={4}>
<Typography type="headline" component="h2">
{!!validID
? t("user.edit_user_header")
: t("user.create_user_header")}
</Typography>
<div className={classes.formRow}>
<Field
label={t("user.username")}
name="username"
component={renderTextField}
className={classes.formItem}
/>
</div>
<div className={classes.formRow}>
<Field
label={
!!validID ? t("user.password_msg_edit") : t("user.password")
}
name="password"
type="password"
component={renderTextField}
className={classes.formItem}
/>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectRoles"}
options={roles}
canAddMultipleValues={true}
handleChangeMulti={handleChange("multiRoles")}
multi={multiRoles}
placeholder={t("user.roles")}
/>
</div>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectGroups"}
options={groups}
canAddMultipleValues={true}
handleChangeMulti={handleChange("multiGroups")}
multi={multiGroups}
placeholder={t("user.select_groups")}
/>
</div>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<ReactSelect
className={classes.formItem}
name={"selectContact"}
options={contacts}
handleChangeMulti={handleChangeSingle("singleContact")}
multi={singleContact}
canAddMultipleValues={false}
placeholder={t("user.select_one_contact")}
/>
</div>
</div>
<div className={classes.formRow}>
<div className={classes.formItem}>
<Checkbox
checked={checkedAdmin}
name={"checkedAdmin"}
onChange={handleChangeCheckbox}
label={t("user.checkBoxAdmin")}
/>
</div>
<div className={classes.formItem}>
<Checkbox
checked={checkedEnabled}
name={"checkedEnabled"}
onChange={handleChangeCheckbox}
label={t("user.checkBoxEnabled")}
/>
</div>
</div>
<div className={classes.buttonsRow}>
<div className={classes.buttonRow}>
<Link className={classes.cancelButton} to="/user/list">
<Button color="primary">{t("user.cancel")}</Button>
</Link>
</div>
{!!validID ? (
<div className={classes.buttonRow}>
<Button onClick={handleDelete} color="primary">
{t("user.delete")}
</Button>
</div>
) : null}
<div className={classes.buttonRow}>
<Button color="primary" type="submit" disabled={!canSubmit}>
{!!validID ? t("user.save_user") : t("user.add_user")}
</Button>
</div>
</div>
</Paper>
</form>
);
}
}
UserCreateForm.propTypes = {
classes: PropTypes.object.isRequired,
t: PropTypes.func.isRequired
};
export default reduxForm({ form: "UserCreateForm", enableReinitialize: true })(
withStyles(styles)(translate()(UserCreateForm))
);

View File

@@ -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 ? (
<div style={{ textAlign: "center" }}>
{t("user.user_is_edited_msg")}
</div>
) : null;
} else {
alert = isEdited ? (
<div style={{ textAlign: "center" }}>{t("user.user_created_msg")}</div>
) : 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 = (
<Screen>
<div className={classes.progressContainer}>
<CircularProgress className={classes.progress} />
</div>
</Screen>
);
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 (
<Screen>
<UserCreateForm
initialValues={{ username: username }}
multiRoles={multiRoles}
multiGroups={multiGroups}
singleContact={singleContact}
handleChange={this.handleChange}
handleChangeSingle={this.handleChangeSingle}
handleChangeCheckbox={this.handleChangeCheckbox}
checkedAdmin={checkedAdmin}
checkedEnabled={checkedEnabled}
roles={mappedRoles}
groups={mappedGroups}
contacts={mappedContacts}
handleDelete={this.handleDelete}
ref={form => (this.form = form)}
onSubmit={this.handleSubmit}
canSubmit={canSubmit}
id={id}
/>
{alert}
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
open={isUserWithErrors}
onClose={this.handleClose}
SnackbarContentProps={{
"aria-describedby": "message-id"
}}
message={<span id="message-id">{t("user.error_message")}</span>}
/>
</Screen>
);
}
}
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));

199
src/components/UserList.js Normal file
View File

@@ -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 (
<div className={classes.root}>
<IconButton
onClick={this.handleBackButtonClick}
disabled={page === 0}
aria-label="Previous Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowRight />
) : (
<KeyboardArrowLeft />
)}
</IconButton>
<IconButton
onClick={this.handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="Next Page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowLeft />
) : (
<KeyboardArrowRight />
)}
</IconButton>
</div>
);
}
}
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 (
<Screen>
<Typography variant="title" align="center" gutterBottom>
{t("user_list.header")}
</Typography>
{users ? (
<div>
<Table className={classes.table} align="center">
<TableHead>
<TableRow>
<TableCell>{t("user_list.name")}</TableCell>
<TableCell>{t("user_list.edit")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map(user => {
return (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>
<Link to={`/user/edit/${user.id}`}>
<CreateIcon />
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
colSpan={3}
count={users.length}
rowsPerPage={rowsPerPage}
page={page}
onChangePage={this.handleChangePage}
onChangeRowsPerPage={this.handleChangeRowsPerPage}
Actions={TablePaginationActionsWrapped}
/>
</TableRow>
</TableFooter>
</Table>
</div>
) : null}
</Screen>
);
}
}
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));

View File

@@ -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"
}
];

34
src/config/index.js Normal file
View File

@@ -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;

8
src/i18n/de.js Normal file
View File

@@ -0,0 +1,8 @@
export default {
translation: {
hello: "Hallo",
login: {
button_login: "Einloggen"
}
}
};

100
src/i18n/en.js Normal file
View File

@@ -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"
}
}
};

30
src/i18n/i18n.js Normal file
View File

@@ -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;

6
src/i18n/index.js Normal file
View File

@@ -0,0 +1,6 @@
import i18n, { changeLanguage } from "./i18n"; // initialized i18next instance
import translations from "./translations";
export { i18n, translations, changeLanguage };
export default i18n;

7
src/i18n/translations.js Normal file
View File

@@ -0,0 +1,7 @@
import en from "./en.js";
import de from "./de.js";
export default {
en,
de
};

5
src/index.css Normal file
View File

@@ -0,0 +1,5 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}

11
src/index.js Normal file
View File

@@ -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(<App />, document.getElementById("root"));
registerServiceWorker();

114
src/lib/api.js Normal file
View File

@@ -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
};

97
src/lib/entity.js Normal file
View File

@@ -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;

7
src/logo.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

119
src/mocks/apiResponses.js Normal file
View File

@@ -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"
}
]
}
]
};

32
src/mocks/index.js Normal file
View File

@@ -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
}
}
};

View File

@@ -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
}
});

View File

@@ -0,0 +1,4 @@
export const RESET_GROUP_CREATE_STATE = "RESET_GROUP_CREATE_STATE";
export const resetGroupCreateState = () => ({
type: RESET_GROUP_CREATE_STATE
});

View File

@@ -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
});

View File

@@ -0,0 +1,6 @@
export const LOGOUT = "LOGOUT";
export const LOGIN = "LOGIN";
export const logout = () => ({
type: LOGOUT
});

View File

@@ -0,0 +1,4 @@
export const RESET_ROLE_CREATE_STATE = "RESET_ROLE_CREATE_STATE";
export const resetRoleCreateState = () => ({
type: RESET_ROLE_CREATE_STATE
});

View File

@@ -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
});

View File

@@ -0,0 +1,4 @@
export const RESET_USER_CREATE_STATE = "RESET_USER_CREATE_STATE";
export const resetUserCreateState = () => ({
type: RESET_USER_CREATE_STATE
});

60
src/redux/createStore.js Normal file
View File

@@ -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 };
};

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

31
src/redux/reducers/ui.js Normal file
View File

@@ -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;

View File

@@ -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
});
});

View File

@@ -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;

194
src/redux/sagas/index.js Normal file
View File

@@ -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)
]);
}

View File

@@ -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
);

View File

@@ -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);
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
export const getLogin = (key = "isLoggedIn") => state => state.login[key];
export const getLoginError = state => getLogin("error")(state);

View File

@@ -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`);

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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();
});
}
}

4
src/setupTests.js Normal file
View File

@@ -0,0 +1,4 @@
import enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
enzyme.configure({ adapter: new Adapter() });

3
src/version.js Normal file
View File

@@ -0,0 +1,3 @@
export default {
version: "0.5.0"
};