diff --git a/src/common/rootReducer.js b/src/common/rootReducer.js index ddc73c2..28d7f60 100644 --- a/src/common/rootReducer.js +++ b/src/common/rootReducer.js @@ -3,6 +3,7 @@ import { routerReducer } from 'react-router-redux'; import homeReducer from '../features/home/redux/reducer'; import commonReducer from '../features/common/redux/reducer'; import userAdminReducer from '../features/user-admin/redux/reducer'; +import roomAdminReducer from '../features/room-admin/redux/reducer'; // NOTE 1: DO NOT CHANGE the 'reducerMap' name and the declaration pattern. // This is used for Rekit cmds to register new features, remove features, etc. @@ -14,6 +15,7 @@ const reducerMap = { home: homeReducer, common: commonReducer, userAdmin: userAdminReducer, + roomAdmin: roomAdminReducer, }; export default combineReducers(reducerMap); diff --git a/src/common/routeConfig.js b/src/common/routeConfig.js index ed85646..5688d67 100644 --- a/src/common/routeConfig.js +++ b/src/common/routeConfig.js @@ -4,6 +4,7 @@ import _ from 'lodash'; import commonRoute from '../features/common/route'; import homeRoute from '../features/home/route'; import userAdminRoute from '../features/user-admin/route'; +import roomAdminRoute from '../features/room-admin/route'; // NOTE: DO NOT CHANGE the 'childRoutes' name and the declaration pattern. // This is used for Rekit cmds to register routes config for new features, and remove config when remove features, etc. @@ -11,6 +12,7 @@ const childRoutes = [ homeRoute, commonRoute, userAdminRoute, + roomAdminRoute, ]; const routes = [{ diff --git a/src/features/common/Layout.js b/src/features/common/Layout.js index 66db472..9ed3018 100644 --- a/src/features/common/Layout.js +++ b/src/features/common/Layout.js @@ -80,6 +80,10 @@ class Layout extends Component { component={Link} to="/user-admin"> + + + ); diff --git a/src/features/room-admin/List.js b/src/features/room-admin/List.js new file mode 100644 index 0000000..f424e50 --- /dev/null +++ b/src/features/room-admin/List.js @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Redirect } from 'react-router-dom' +import { Table, TableBody, TableCell, TableHead, TableRow } from '@material-ui/core'; +import * as actions from './redux/actions'; + +export class List extends Component { + static propTypes = { + roomAdmin: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + }; + + componentDidMount() { + const { fetchPublicRooms } = this.props.actions; + fetchPublicRooms(); + } + + render() { + const { mtx } = this.props; + const { roomList, fetchRoomsPending } = this.props.roomAdmin; + + return ( +
+ {(!mtx || !mtx.clientRunning) && } + {fetchRoomsPending && logging in} + + + + NameRoom-IDMembers + + + + {roomList.map(item => ({item.name}{item.room_id}{item.num_joined_members}))} + +
+
+ ); + } +} + +/* istanbul ignore next */ +function mapStateToProps(state) { + return { + mtx: state.common.mtx, + roomAdmin: state.roomAdmin, + }; +} + +/* istanbul ignore next */ +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ ...actions }, dispatch) + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(List); diff --git a/src/features/room-admin/List.scss b/src/features/room-admin/List.scss new file mode 100644 index 0000000..0cacc6b --- /dev/null +++ b/src/features/room-admin/List.scss @@ -0,0 +1,9 @@ +@import '../../styles/mixins'; + +.room-admin-list { + th { + background-color: #EEE; + font-size: 1rem; + font-weight: bold; + } +} diff --git a/src/features/room-admin/index.js b/src/features/room-admin/index.js new file mode 100644 index 0000000..2d01c86 --- /dev/null +++ b/src/features/room-admin/index.js @@ -0,0 +1 @@ +export { default as List } from './List'; diff --git a/src/features/room-admin/redux/actions.js b/src/features/room-admin/redux/actions.js new file mode 100644 index 0000000..dfa3047 --- /dev/null +++ b/src/features/room-admin/redux/actions.js @@ -0,0 +1 @@ +export { fetchPublicRooms, dismissFetchPublicRoomsError } from './fetchPublicRooms'; diff --git a/src/features/room-admin/redux/constants.js b/src/features/room-admin/redux/constants.js new file mode 100644 index 0000000..eccaf57 --- /dev/null +++ b/src/features/room-admin/redux/constants.js @@ -0,0 +1,4 @@ +export const ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN = 'ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN'; +export const ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS = 'ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS'; +export const ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE = 'ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE'; +export const ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR = 'ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR'; diff --git a/src/features/room-admin/redux/fetchPublicRooms.js b/src/features/room-admin/redux/fetchPublicRooms.js new file mode 100644 index 0000000..82f36ec --- /dev/null +++ b/src/features/room-admin/redux/fetchPublicRooms.js @@ -0,0 +1,88 @@ +import { + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN, + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS, + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE, + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR, +} from './constants'; + +// Rekit uses redux-thunk for async actions by default: https://github.com/gaearon/redux-thunk +// If you prefer redux-saga, you can use rekit-plugin-redux-saga: https://github.com/supnate/rekit-plugin-redux-saga +export function fetchPublicRooms(args = {}) { + return (dispatch, getState) => { + dispatch({ + type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN, + }); + + const promise = new Promise((resolve, reject) => { + const mtx = getState().common.mtx; + const doRequest = mtx.publicRooms(); + doRequest.then( + (res) => { + dispatch({ + type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS, + data: res, + }); + resolve(res); + }, + // Use rejectHandler as the second argument so that render errors won't be caught. + (err) => { + dispatch({ + type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE, + data: { error: err }, + }); + reject(err); + }, + ); + }); + + return promise; + }; +} + +// Async action saves request error by default, this method is used to dismiss the error info. +// If you don't want errors to be saved in Redux store, just ignore this method. +export function dismissFetchPublicRoomsError() { + return { + type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR, + }; +} + +export function reducer(state, action) { + switch (action.type) { + case ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN: + // Just after a request is sent + return { + ...state, + fetchPublicRoomsPending: true, + fetchPublicRoomsError: null, + }; + + case ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS: + // The request is success + return { + ...state, + roomList: action.data.chunk, + fetchPublicRoomsPending: false, + fetchPublicRoomsError: null, + //roomList: action.data.chunk, + }; + + case ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE: + // The request is failed + return { + ...state, + fetchPublicRoomsPending: false, + fetchPublicRoomsError: action.data.error, + }; + + case ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR: + // Dismiss the request failure error + return { + ...state, + fetchPublicRoomsError: null, + }; + + default: + return state; + } +} diff --git a/src/features/room-admin/redux/initialState.js b/src/features/room-admin/redux/initialState.js new file mode 100644 index 0000000..f3860da --- /dev/null +++ b/src/features/room-admin/redux/initialState.js @@ -0,0 +1,14 @@ +// Initial state is the place you define all initial values for the Redux store of the feature. +// In the 'standard' way, initialState is defined in reducers: http://redux.js.org/docs/basics/Reducers.html +// But when application grows, there will be multiple reducers files, it's not intuitive what data is managed by the whole store. +// So Rekit extracts the initial state definition into a separate module so that you can have +// a quick view about what data is used for the feature, at any time. + +// NOTE: initialState constant is necessary so that Rekit could auto add initial state when creating async actions. +const initialState = { + fetchPublicRoomsPending: false, + fetchPublicRoomsError: null, + roomList: [], +}; + +export default initialState; diff --git a/src/features/room-admin/redux/reducer.js b/src/features/room-admin/redux/reducer.js new file mode 100644 index 0000000..3da8f75 --- /dev/null +++ b/src/features/room-admin/redux/reducer.js @@ -0,0 +1,25 @@ +// This is the root reducer of the feature. It is used for: +// 1. Load reducers from each action in the feature and process them one by one. +// Note that this part of code is mainly maintained by Rekit, you usually don't need to edit them. +// 2. Write cross-topic reducers. If a reducer is not bound to some specific action. +// Then it could be written here. +// Learn more from the introduction of this approach: +// https://medium.com/@nate_wang/a-new-approach-for-managing-redux-actions-91c26ce8b5da. + +import initialState from './initialState'; +import { reducer as fetchPublicRoomsReducer } from './fetchPublicRooms'; + +const reducers = [ + fetchPublicRoomsReducer, +]; + +export default function reducer(state = initialState, action) { + let newState; + switch (action.type) { + // Handle cross-topic actions here + default: + newState = state; + break; + } + return reducers.reduce((s, r) => r(s, action), newState); +} diff --git a/src/features/room-admin/route.js b/src/features/room-admin/route.js new file mode 100644 index 0000000..77c3a8d --- /dev/null +++ b/src/features/room-admin/route.js @@ -0,0 +1,16 @@ +// This is the JSON way to define React Router rules in a Rekit app. +// Learn more from: http://rekit.js.org/docs/routing.html + +import { + List, +} from './'; +import { Layout } from '../common'; + +export default { + path: 'room-admin', + name: 'Room admin', + component: Layout, + childRoutes: [ + { path: 'list', name: 'List', component: List, isIndex: true }, + ], +}; diff --git a/src/features/room-admin/style.scss b/src/features/room-admin/style.scss new file mode 100644 index 0000000..9c6d373 --- /dev/null +++ b/src/features/room-admin/style.scss @@ -0,0 +1,2 @@ +@import '../../styles/mixins'; +@import './List'; diff --git a/src/styles/index.scss b/src/styles/index.scss index fd4a49a..1c1af56 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -3,3 +3,4 @@ @import '../features/home/style'; @import '../features/common/style'; @import '../features/user-admin/style'; +@import '../features/room-admin/style'; diff --git a/tests/features/room-admin/List.test.js b/tests/features/room-admin/List.test.js new file mode 100644 index 0000000..efed3c0 --- /dev/null +++ b/tests/features/room-admin/List.test.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { List } from '../../../src/features/room-admin/List'; + +describe('room-admin/List', () => { + it('renders node with correct class name', () => { + const props = { + roomAdmin: {}, + actions: {}, + }; + const renderedComponent = shallow( + + ); + + expect( + renderedComponent.find('.room-admin-list').length + ).toBe(1); + }); +}); diff --git a/tests/features/room-admin/redux/fetchPublicRooms.test.js b/tests/features/room-admin/redux/fetchPublicRooms.test.js new file mode 100644 index 0000000..910761c --- /dev/null +++ b/tests/features/room-admin/redux/fetchPublicRooms.test.js @@ -0,0 +1,97 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import nock from 'nock'; + +import { + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN, + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS, + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE, + ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR, +} from '../../../../src/features/room-admin/redux/constants'; + +import { + fetchPublicRooms, + dismissFetchPublicRoomsError, + reducer, +} from '../../../../src/features/room-admin/redux/fetchPublicRooms'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('room-admin/redux/fetchPublicRooms', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('dispatches success action when fetchPublicRooms succeeds', () => { + const store = mockStore({}); + + return store.dispatch(fetchPublicRooms()) + .then(() => { + const actions = store.getActions(); + expect(actions[0]).toHaveProperty('type', ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN); + expect(actions[1]).toHaveProperty('type', ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS); + }); + }); + + it('dispatches failure action when fetchPublicRooms fails', () => { + const store = mockStore({}); + + return store.dispatch(fetchPublicRooms({ error: true })) + .catch(() => { + const actions = store.getActions(); + expect(actions[0]).toHaveProperty('type', ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN); + expect(actions[1]).toHaveProperty('type', ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE); + expect(actions[1]).toHaveProperty('data.error', expect.anything()); + }); + }); + + it('returns correct action by dismissFetchPublicRoomsError', () => { + const expectedAction = { + type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR, + }; + expect(dismissFetchPublicRoomsError()).toEqual(expectedAction); + }); + + it('handles action type ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN correctly', () => { + const prevState = { fetchPublicRoomsPending: false }; + const state = reducer( + prevState, + { type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_BEGIN } + ); + expect(state).not.toBe(prevState); // should be immutable + expect(state.fetchPublicRoomsPending).toBe(true); + }); + + it('handles action type ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS correctly', () => { + const prevState = { fetchPublicRoomsPending: true }; + const state = reducer( + prevState, + { type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_SUCCESS, data: {} } + ); + expect(state).not.toBe(prevState); // should be immutable + expect(state.fetchPublicRoomsPending).toBe(false); + }); + + it('handles action type ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE correctly', () => { + const prevState = { fetchPublicRoomsPending: true }; + const state = reducer( + prevState, + { type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_FAILURE, data: { error: new Error('some error') } } + ); + expect(state).not.toBe(prevState); // should be immutable + expect(state.fetchPublicRoomsPending).toBe(false); + expect(state.fetchPublicRoomsError).toEqual(expect.anything()); + }); + + it('handles action type ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR correctly', () => { + const prevState = { fetchPublicRoomsError: new Error('some error') }; + const state = reducer( + prevState, + { type: ROOM_ADMIN_FETCH_PUBLIC_ROOMS_DISMISS_ERROR } + ); + expect(state).not.toBe(prevState); // should be immutable + expect(state.fetchPublicRoomsError).toBe(null); + }); +}); + diff --git a/tests/features/room-admin/redux/reducer.test.js b/tests/features/room-admin/redux/reducer.test.js new file mode 100644 index 0000000..9415324 --- /dev/null +++ b/tests/features/room-admin/redux/reducer.test.js @@ -0,0 +1,14 @@ +import reducer from '../../../../src/features/room-admin/redux/reducer'; + +describe('room-admin/redux/reducer', () => { + it('does nothing if no matched action', () => { + const prevState = {}; + const state = reducer( + prevState, + { type: '__UNKNOWN_ACTION_TYPE__' } + ); + expect(state).toBe(prevState); + }); + + // TODO: add global reducer test if needed. +});