Improved flow coverage, fixed bugs and enabled deleting users

This commit is contained in:
Philipp Czora
2018-07-11 12:02:53 +02:00
parent 8f30907e96
commit e3caa93aa7
11 changed files with 118 additions and 91 deletions

View File

@@ -5,7 +5,6 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"classnames": "^2.2.5", "classnames": "^2.2.5",
"flow-bin": "^0.75.0",
"history": "^4.7.2", "history": "^4.7.2",
"react": "^16.4.1", "react": "^16.4.1",
"react-dom": "^16.4.1", "react-dom": "^16.4.1",
@@ -34,6 +33,8 @@
"devDependencies": { "devDependencies": {
"enzyme": "^3.3.0", "enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1", "enzyme-adapter-react-16": "^1.1.1",
"flow-bin": "^0.75.0",
"flow-typed": "^2.5.1",
"prettier": "^1.13.7", "prettier": "^1.13.7",
"react-test-renderer": "^16.4.1" "react-test-renderer": "^16.4.1"
}, },

View File

@@ -4,6 +4,7 @@
const apiUrl = process.env.API_URL || process.env.PUBLIC_URL || "/scm"; const apiUrl = process.env.API_URL || process.env.PUBLIC_URL || "/scm";
export const PAGE_NOT_FOUND_ERROR = Error("page not found"); export const PAGE_NOT_FOUND_ERROR = Error("page not found");
export const NOT_AUTHENTICATED_ERROR = Error("not authenticated");
const fetchOptions: RequestOptions = { const fetchOptions: RequestOptions = {
credentials: "same-origin", credentials: "same-origin",
@@ -15,7 +16,7 @@ const fetchOptions: RequestOptions = {
function handleStatusCode(response: Response) { function handleStatusCode(response: Response) {
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
return response; throw NOT_AUTHENTICATED_ERROR;
} }
if (response.status === 404) { if (response.status === 404) {
throw PAGE_NOT_FOUND_ERROR; throw PAGE_NOT_FOUND_ERROR;
@@ -26,11 +27,14 @@ function handleStatusCode(response: Response) {
} }
function createUrl(url: string) { function createUrl(url: string) {
if (url.indexOf("://") > 0) {
return url;
}
return `${apiUrl}/api/rest/v2/${url}`; return `${apiUrl}/api/rest/v2/${url}`;
} }
class ApiClient { class ApiClient {
get(url: string) { get(url: string): Promise<Response> {
return fetch(createUrl(url), fetchOptions).then(handleStatusCode); return fetch(createUrl(url), fetchOptions).then(handleStatusCode);
} }
@@ -38,7 +42,7 @@ class ApiClient {
return this.httpRequestWithJSONBody(url, payload, "POST"); return this.httpRequestWithJSONBody(url, payload, "POST");
} }
delete(url: string) { delete(url: string): Promise<Response> {
let options: RequestOptions = { let options: RequestOptions = {
method: "DELETE" method: "DELETE"
}; };
@@ -46,7 +50,11 @@ class ApiClient {
return fetch(createUrl(url), options).then(handleStatusCode); return fetch(createUrl(url), options).then(handleStatusCode);
} }
httpRequestWithJSONBody(url: string, payload: any, method: string) { httpRequestWithJSONBody(
url: string,
payload: any,
method: string
): Promise<Response> {
let options: RequestOptions = { let options: RequestOptions = {
method: method, method: method,
body: JSON.stringify(payload) body: JSON.stringify(payload)

View File

@@ -9,17 +9,19 @@ import { withRouter } from "react-router-dom";
type Props = { type Props = {
login: boolean, login: boolean,
username: string, username: string,
getAuthState: any getAuthState: () => void,
loading: boolean
}; };
class App extends Component { class App extends Component<Props> {
componentWillMount() { componentDidMount() {
this.props.getAuthState(); this.props.getAuthState();
} }
render() { render() {
const { login, username } = this.props.login; const { login, username, loading } = this.props;
if (loading) {
if (!login) { return <div>Loading...</div>;
} else if (!login) {
return ( return (
<div> <div>
<Login /> <Login />
@@ -44,7 +46,7 @@ const mapDispatchToProps = dispatch => {
}; };
const mapStateToProps = state => { const mapStateToProps = state => {
return { login: state.login }; return state.login || {};
}; };
export default withRouter( export default withRouter(

View File

@@ -1,6 +1,6 @@
//@flow //@flow
import { apiClient } from "../apiclient"; import { apiClient, NOT_AUTHENTICATED_ERROR } from "../apiclient";
const LOGIN_URL = "/auth/access_token"; const LOGIN_URL = "/auth/access_token";
const AUTHENTICATION_INFO_URL = "/me"; const AUTHENTICATION_INFO_URL = "/me";
@@ -21,7 +21,7 @@ export function getIsAuthenticatedRequest() {
} }
export function getIsAuthenticated() { export function getIsAuthenticated() {
return function(dispatch: (any) => void) { return function(dispatch: any => void) {
dispatch(getIsAuthenticatedRequest()); dispatch(getIsAuthenticatedRequest());
return apiClient return apiClient
.get(AUTHENTICATION_INFO_URL) .get(AUTHENTICATION_INFO_URL)
@@ -32,6 +32,13 @@ export function getIsAuthenticated() {
if (data) { if (data) {
dispatch(isAuthenticated(data.username)); dispatch(isAuthenticated(data.username));
} }
})
.catch((error: Error) => {
if (error === NOT_AUTHENTICATED_ERROR) {
dispatch(isNotAuthenticated());
} else {
// TODO: Handle errors other than not_authenticated
}
}); });
}; };
} }
@@ -60,9 +67,9 @@ export function login(username: string, password: string) {
cookie: true, cookie: true,
grant_type: "password", grant_type: "password",
username, username,
password, password
}; };
return function(dispatch: (any) => void) { return function(dispatch: any => void) {
dispatch(loginRequest()); dispatch(loginRequest());
return apiClient.post(LOGIN_URL, login_data).then(response => { return apiClient.post(LOGIN_URL, login_data).then(response => {
if (response.ok) { if (response.ok) {
@@ -79,23 +86,29 @@ export function loginSuccessful() {
}; };
} }
export default function reducer(state: any = {}, action: any = {}) { export default function reducer(
state: any = { loading: true },
action: any = {}
) {
switch (action.type) { switch (action.type) {
case LOGIN: case LOGIN:
return { return {
...state, ...state,
loading: true,
login: false, login: false,
error: null error: null
}; };
case LOGIN_SUCCESSFUL: case LOGIN_SUCCESSFUL:
return { return {
...state, ...state,
loading: false,
login: true, login: true,
error: null error: null
}; };
case LOGIN_FAILED: case LOGIN_FAILED:
return { return {
...state, ...state,
loading: false,
login: false, login: false,
error: action.payload error: action.payload
}; };
@@ -103,12 +116,14 @@ export default function reducer(state: any = {}, action: any = {}) {
return { return {
...state, ...state,
login: true, login: true,
loading: false,
username: action.username username: action.username
}; };
case IS_NOT_AUTHENTICATED: case IS_NOT_AUTHENTICATED:
return { return {
...state, ...state,
login: false, login: false,
loading: false,
username: null, username: null,
error: null error: null
}; };

6
scm-ui/src/types/hal.js Normal file
View File

@@ -0,0 +1,6 @@
// @flow
export type Link = {
href: string
};
export type Links = { [string]: Link };

View File

@@ -1,21 +1,17 @@
// @flow // @flow
import React from "react"; import React from "react";
import type { User } from "../types/User";
type Props = { type Props = {
user: any, user: User,
deleteUser: (link: string) => void deleteUser: (link: string) => void
}; };
class DeleteUser extends React.Component<Props> { class DeleteUser extends React.Component<Props> {
deleteUser = () => { deleteUser = () => {
this.props.deleteUser(this.props.user._links.delete.href); this.props.deleteUser(this.props.user._links.delete.href);
}; };
if(deleteButtonClicked) {
let deleteButtonAsk = <div>You really want to remove this user?</div>
}
isDeletable = () => { isDeletable = () => {
return this.props.user._links.delete; return this.props.user._links.delete;
}; };
@@ -28,7 +24,6 @@ class DeleteUser extends React.Component<Props> {
<button type="button" onClick={this.deleteUser}> <button type="button" onClick={this.deleteUser}>
Delete User Delete User
</button> </button>
); );
} }
} }

View File

@@ -1,42 +1,39 @@
import React from 'react'; import React from "react";
import {configure, shallow} from 'enzyme'; import { configure, shallow } from "enzyme";
import DeleteUserButton from "./DeleteUserButton"; import DeleteUserButton from "./DeleteUserButton";
import Adapter from 'enzyme-adapter-react-16'; import Adapter from "enzyme-adapter-react-16";
import 'raf/polyfill'; import "raf/polyfill";
configure({ adapter: new Adapter() }); configure({ adapter: new Adapter() });
it('should render nothing, if the delete link is missing', () => { it("should render nothing, if the delete link is missing", () => {
const user = { const user = {
_links: {} _links: {}
}; };
const button = shallow(<DeleteUserButton user={ user } />); const button = shallow(<DeleteUserButton user={user} />);
expect(button.text()).toBe(""); expect(button.text()).toBe("");
}); });
it('should render the button', () => { it("should render the button", () => {
const user = { const user = {
_links: { _links: {
"delete": { delete: {
"href": "/users" href: "/users"
} }
} }
}; };
const button = shallow(<DeleteUserButton user={ user } />); const button = shallow(<DeleteUserButton user={user} />);
expect(button.text()).not.toBe(""); expect(button.text()).not.toBe("");
}); });
it('should call the delete user function with delete url', () => { it("should call the delete user function with delete url", () => {
const user = { const user = {
_links: { _links: {
"delete": { delete: {
"href": "/users" href: "/users"
} }
} }
}; };
@@ -47,7 +44,7 @@ it('should call the delete user function with delete url', () => {
calledUrl = url; calledUrl = url;
} }
const button = shallow(<DeleteUserButton user={ user } deleteUser={ capture } />); const button = shallow(<DeleteUserButton user={user} deleteUser={capture} />);
button.simulate("click"); button.simulate("click");
expect(calledUrl).toBe("/users"); expect(calledUrl).toBe("/users");

View File

@@ -1,22 +1,26 @@
// @flow // @flow
import React from "react"; import React from "react";
import DeleteUserButton from "./DeleteUserButton"; import DeleteUserButton from "./DeleteUserButton";
import type { User } from "../types/User";
type Props = { type Props = {
user: any user: User,
deleteUser: string => void
}; };
export default class UserRow extends React.Component<Props> { export default class UserRow extends React.Component<Props> {
render() { render() {
const { user, deleteUser } = this.props;
return ( return (
<tr> <tr>
<td>{this.props.user.displayName}</td> <td>{user.name}</td>
<td>{this.props.user.mail}</td> <td>{user.displayName}</td>
<td>{user.mail}</td>
<td> <td>
<input type="checkbox" id="admin" checked={this.props.user.admin} /> <input type="checkbox" id="admin" checked={user.admin} readOnly />
</td> </td>
<td> <td>
<DeleteUserButton user={this.props.user} /> <DeleteUserButton user={user} deleteUser={deleteUser} />
</td> </td>
</tr> </tr>
); );

View File

@@ -2,27 +2,22 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { fetchUsersIfNeeded, fetchUsers } from "../modules/users"; import { fetchUsers, deleteUser } from "../modules/users";
import Login from "../../containers/Login"; import Login from "../../containers/Login";
import UserRow from "./UserRow"; import UserRow from "./UserRow";
import type { User } from "../types/User";
type Props = { type Props = {
login: boolean, login: boolean,
error: any, error: Error,
users: any, users: Array<User>,
fetchUsersIfNeeded: () => void,
fetchUsers: () => void, fetchUsers: () => void,
fetchUsersIfNeeded: (url: string) => void, deleteUser: string => void
}; };
class Users extends React.Component<Props> { class Users extends React.Component<Props> {
componentWillMount() { componentDidMount() {
this.props.fetchUsersIfNeeded(); this.props.fetchUsers();
}
componentDidUpdate() {
this.props.fetchUsersIfNeeded();
} }
render() { render() {
@@ -35,13 +30,20 @@ class Users extends React.Component<Props> {
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Display Name</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Admin</th> <th>Admin</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{this.props.users.map((user, index) => { {this.props.users.map((user, index) => {
return <UserRow key={index} user={user} />; return (
<UserRow
key={index}
user={user}
deleteUser={this.props.deleteUser}
/>
);
})} })}
</tbody> </tbody>
</table> </table>
@@ -61,11 +63,11 @@ const mapStateToProps = state => {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchUsersIfNeeded: () => {
dispatch(fetchUsersIfNeeded());
},
fetchUsers: () => { fetchUsers: () => {
dispatch(fetchUsers()); dispatch(fetchUsers());
},
deleteUser: (link: string) => {
dispatch(deleteUser(link));
} }
}; };
}; };

View File

@@ -1,5 +1,6 @@
// @flow // @flow
import { apiClient, PAGE_NOT_FOUND_ERROR } from "../../apiclient"; import { apiClient, PAGE_NOT_FOUND_ERROR } from "../../apiclient";
import { ThunkDispatch } from "redux-thunk";
const FETCH_USERS = "scm/users/FETCH"; const FETCH_USERS = "scm/users/FETCH";
const FETCH_USERS_SUCCESS = "scm/users/FETCH_SUCCESS"; const FETCH_USERS_SUCCESS = "scm/users/FETCH_SUCCESS";
@@ -34,7 +35,7 @@ function usersNotFound(url: string) {
} }
export function fetchUsers() { export function fetchUsers() {
return function(dispatch: any => void) { return function(dispatch: ThunkDispatch) {
dispatch(requestUsers()); dispatch(requestUsers());
return apiClient return apiClient
.get(USERS_URL) .get(USERS_URL)
@@ -66,21 +67,6 @@ function fetchUsersSuccess(users: any) {
}; };
} }
export function shouldFetchUsers(state: any): boolean {
if (state.users.users == null) {
return true;
}
return false;
}
export function fetchUsersIfNeeded() {
return (dispatch, getState) => {
if (shouldFetchUsers(getState())) {
dispatch(fetchUsers());
}
};
}
function requestDeleteUser(url: string) { function requestDeleteUser(url: string) {
return { return {
type: DELETE_USER, type: DELETE_USER,
@@ -102,41 +88,42 @@ function deleteUserFailure(url: string, err: Error) {
}; };
} }
export function deleteUser(username: string) { export function deleteUser(link: string) {
return function(dispatch) { return function(dispatch: ThunkDispatch) {
dispatch(requestDeleteUser(username)); dispatch(requestDeleteUser(link));
return apiClient return apiClient
.delete(USERS_URL + "/" + username) .delete(link)
.then(() => { .then(() => {
dispatch(deleteUserSuccess()); dispatch(deleteUserSuccess());
dispatch(fetchUsers());
}) })
.catch(err => dispatch(deleteUserFailure(username, err))); .catch(err => dispatch(deleteUserFailure(link, err)));
}; };
} }
export default function reducer(state: any = {}, action: any = {}) { export default function reducer(state: any = {}, action: any = {}) {
switch (action.type) { switch (action.type) {
case FETCH_USERS: case FETCH_USERS:
case DELETE_USER:
return { return {
...state, ...state,
users: null users: null,
loading: true
}; };
case FETCH_USERS_SUCCESS: case FETCH_USERS_SUCCESS:
return { return {
...state, ...state,
error: null, error: null,
users: action.payload._embedded.users users: action.payload._embedded.users,
loading: false
}; };
case FETCH_USERS_FAILURE: case FETCH_USERS_FAILURE:
case DELETE_USER_FAILURE:
return { return {
...state, ...state,
login: false, login: false,
error: action.payload error: action.payload,
}; loading: false
case DELETE_USER_SUCCESS:
return {
...state,
users: null
}; };
default: default:

View File

@@ -0,0 +1,10 @@
//@flow
import type { Link, Links } from "../../types/hal";
export type User = {
displayName: string,
name: string,
mail: string,
admin: boolean,
_links: Links
};