diff --git a/src/components/FetchSelf.js b/src/components/FetchSelf.js new file mode 100644 index 0000000000000000000000000000000000000000..bcfea4388fea5d096b1a1711bcfdff625f30cfd2 --- /dev/null +++ b/src/components/FetchSelf.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { getSelf } from '../redux/actions/lookupApi'; + +/** + * A component which causes the authenticated user's profile to be fetched from lookup when the + * user logs in and the profile has not previously been fetched. Since this list needs only to be + * fetched once, place it *outside* of any dynamic routes, etc. + * + * The fetching waits until auth.isLoggedIn becomes true. + */ +const FetchSelf = ({ isLoggedIn, self, selfLoading, getSelf }) => { + + // If we are signed in and we haven't retrieved (and aren't retrieving) the profile - + // then retrieve the profile. + if (isLoggedIn && !self && !selfLoading) { + getSelf(); + } + + return null; +}; + +FetchSelf.propTypes = { + isLoggedIn: PropTypes.bool.isRequired, + self: PropTypes.object, + selfLoading: PropTypes.bool.isRequired, + getSelf: PropTypes.func.isRequired, +}; + +const mapStateToProps = ({ auth: { isLoggedIn }, lookupApi: { self, selfLoading } }) => ({ + isLoggedIn, self, selfLoading +}); + +const mapDispatchToProps = { getSelf }; + +export default connect(mapStateToProps, mapDispatchToProps)(FetchSelf); diff --git a/src/components/FetchSelf.test.js b/src/components/FetchSelf.test.js new file mode 100644 index 0000000000000000000000000000000000000000..7a4d40723e35db95da04f8a6bac8491931e04d03 --- /dev/null +++ b/src/components/FetchSelf.test.js @@ -0,0 +1,31 @@ +import React from 'react'; +import {createMockStore, DEFAULT_INITIAL_STATE, render} from "../testutils"; +import { FetchSelf } from "."; +import {PEOPLE_GET_SELF_REQUEST} from "../redux/actions/lookupApi"; + +// Check that the profile is retrieved if we are logged in. +test('The profile is retrieved if we are logged in', () => { + const store = createMockStore(DEFAULT_INITIAL_STATE); + const instance = render(<FetchSelf />, { store, url: '/' }); + expect(store.getActions()).toEqual([{type: PEOPLE_GET_SELF_REQUEST}]); +}); + +// Check that the profile isn't retrieved if we are mid fetch. +test("The profile isn't retrieved if we are mid fetch", () => { + const store = createMockStore({ + ...DEFAULT_INITIAL_STATE, + lookupApi: {...DEFAULT_INITIAL_STATE.lookupApi, selfLoading: true} + }); + const instance = render(<FetchSelf />, { store, url: '/' }); + expect(store.getActions()).toEqual([]); +}); + +// Check that the profile isn't retrieved twice. +test("The profile isn't retrieved twice", () => { + const store = createMockStore({ + ...DEFAULT_INITIAL_STATE, + lookupApi: {...DEFAULT_INITIAL_STATE.lookupApi, self: {visibleName: "M. Bamford"}} + }); + const instance = render(<FetchSelf />, { store, url: '/' }); + expect(store.getActions()).toEqual([]); +}); diff --git a/src/components/LogoutLink.js b/src/components/LogoutLink.js index 66226da00038737e2777ac7dde65bed6b167087a..ee004fcb0d8f83fa8e6d313305baca88c4df8776 100644 --- a/src/components/LogoutLink.js +++ b/src/components/LogoutLink.js @@ -2,13 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { logout } from '../redux/actions/auth'; +import { resetSelf } from "../redux/actions/lookupApi"; /** * A link whose action will always log the current user out. */ -const LogoutLink = ({logout, children, ...rest}) => ( +const LogoutLink = ({logout, resetSelf, children, ...rest}) => ( // eslint-disable-next-line - <a href="#" onClick={logout} {...rest}>{ children }</a> + <a href="#" onClick={() => {logout(); resetSelf()}} {...rest}>{ children }</a> ); LogoutLink.propTypes = { @@ -16,6 +17,6 @@ LogoutLink.propTypes = { logout: PropTypes.func.isRequired }; -const mapDispatchToProps = { logout }; +const mapDispatchToProps = { logout, resetSelf }; export default connect(null, mapDispatchToProps)(LogoutLink); diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index b9af8bd18a42822a32d1d4f903c08250fc4b8282..8ca0e868dea553d0d50fccc83b5c1b47f56b15d5 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -1,41 +1,54 @@ -import React from 'react'; +import React from 'react' import { withStyles } from 'material-ui/styles'; import LogoutLink from './LogoutLink'; import Divider from 'material-ui/Divider'; import Toolbar from 'material-ui/Toolbar'; -import List from 'material-ui/List'; +import { List, ListItem } from 'material-ui'; import SidebarNavLink from './SidebarNavLink'; import Logo from '../images/cambridgeuniversity_logo.svg'; +import {connect} from "react-redux"; const styles = theme => ({ drawerHeader: theme.mixins.toolbar, nested: { paddingLeft: theme.spacing.unit * 4 }, camLogo: { width: '145px', paddingTop: '10px'}, - tagLine: { fontSize: 12}, - logoToolbar: { flexDirection:'column', alignItems: 'flex-start', paddingLeft: theme.spacing.unit * 2 } + tagLine: { fontSize: 12 }, + logoToolbar: { flexDirection:'column', alignItems: 'flex-start', paddingLeft: theme.spacing.unit * 2 }, + assetHeading: { padding: '12px 16px' } }); /** * The content of the IAR application side bar. */ -const Sidebar = ({ classes, history, logout }) => ( - <div> - <div className={classes.drawerHeader}> - <Toolbar className={classes.logoToolbar} disableGutters={true}> - <img src={Logo} className={classes.camLogo} alt="Cambridge University Logo"/> - <div className={classes.tagLine}>Information Asset Register</div> - </Toolbar> +const Sidebar = ({ classes, institutions, pathname }) => ( + <div> + <div className={classes.drawerHeader}> + <Toolbar className={classes.logoToolbar} disableGutters={true}> + <img src={Logo} className={classes.camLogo} alt="Cambridge University Logo"/> + <div className={classes.tagLine}>Information Asset Register</div> + </Toolbar> + </div> + <Divider /> + + <List component='nav'> + <ListItem className={classes.assetHeading}>Assets:</ListItem> + { + /* TODO if you don't pass pathname here then "by department" Sidebar items don't re-render and item selection isn't updated */ + institutions.map(({ instid, name }) => ( + <SidebarNavLink key={instid} to={'/assets/' + instid} label={name} className={classes.nested} pathname={pathname} /> + )) + } + {/* TODO if you don't pass pathname here then "by department" Sidebar items don't re-render and item selection isn't updated */} + <SidebarNavLink to='/assets' label='All departments' className={classes.nested} pathname={pathname} /> + <SidebarNavLink to='/help' label='Help' /> + <SidebarNavLink component={LogoutLink} label='Sign out' /> + </List> </div> - <Divider /> +) - <List component='nav'> - <SidebarNavLink to='/assets/all' label='All Assets' /> - <SidebarNavLink to='/assets/dept' label='Department of Foo' className={classes.nested} /> - <SidebarNavLink to='/help' label='Help' /> - <SidebarNavLink component={LogoutLink} label='Sign out' /> - </List> - </div> -); +const mapStateToProps = ({ lookupApi: { self } }) => ({ + institutions: self && self.institutions ? self.institutions : [] +}); -export default withStyles(styles)(Sidebar); +export default connect(mapStateToProps)(withStyles(styles)(Sidebar)); diff --git a/src/components/Sidebar.test.js b/src/components/Sidebar.test.js new file mode 100644 index 0000000000000000000000000000000000000000..7610be2c265ba224303c6747104ee25ed10adf59 --- /dev/null +++ b/src/components/Sidebar.test.js @@ -0,0 +1,21 @@ +import React from 'react'; +import {createMockStore, DEFAULT_INITIAL_STATE, render} from "../testutils"; +import { Sidebar } from "."; +import SidebarNavLink from "./SidebarNavLink"; + +// Check that Sidebar items are rendered as expected. +test('Sidebar items are rendered', () => { + const self = { + institutions: [ + {instid: 'UIS', name: 'University Information Services'} + ] + }; + const store = createMockStore({...DEFAULT_INITIAL_STATE, lookupApi: {self}}); + const testInstance = render(<Sidebar />, { store, url: '/' }); + const sidebarNavLinks = testInstance.findAllByType(SidebarNavLink); + expect(sidebarNavLinks).toHaveLength(4); + expect(sidebarNavLinks.find(link => link.props.label === 'University Information Services').props.to).toBe("/assets/UIS"); + expect(sidebarNavLinks.find(link => link.props.label === 'All departments').props.to).toBe("/assets"); + expect(sidebarNavLinks.find(link => link.props.label === 'Help').props.to).toBe("/help"); + expect(sidebarNavLinks.find(link => link.props.label === 'Sign out')).not.toBeNull(); +}); diff --git a/src/components/index.js b/src/components/index.js index 4d7ef6183387fa72f2f5e008e4ee89277c4a32bc..8cf95a153e91abbb54480caf1b49d5c427decd85 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,12 +1,23 @@ import AssetFormHeader from './AssetFormHeader'; -import AssetListHeader from './AssetListHeader'; import AssetListItem from './AssetListItem'; import BooleanChoice from './BooleanChoice'; import CheckboxGroup from './CheckboxGroup'; +import DeleteConfirmationDialog from './DeleteConfirmationDialog'; +import FetchSelf from './FetchSelf'; import Lookup from './Lookup'; -import LoginButton from './LoginButton'; import LoginRequiredRoute from './LoginRequiredRoute'; -import LogoutLink from './LogoutLink'; +import ScrollToTop from './ScrollToTop'; import Sidebar from './Sidebar'; +import Snackbar from './Snackbar'; -export { AssetFormHeader, AssetListHeader, AssetListItem, BooleanChoice, CheckboxGroup, LoginButton, LoginRequiredRoute, LogoutLink, Lookup, Sidebar } +export { + AssetFormHeader, AssetListItem, + BooleanChoice, + CheckboxGroup, + DeleteConfirmationDialog, + FetchSelf, + LoginRequiredRoute, Lookup, + ScrollToTop, + Sidebar, + Snackbar +} diff --git a/src/containers/App.js b/src/containers/App.js index baddaf2a1442cc36e994acecda53c5fd5430102e..0997be7570184ace166f850a3700b8c4a6daa100 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -3,11 +3,9 @@ import { Provider as ReduxProvider } from 'react-redux'; import { BrowserRouter as Router } from 'react-router-dom' import { MuiThemeProvider } from 'material-ui/styles'; import { IntlProvider } from 'react-intl'; -import Snackbar from '../components/Snackbar'; +import { DeleteConfirmationDialog, FetchSelf, ScrollToTop, Snackbar } from '../components'; import PropTypes from 'prop-types'; import AppRoutes from './AppRoutes'; -import DeleteConfirmationDialog from '../components/DeleteConfirmationDialog'; -import ScrollToTop from '../components/ScrollToTop'; import theme from '../style/CustomMaterialTheme'; import '../style/App.css'; @@ -26,6 +24,7 @@ const App = ({ store }) => ( </Router> <DeleteConfirmationDialog /> <Snackbar /> + <FetchSelf /> </div> </ReduxProvider> </IntlProvider> diff --git a/src/containers/AppRoutes.js b/src/containers/AppRoutes.js index 889932e40af97a5c3ec0e56607b87a6655d36679..8d5265fbcbfbb3b9e033bb591d5aada9416d4925 100644 --- a/src/containers/AppRoutes.js +++ b/src/containers/AppRoutes.js @@ -14,13 +14,14 @@ import NotFoundPage from './NotFoundPage'; const AppRoutes = () => ( <Switch> <LoginRequiredRoute path="/assets/:filter" exact component={AssetList}/> + <LoginRequiredRoute path="/assets" exact component={AssetList}/> <LoginRequiredRoute path="/asset/:assetId" exact component={routeProps => <AssetForm navigateOnSave='/' {...routeProps} />} /> <LoginRequiredRoute path="/help" exact component={() => <Static page='help' />}/> <Route path="/oauth2-callback" exact component={() => <div />} /> - <Redirect from='/' exact to='/assets/dept' /> + <Redirect from='/' exact to='/assets' /> { /* Catch all route for "not found" */ } <Route path="*" component={NotFoundPage} /> diff --git a/src/containers/AppRoutes.test.js b/src/containers/AppRoutes.test.js index 851f9285e3f9372681263299f746335ddebcaaee..5981aabb79af543e5a6bff8e7fab7b47d94cc82e 100644 --- a/src/containers/AppRoutes.test.js +++ b/src/containers/AppRoutes.test.js @@ -21,22 +21,30 @@ test('can render /help', () => { expect(appBarTitle(testInstance)).toBe('Help') }); -test('can render /assets/dept', () => { - const testInstance = render(<AppRoutes/>, {url: '/assets/dept'}); +test('can render /assets/UIS', () => { + const self = { + institutions: [ + {instid: 'UIS', name: 'University Information Services'} + ] + }; + const testInstance = render(<AppRoutes/>, { + url: '/assets/UIS', + store: createMockStore({...DEFAULT_INITIAL_STATE, lookupApi: {self}}) + }); - expect(appBarTitle(testInstance)).toBe('Assets: My department') + expect(appBarTitle(testInstance)).toBe('Assets: University Information Services') }); test('can render /assets/all', () => { const testInstance = render(<AppRoutes/>, {url: '/assets/all'}); - expect(appBarTitle(testInstance)).toBe('Assets: All') + expect(appBarTitle(testInstance)).toBe('Assets: All departments') }); -test('/ redirects to /assets/dept', () => { +test('/ redirects to /assets/all', () => { const testInstance = render(<AppRoutes/>, {url: '/'}); - expect(appBarTitle(testInstance)).toBe('Assets: My department') + expect(appBarTitle(testInstance)).toBe('Assets: All departments') }); test('can render /asset/create', () => { diff --git a/src/containers/AssetForm.js b/src/containers/AssetForm.js index c2438fedc80791e3dfdca6ab1b4e36355a0429ec..5431fedeed261dcfffa96ee60ec623d7d37b371c 100644 --- a/src/containers/AssetForm.js +++ b/src/containers/AssetForm.js @@ -419,7 +419,7 @@ AssetForm.propTypes = { const mapDispatchToProps = { snackbarOpen, getAsset, postAsset, putAsset }; -const mapStateToProps = ({ assets } , { match : {params: {assetId} } } ) => { +const mapStateToProps = ({ assets }, { match : {params: {assetId} } } ) => { let assetUrl, asset = null; diff --git a/src/containers/AssetForm.test.js b/src/containers/AssetForm.test.js index cc434502a25bdf7f0146b093acbd45db4c7bf3c7..01119fb1c9468078bcc7e688977dcc257dd88755 100644 --- a/src/containers/AssetForm.test.js +++ b/src/containers/AssetForm.test.js @@ -12,6 +12,7 @@ import AssetForm from "./AssetForm"; import {Route} from 'react-router-dom'; import AssetFormHeader from '../components/AssetFormHeader'; import {SNACKBAR_OPEN} from '../redux/actions/snackbar'; +import {PEOPLE_GET_SELF_REQUEST} from "../redux/actions/lookupApi"; const NEW_ASSET_FIXTURE = { name: 'Super Secret Medical Data', @@ -39,6 +40,7 @@ const ASSET_FIXTURE = {...NEW_ASSET_FIXTURE, url: ASSET_FIXTURE_URL}; beforeEach(() => { fetch_mock.get(process.env.REACT_APP_ENDPOINT_LOOKUP + 'people/crsid/mb2174', {}); + fetch_mock.get(process.env.REACT_APP_ENDPOINT_LOOKUP + 'people/token/self?fetch=all_insts', {}); fetch_mock.get(process.env.REACT_APP_ENDPOINT_ASSETS + 'e20f4cd4-9f97-4829-8178-476c7a67eb97/', {}); }); @@ -85,7 +87,9 @@ test('can populate a form with data', () => { url: '/asset/e20f4cd4-9f97-4829-8178-476c7a67eb97', store }); - expect(store.getActions()).toEqual([{meta: {url: ASSET_FIXTURE_URL}, type: ASSET_GET_REQUEST}]); + expect(store.getActions()).toEqual([ + {meta: {url: ASSET_FIXTURE_URL}, type: ASSET_GET_REQUEST} + ]); // test the ASSET_GET_REQUEST is dispatched diff --git a/src/containers/AssetList.js b/src/containers/AssetList.js index dc39d5344da0a97c834010917aeccb4c854f7cd7..4255c5a1aa822d1d4a3f44d8f39236457979c955 100644 --- a/src/containers/AssetList.js +++ b/src/containers/AssetList.js @@ -10,19 +10,15 @@ import { getAssets, Direction } from '../redux/actions/assetRegisterApi'; import '../style/App.css'; -const TITLES = { - '/assets/dept': 'Assets: My department', - '/assets/all': 'Assets: All', -}; - // Default query to use if none has previously been set by the user. export const DEFAULT_QUERY = { sort: { field: 'updated_at', direction: Direction.descending }, }; class AssetList extends Component { + componentDidMount() { - const { getAssets, fetchedAt } = this.props; + const { fetchedAt } = this.props; // Fetch an asset list if one has not already been fetched. We detect an existing fetch by // looking at the "fetchedAt" value on the asset list which should be non-NULL if a fetch @@ -30,21 +26,44 @@ class AssetList extends Component { if(!fetchedAt) { // If there is currently a sort query set by the user, use that otherwise update the query // with a default sort. - const { query } = this.props; + const { query, match : {params: {filter}} } = this.props; const { sort: { field } } = query; if(field !== null) { - getAssets(query); + this.getAssetsFilteredByDept(query, filter); } else { - getAssets({ ...query, ...DEFAULT_QUERY }); + this.getAssetsFilteredByDept({ ...query, ...DEFAULT_QUERY }, filter); } } } + /** + * If the department filter has changed then re-fetch the list. + */ + componentWillReceiveProps({match : {params: {filter: nextFilter}} }) { + const {match : {params: {filter}}, query} = this.props; + if (nextFilter !== filter) { + this.getAssetsFilteredByDept(query, nextFilter); + } + } + + /** + * Method to apply or remove a department filter to a getAssets() call. + */ + getAssetsFilteredByDept(query, filter = null) { + if (filter) { + query.filter = {...query.filter, department: filter} + } else { + delete query.filter.department + } + + this.props.getAssets(query); + } + render() { - const { match } = this.props; + const { institution } = this.props; return ( <Page> - <AssetListHeader title={TITLES[match.url]} /> + <AssetListHeader title={'Assets: ' + (institution ? institution.name : 'All departments')} /> {/* Table of currently loaded assets. */} <AssetTable /> @@ -56,7 +75,7 @@ class AssetList extends Component { </Page> ); } -}; +} AssetList.propTypes = { match: PropTypes.object.isRequired, @@ -64,9 +83,12 @@ AssetList.propTypes = { query: PropTypes.object.isRequired, }; -const mapStateToProps = ({ assets: { fetchedAt, query } }) => ( - { fetchedAt, query } -); +const mapStateToProps = ({assets: {fetchedAt, query}, lookupApi: {self}}, {match : {params: {filter}}}) => { + // map the institution selected by the filter - if any. + const institutions = (self && self.institutions ? self.institutions : []); + const institution = institutions.find(institution => institution.instid === filter); + return { fetchedAt, query, institution }; +}; const mapDispatchToProps = { getAssets }; diff --git a/src/containers/AssetList.test.js b/src/containers/AssetList.test.js index a2ef0e7d986aa6d3999db427bac1c5ec33864b06..1eac6d846e65f604050ae4fc01b89545381d05aa 100644 --- a/src/containers/AssetList.test.js +++ b/src/containers/AssetList.test.js @@ -25,12 +25,12 @@ beforeEach(() => { // re-worked, we can move to testing the AssetList component directly. test('AssetList can render', () => { - const testInstance = render(<AppRoutes />, { url: '/assets/all' }); + render(<AppRoutes />, { url: '/assets/all' }); }); test('AssetList sends a default query if none is set', () => { const store = createMockStore(DEFAULT_INITIAL_STATE); - const testInstance = render(<AppRoutes />, { store, url: '/assets/all' }); + render(<AppRoutes />, { store, url: '/assets/all' }); // getAssets was called once expect(getAssets.mock.calls).toHaveLength(1); @@ -53,7 +53,7 @@ test('AssetList respects the current query', () => { initialState.assets = { ...initialState.assets, query: initialQuery }; const store = createMockStore(initialState); - const testInstance = render(<AppRoutes />, { store, url: '/assets/all' }); + render(<AppRoutes />, { store, url: '/assets/all' }); // getAssets was called once expect(getAssets.mock.calls).toHaveLength(1); @@ -66,3 +66,13 @@ test('AssetList respects the current query', () => { expect(query[name]).toEqual(initialQuery[name]); }); }); + +// check that a department filter is set +test('A department filter is set', () => { + render(<AppRoutes />, { url: '/assets/UIS' }); + + expect(getAssets.mock.calls).toHaveLength(1); + + const [ [ query ] ] = getAssets.mock.calls; + expect(query.filter.department).toEqual('UIS'); +}); diff --git a/src/containers/Page.js b/src/containers/Page.js index 326790c32ed3a49595d58abc08cc1d43fecfd761..601be14cd8ca479ba9d86181d3f81e643a325059 100644 --- a/src/containers/Page.js +++ b/src/containers/Page.js @@ -5,6 +5,7 @@ import React from 'react'; import { withStyles } from 'material-ui/styles'; import { Sidebar } from '../components'; import Drawer from 'material-ui/Drawer'; +import {withRouter} from "react-router-dom"; const drawerWidth = 240; @@ -26,10 +27,11 @@ const styles = theme => ({ }, }); -const Page = ({ children, classes }) => ( +const Page = ({ children, classes, location: {pathname} }) => ( <div className={classes.appFrame}> <Drawer variant="permanent" classes={{paper: classes.drawerPaper}}> - <Sidebar /> + {/* TODO if you don't pass pathname here then "by department" Sidebar items don't re-render and item selection isn't updated */} + <Sidebar pathname={pathname} /> </Drawer> <div className={classes.pageContent}> { children } @@ -37,4 +39,4 @@ const Page = ({ children, classes }) => ( </div> ); -export default withStyles(styles)(Page); +export default withRouter(withStyles(styles)(Page)); diff --git a/src/redux/actions/lookupApi.js b/src/redux/actions/lookupApi.js index 1812277d5ed49bf93546b462bd7d78ab08e5e75f..29f23d71505f1dfde98c3bc669a2af373feb8d06 100644 --- a/src/redux/actions/lookupApi.js +++ b/src/redux/actions/lookupApi.js @@ -8,6 +8,11 @@ export const PEOPLE_GET_REQUEST = Symbol('PEOPLE_GET_REQUEST'); export const PEOPLE_GET_SUCCESS = Symbol('PEOPLE_GET_SUCCESS'); export const PEOPLE_GET_FAILURE = Symbol('PEOPLE_GET_FAILURE'); +export const PEOPLE_GET_SELF_REQUEST = Symbol('PEOPLE_GET_SELF_REQUEST'); +export const PEOPLE_GET_SELF_SUCCESS = Symbol('PEOPLE_GET_SELF_SUCCESS'); +export const PEOPLE_GET_SELF_FAILURE = Symbol('PEOPLE_GET_SELF_FAILURE'); +export const PEOPLE_GET_SELF_RESET = Symbol('PEOPLE_GET_SELF_RESET'); + export const ENDPOINT_PEOPLE = process.env.REACT_APP_ENDPOINT_LOOKUP + 'people'; /** @@ -44,3 +49,21 @@ export const getPeople = (crsid) => ({ types: [PEOPLE_GET_REQUEST, PEOPLE_GET_SUCCESS, PEOPLE_GET_FAILURE] } }); + +/** + * Fetch the authenticated user's profile. + */ +export const getSelf = () => ({ + [RSAA]: { + endpoint: ENDPOINT_PEOPLE + '/token/self?fetch=all_insts', + method: 'GET', + types: [PEOPLE_GET_SELF_REQUEST, PEOPLE_GET_SELF_SUCCESS, PEOPLE_GET_SELF_FAILURE] + } +}); + +/** + * Reset the authenticated user's profile. + */ +export const resetSelf = () => ({ + type: PEOPLE_GET_SELF_RESET, +}); diff --git a/src/redux/reducers/lookupApi.js b/src/redux/reducers/lookupApi.js index 710906b5f836d8f615997c387ab0fd5a421e1816..517397499b9f728f4ea52875824f09c70198c009 100644 --- a/src/redux/reducers/lookupApi.js +++ b/src/redux/reducers/lookupApi.js @@ -1,19 +1,26 @@ import { + PEOPLE_GET_SELF_FAILURE, + PEOPLE_GET_SELF_REQUEST, PEOPLE_GET_SELF_RESET, PEOPLE_GET_SELF_SUCCESS, PEOPLE_GET_SUCCESS, PEOPLE_LIST_SUCCESS, } from '../actions/lookupApi'; import Cache from '../cache'; +import { Map as ImmutableMap } from 'immutable'; /** * State managed by the lookup API reducers. */ export const initialState = { // a map of people records retrieved from the lookup api - keyed on crsid - peopleByCrsid: new Map(), + peopleByCrsid: ImmutableMap(), // a cache of arrays of people records returned by the lookup api search endpoint - // keyed on the search text that produced the result. matchingPeopleByQuery: new Cache({maxSize: 20}), + // the authenticated user's profile + self: null, + // whether or not the authenticated user's profile is being loaded + selfLoading: false, }; export default (state = initialState, action) => { @@ -27,11 +34,20 @@ export default (state = initialState, action) => { case PEOPLE_GET_SUCCESS: // Add the person to the peopleByCrsid map const person = action.payload; - const peopleByCrsid = new Map([ - ...state.peopleByCrsid, - [person.identifier.value, person] - ]); - return { ...state, peopleByCrsid }; + return { ...state, peopleByCrsid: state.peopleByCrsid.set(person.identifier.value, person) }; + + case PEOPLE_GET_SELF_REQUEST: + // use an empty object is indicate loading + return { ...state, selfLoading: true }; + + case PEOPLE_GET_SELF_RESET: + case PEOPLE_GET_SELF_FAILURE: + // reset self in case of reset and failure. + return { ...state, self: null, selfLoading: false }; + + case PEOPLE_GET_SELF_SUCCESS: + // Add the person to the peopleByCrsid map + return { ...state, self: action.payload, selfLoading: false}; default: return state; diff --git a/src/redux/reducers/lookupApi.test.js b/src/redux/reducers/lookupApi.test.js index 4c6e726b3deb5ecde67d76102b9734de2ad816c4..62fa7aea04ee093b1cf5d1c1048a895edbe56f2b 100644 --- a/src/redux/reducers/lookupApi.test.js +++ b/src/redux/reducers/lookupApi.test.js @@ -1,6 +1,10 @@ import Cache from '../cache'; +import { Map } from 'immutable'; import reducer, { initialState } from './lookupApi'; -import { PEOPLE_GET_SUCCESS, PEOPLE_LIST_SUCCESS } from '../actions/lookupApi'; +import { + PEOPLE_GET_SELF_REQUEST, PEOPLE_GET_SELF_SUCCESS, PEOPLE_GET_SUCCESS, + PEOPLE_LIST_SUCCESS +} from '../actions/lookupApi'; // test that the state is correctly initialised. test('the state is correctly initialised', () => { @@ -30,8 +34,9 @@ test('a people list result is cached', () => { const nextState = reducer(initialState, action); - expect(Object.is(initialState.matchingPeopleByQuery, nextState.matchingPeopleByQuery)).toBe(false); expect(nextState.matchingPeopleByQuery.get('msb9')).toBe(results); + // check the state wasn't mutated + expect(Object.is(initialState.matchingPeopleByQuery, nextState.matchingPeopleByQuery)).toBe(false); }); // test the people list cache doesn't grow beyond 20 @@ -61,7 +66,7 @@ test('the people list cache is pruned', () => { expect(nextState.matchingPeopleByQuery.get('msb10')).toBeDefined(); }); -// a retrieved person model is set in peopleByCrsid +// check that a retrieved person model is set in peopleByCrsid test('a retrieved person model is set in peopleByCrsid', () => { const payload = { @@ -75,6 +80,40 @@ test('a retrieved person model is set in peopleByCrsid', () => { const nextState = reducer(initialState, {type: PEOPLE_GET_SUCCESS, payload: payload}); - expect(Object.is(initialState.peopleByCrsid, nextState.peopleByCrsid)).toBe(false); expect(nextState.peopleByCrsid.get('msb999')).toBe(payload); + // check the state wasn't mutated + expect(Object.is(initialState.peopleByCrsid, nextState.peopleByCrsid)).toBe(false); +}); + +// check that the selfLoading flag is set when the self is requested +test("the selfLoading flag is set", () => { + + const nextState = reducer(initialState, {type: PEOPLE_GET_SELF_REQUEST}); + + expect(nextState.self).toBeNull(); + expect(nextState.selfLoading).toBe(true); +}); + +// check that the authenticated user's profile is set in self +test("the authenticated user's profile is set in self", () => { + + const payload = { + url: "http://localhost:8080/people/crsid/msb999", + cancelled: false, + identifier: {scheme: "crsid", value: "msb999"}, + visibleName: "M. Bamford", + isStaff: true, + isStudent: false, + institutions:[ + {instid:"CL",name:"Department of Computer Science and Technology"}, + {instid:"UIS",name:"University Information Services"} + ] + }; + + const nextState = reducer(initialState, {type: PEOPLE_GET_SELF_SUCCESS, payload: payload}); + + expect(nextState.self).toBe(payload); + expect(nextState.selfLoading).toBe(false); + // check the state wasn't mutated + expect(Object.is(initialState.self, nextState.self)).toBe(false); });