Commit 0ac9e3af authored by Andrew Hrdy's avatar Andrew Hrdy

Added proper typings for Redux, converted from thunk to saga.

parent 17e18825
Pipeline #3950 failed with stage
in 1 minute and 11 seconds
This diff is collapsed.
export const SET_SIDEBAR = 'SET_SIDEBAR';
export const SET_FACILITIES = 'SET_FACILITIES';
export const GET_FACILITIES = 'GET_FACILITIES';
export const SET_ALERTS = 'SET_ALERTS';
export const GET_ALERTS = 'GET_ALERTS';
export const VIEW_ALERT = 'VIEW_ALERT';
export const SET_SELECTED_FACILITY = 'SET_SELECTED_FACILITY';
export const SET_SEARCH_TERM = 'SET_SEARCH_TERM';
export const SET_CAMPUS_REGION = 'SET_CAMPUS_REGION';
export const ADD_FAVORITE_FACILITY = 'ADD_FAVORITE_FACILITY';
export const REMOVE_FAVORITE_FACILITY = 'REMOVE_FAVORITE_FACILITY';
export const SET_ALL_FAVORITES = 'SET_ALL_FAVORITES';
export const SORT_FACILITY_CARDS = 'SORT_FACILITY_CARDS';
\ No newline at end of file
import {GET_ALERTS, GET_FACILITIES, SET_ALERTS, SET_FACILITIES, SORT_FACILITY_CARDS, VIEW_ALERT} from './action-types';
import { Dispatch } from 'redux';
import { IFacility } from '../models/facility.model';
import { IAlert } from '../models/alert.model';
const API_GET_FACILITIES = process.env.API_GET_FACILITIES ? process.env.API_GET_FACILITIES :
'https://api.srct.gmu.edu/whatsopen/v2/facilities/';
const API_GET_ALERTS = 'https://api.srct.gmu.edu/whatsopen/v2/alerts/?ordering=urgency_tag';
export const getFacilities = () => (dispatch: Dispatch<any>) => {
dispatch({
type: GET_FACILITIES
});
const request = new Request(API_GET_FACILITIES, {
method: 'GET'
});
return fetch(request)
.then((res) => {
if (res.status < 200 || res.status >= 300) {
throw new Error(res.statusText);
}
return res.json();
}).then((json) => {
dispatch(setFacilities(json));
});
};
export const setFacilities = (facilities: IFacility[]) => {
try {
localStorage.setItem('facilities', JSON.stringify(facilities));
} catch (e) {
//Empty
}
return {
type: SET_FACILITIES,
facilities: facilities
};
};
export const sortFacilityCards = () => ({
type: SORT_FACILITY_CARDS
});
export const getAlerts = () => (dispatch: Dispatch<any>) => {
dispatch({
type: GET_ALERTS
});
const request = new Request(API_GET_ALERTS, {
method: 'GET'
});
return fetch(request)
.then((res) => {
if (res.status < 200 || res.status >= 300) {
throw new Error(res.statusText);
}
return res.json();
}).then((json) => {
dispatch(setAlerts(json));
});
};
export const setAlerts = (alerts: IAlert[]) => {
const viewedAlerts = JSON.parse(localStorage.getItem('viewedAlerts'));
if (viewedAlerts) {
alerts.forEach((alert) => {
alert['viewed'] = viewedAlerts.includes(alert.id);
});
}
return {
type: SET_ALERTS,
alerts: alerts
};
};
export const viewAlert = (alert: IAlert) => {
try {
let viewedAlerts = JSON.parse(localStorage.getItem('viewedAlerts'));
if (!viewedAlerts) {
viewedAlerts = [];
}
if (!viewedAlerts.includes(alert.id)) {
viewedAlerts.push(alert.id);
}
localStorage.setItem('viewedAlerts', JSON.stringify(viewedAlerts));
} catch (e) {
//Empty
}
return {
type: VIEW_ALERT,
alert
};
};
import {
ADD_FAVORITE_FACILITY, REMOVE_FAVORITE_FACILITY, SET_ALL_FAVORITES, SET_CAMPUS_REGION, SET_SEARCH_TERM,
SET_SELECTED_FACILITY, SET_SIDEBAR
} from './action-types';
import { IFacility, CampusRegion } from '../models/facility.model';
export const setSidebar = (setOpen: boolean) => ({
type: SET_SIDEBAR,
setOpen
});
export const setSelectedFacility = (facility: IFacility) => ({
type: SET_SELECTED_FACILITY,
facility
});
export const setSearchTerm = (term: string) => ({
type: SET_SEARCH_TERM,
term
});
export const setCampusRegion = (campusRegion: CampusRegion) => ({
type: SET_CAMPUS_REGION,
campusRegion
});
export const addFavoriteFacility = (slug: string) => ({
type: ADD_FAVORITE_FACILITY,
slug
});
export const removeFavoriteFacility = (slug: string) => ({
type: REMOVE_FAVORITE_FACILITY,
slug
});
export const setAllFavorites = (favorites: string[]) => ({
type: SET_ALL_FAVORITES,
favorites
});
import * as React from 'react';
import {connect} from 'react-redux';
import {viewAlert} from '../actions/api';
import { State } from '../models/redux.model';
import { IAlert } from '../models/alert.model';
import Alert from '../components/Alert';
......@@ -11,6 +9,9 @@ import Popover from '@material-ui/core/Popover';
import NotificationsIcon from '@material-ui/icons/Notifications';
import CloseIcon from '@material-ui/icons/Close';
import Typography from '@material-ui/core/Typography';
import { ApplicationState } from '../store';
import { addViewedAlerts } from '../store/alert/alert.actions';
import { Dispatch } from 'redux';
class AlertContainer extends React.Component<AlertContainerProps, AlertContainerState> {
......@@ -29,7 +30,7 @@ class AlertContainer extends React.Component<AlertContainerProps, AlertContainer
anchorEl: event.currentTarget
});
this.props.alerts.forEach((alert) => this.props.viewAlert(alert));
this.props.addViewedAlerts(this.props.alerts.map(alert => alert.id));
};
handleClose = () => {
......@@ -47,6 +48,10 @@ class AlertContainer extends React.Component<AlertContainerProps, AlertContainer
return curDate > startDate && curDate < endDate;
};
hasAlertBeenViewed = (alert: IAlert) => {
return this.props.viewedAlerts.includes(alert.id);
}
render() {
const {alerts} = this.props;
const activeAlerts = alerts.filter(this.isAlertActive);
......@@ -56,7 +61,7 @@ class AlertContainer extends React.Component<AlertContainerProps, AlertContainer
<IconButton classes={{
root: 'alert-container-btn'
}} onClick={this.handleOpen}>
{activeAlerts.filter((alert) => !alert.viewed).length !== 0 &&
{activeAlerts.filter((alert) => this.hasAlertBeenViewed(alert)).length !== 0 &&
<span className={'alert-container-number'}>
<Typography variant={'caption'} className={'alert-container-number-text'}>
{activeAlerts.length}
......@@ -112,7 +117,8 @@ class AlertContainer extends React.Component<AlertContainerProps, AlertContainer
interface AlertContainerProps {
alerts: IAlert[];
viewAlert: (alert: IAlert) => any;
viewedAlerts: number[]
addViewedAlerts: (alertIds: number[]) => any;
}
interface AlertContainerState {
......@@ -120,12 +126,13 @@ interface AlertContainerState {
anchorEl: HTMLElement
}
function mapStateToProps(state: State) {
return {
alerts: state.alerts
};
}
const mapStateToProps = (state: ApplicationState): Partial<AlertContainerProps> => ({
alerts: state.alerts.alerts,
viewedAlerts: state.alerts.viewedAlerts
})
const mapDispatchToProps = (dispatch: Dispatch): Partial<AlertContainerProps> => ({
addViewedAlerts: (alertIds: number[]) => dispatch(addViewedAlerts(alertIds))
});
export default connect(mapStateToProps, {
viewAlert
})(AlertContainer);
export default connect(mapStateToProps, mapDispatchToProps)(AlertContainer);
\ No newline at end of file
import * as React from 'react';
import {connect} from 'react-redux';
import {addFavoriteFacility, removeFavoriteFacility, setSelectedFacility, setSidebar} from '../actions/ui';
import {removeBrackets} from '../utils/nameUtils';
import classNames from 'classnames';
import FacilityUtils from '../utils/facilityUtils';
import ReactPiwik from 'react-piwik';
import { IFacility } from '../models/facility.model';
import { State } from '../models/redux.model';
import FacilityStatus from '../components/FacilityStatus';
import FavoriteButton from '../components/FavoriteButton';
......@@ -18,6 +16,9 @@ import CardContent from '@material-ui/core/CardContent';
import Typography from '@material-ui/core/Typography';
import Grid from '@material-ui/core/Grid';
import LocationOnIcon from '@material-ui/icons/LocationOn';
import { ApplicationState } from '../store';
import { Dispatch } from 'redux';
import { addFavoriteFacility, removeFavoriteFacility, setSelectedFacility, setSidebarExpansion } from '../store/ui/ui.actions';
class FacilityCard extends React.Component<FacilityCardProps, FacilityCardState> {
......@@ -54,7 +55,7 @@ class FacilityCard extends React.Component<FacilityCardProps, FacilityCardState>
setTimeout(() => {
this.props.setSelectedFacility(newState ? this.props.facility : null);
this.props.setSidebar(newState);
this.props.setSidebarExpansion(newState);
}, 0);
};
......@@ -71,7 +72,7 @@ class FacilityCard extends React.Component<FacilityCardProps, FacilityCardState>
if (todaysHours.length > 1) {
for (let i = 0; i < todaysHours.length; i++) {
const hour = todaysHours[i];
if (currentHour <= parseInt(hour.end) && currentHour >= parseInt(hour.start)) {
if (currentHour <= hour.end && currentHour >= hour.start) {
return hour.text;
}
}
......@@ -152,21 +153,23 @@ export interface FacilityCardProps {
setSelectedFacility: (facility: IFacility) => void;
addFavoriteFacility: (slug: string) => void;
removeFavoriteFacility: (slug: string) => void;
setSidebar: (isOpen: boolean) => void;
setSidebarExpansion: (isOpen: boolean) => void;
}
export interface FacilityCardState {
modified: Date;
modified: string;
}
const mapStateToProps = (state: State) => ({
const mapStateToProps = (state: ApplicationState): Partial<FacilityCardProps> => ({
favorites: state.ui.favorites,
selectedFacility: state.ui.selectedFacility
});
export default connect(mapStateToProps, {
setSelectedFacility,
addFavoriteFacility,
removeFavoriteFacility,
setSidebar
})(FacilityCard);
\ No newline at end of file
const mapDispatchToProps = (dispatch: Dispatch): Partial<FacilityCardProps> => ({
setSelectedFacility: (facility: IFacility) => dispatch(setSelectedFacility(facility)),
addFavoriteFacility: (slug: string) => dispatch(addFavoriteFacility(slug)),
removeFavoriteFacility: (slug: string) => dispatch(removeFavoriteFacility(slug)),
setSidebarExpansion: (isOpen: boolean) => dispatch(setSidebarExpansion(isOpen))
})
export default connect(mapStateToProps, mapDispatchToProps)(FacilityCard);
\ No newline at end of file
import * as React from 'react';
import {connect} from 'react-redux';
import {getAlerts, getFacilities, setAlerts, setFacilities, sortFacilityCards} from '../actions/api';
import {setSidebar, setSelectedFacility, setAllFavorites} from '../actions/ui';
import { State } from '../models/redux.model';
import { IFacility, CampusRegion } from '../models/facility.model';
import { IAlert } from '../models/alert.model';
import CardContainer from '../components/CardContainer';
import AppBar from '../components/AppBar';
import Sidebar from '../components/Sidebar';
import { ApplicationState } from '../store';
import { Dispatch } from 'redux';
import { fetchFacilities } from '../store/facility/facility.actions';
import { fetchAlerts } from '../store/alert/alert.actions';
import { setSidebarExpansion, setSelectedFacility } from '../store/ui/ui.actions';
class Layout extends React.Component<LayoutProps> {
constructor(props: LayoutProps) {
......@@ -16,32 +18,12 @@ class Layout extends React.Component<LayoutProps> {
}
componentWillMount = () => {
/*
This is done in order to immediately load the page (retrieving from local storage is faster
than an API call). After retrieving from local storage, then call the API to see if there
are any updates.
*/
try {
if (localStorage.getItem('facilities')) {
const facilities = localStorage.getItem('facilities');
this.props.setFacilities(JSON.parse(facilities));
}
if (localStorage.getItem('favorites')) {
const favorites = JSON.parse(localStorage.getItem('favorites'));
this.props.setAllFavorites(favorites);
}
this.props.sortFacilityCards();
} catch (e) {
console.warn('you should enable cookies so we can remember what places you favorite');
}
this.props.getFacilities();
this.props.getAlerts();
this.props.fetchFacilities();
this.props.fetchAlerts();
};
render() {
const {isSidebarOpen, selectedFacility, facilities, searchTerm, campusRegion, setSidebar, setSelectedFacility} = this.props;
const {isSidebarOpen, selectedFacility, facilities, searchTerm, campusRegion, setSidebarExpansion, setSelectedFacility} = this.props;
return (
<div className={'layout-root'}>
......@@ -55,7 +37,7 @@ class Layout extends React.Component<LayoutProps> {
</div>
<Sidebar facilities={facilities} facility={selectedFacility} isSidebarOpen={isSidebarOpen}
setSidebar={setSidebar} setSelectedFacility={setSelectedFacility}/>
setSidebar={setSidebarExpansion} setSelectedFacility={setSelectedFacility}/>
</div>
</div>
);
......@@ -68,39 +50,29 @@ export interface LayoutProps {
favorites: string[];
searchTerm: string;
campusRegion: CampusRegion;
isLoading: boolean;
selectedFacility: IFacility;
isSidebarOpen: boolean;
getFacilities: () => any;
setFacilities: (facilities: IFacility[]) => any;
getAlerts: () => any;
setAlerts: (alert: IAlert[]) => any;
setAllFavorites: (favorites: string[]) => any;
sortFacilityCards: () => any;
setSidebar: (setOpen: boolean) => any;
fetchFacilities: () => any;
fetchAlerts: () => any;
setSidebarExpansion: (isOpen: boolean) => any;
setSelectedFacility: (facility: IFacility) => any;
}
function mapStateToProps(state: State) {
return {
facilities: state.facilities.data,
alerts: state.alerts,
favorites: state.ui.favorites,
searchTerm: state.ui.search.term,
campusRegion: state.ui.search.campusRegion,
isLoading: state.facilities.isLoading,
selectedFacility: state.ui.selectedFacility,
isSidebarOpen: state.ui.sidebar.isOpen
};
}
const mapStateToProps = (state: ApplicationState): Partial<LayoutProps> => ({
facilities: state.facilities.facilities,
alerts: state.alerts.alerts,
favorites: state.ui.favorites,
searchTerm: state.ui.search.searchTerm,
campusRegion: state.ui.search.campusRegion,
selectedFacility: state.ui.selectedFacility,
isSidebarOpen: state.ui.sidebar.isOpen
});
const mapDispatchToProps = (dispatch: Dispatch): Partial<LayoutProps> => ({
fetchFacilities: () => dispatch(fetchFacilities()),
fetchAlerts: () => dispatch(fetchAlerts()),
setSidebarExpansion: (isOpen: boolean) => dispatch(setSidebarExpansion(isOpen)),
setSelectedFacility: (facility: IFacility) => dispatch(setSelectedFacility(facility))
})
export default connect(mapStateToProps, {
getFacilities,
setFacilities,
getAlerts,
setAlerts,
setAllFavorites,
sortFacilityCards,
setSidebar,
setSelectedFacility
})(Layout);
export default connect(mapStateToProps, mapDispatchToProps)(Layout);
import * as React from 'react';
import {connect} from 'react-redux';
import {setCampusRegion, setSearchTerm} from '../actions/ui';
import classNames from 'classnames';
import ReactPiwik from 'react-piwik';
import { CampusRegion } from '../models/facility.model';
......@@ -14,6 +13,8 @@ import Paper from '@material-ui/core/Paper';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import FormControl from '@material-ui/core/FormControl';
import { Dispatch } from 'redux';
import { setSearchTerm, setSelectedCampusRegion } from '../store/ui/ui.actions';
class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
private inputElement: any;
......@@ -44,7 +45,7 @@ class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
campus: e.target.value
});
this.props.setCampusRegion(e.target.value);
this.props.setSelectedCampusRegion(e.target.value);
};
handleFocus = () => {
......@@ -138,7 +139,7 @@ class SearchBar extends React.Component<SearchBarProps, SearchBarState> {
export interface SearchBarProps {
setSearchTerm: (term: string) => any;
setCampusRegion: (region: CampusRegion) => any;
setSelectedCampusRegion: (region: CampusRegion) => any;
onSearchExpand: () => any;
onSearchCollapse: () => any;
}
......@@ -150,7 +151,9 @@ export interface SearchBarState {
campus: CampusRegion
}
export default connect(null, {
setSearchTerm,
setCampusRegion
})(SearchBar);
\ No newline at end of file
const mapDispatchToProps = (dispatch: Dispatch): Partial<SearchBarProps> => ({
setSearchTerm: (term: string) => dispatch(setSearchTerm(term)),
setSelectedCampusRegion: (region: CampusRegion) => dispatch(setSelectedCampusRegion(region))
});
export default connect(null, mapDispatchToProps)(SearchBar);
\ No newline at end of file
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import ReactPiwik from 'react-piwik';
import {applyMiddleware, compose, createStore} from 'redux';
import Layout from './containers/Layout';
import registerServiceWorker from './registerServiceWorker';
import {ConnectedRouter, routerMiddleware} from 'react-router-redux';
import {ConnectedRouter} from 'react-router-redux';
import {Provider} from 'react-redux';
import ReduxThunk from 'redux-thunk';
import reducers from './reducers/index';
import {MuiThemeProvider} from '@material-ui/core/styles';
import theme from './theme';
import './styles/whatsOpen.scss';
......@@ -15,33 +12,12 @@ import 'typeface-roboto';
import '../public/manifest.json';
import '../public/favicon.png';
import '../public/apple-app-site-association';
const piwik = new ReactPiwik({
url: 'matomo.srct.gmu.edu/',
siteId: 2,
trackErrors: true,
enableLinkTracking: true,
trackDocumentTitle: true
});
// Create a history of your choosing (we're using a browser history in this case)
const piwikHistory = piwik.connectToHistory(history);
// const history = createHistory();
const extension = window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__();
const isProduction = process.env.NODE_ENV === 'production';
let enhance;
if (isProduction || !extension) {
enhance = compose(applyMiddleware(ReduxThunk, routerMiddleware(piwikHistory)));
} else {
enhance = compose(applyMiddleware(ReduxThunk, routerMiddleware(piwikHistory)), extension);
}
const store = createStore(reducers, enhance);
import { generateStore } from './store';
import { whatsOpenPiwikHistory } from './piwik/piwik';
ReactDOM.render(
<Provider store={store}>
<ConnectedRouter history={piwikHistory}>
<Provider store={generateStore()}>
<ConnectedRouter history={whatsOpenPiwikHistory}>
<MuiThemeProvider theme={theme}>
<Layout />
</MuiThemeProvider>
......
......@@ -6,9 +6,6 @@ export interface IAlert {
url: string;
start_datetime: Date;
end_datetime: Date;
// A client side field
viewed?: boolean;
}
export enum AlertUrgencyTag {
......
import { IFacility, CampusRegion } from './facility.model';
import { IAlert } from './alert.model';
export interface FacilityState {
isLoading: boolean;
data: IFacility[];
}
export type AlertState = IAlert[];
export interface SearchBarState {
term: string;
campusRegion: CampusRegion;
}
export interface UiState {
selectedFacility: IFacility;
sidebar: {
isOpen: boolean
};
search: SearchBarState;
favorites: string[]
}
export interface State {
ui: UiState;
facilities: FacilityState;
alerts: AlertState;
}
\ No newline at end of file
import ReactPiwik from 'react-piwik';
import { applyMiddleware } from 'redux';
import { routerMiddleware } from 'react-router-redux';
export const whatsOpenPiwik = new ReactPiwik({
url: 'matomo.srct.gmu.edu/',
siteId: 2,
trackErrors: true,
enableLinkTracking: true,
trackDocumentTitle: true
});
export const whatsOpenPiwikHistory = whatsOpenPiwik.connectToHistory(history);
export const whatsOpenPiwikMiddleware = () => applyMiddleware(routerMiddleware(whatsOpenPiwikHistory));
\ No newline at end of file
import {GET_ALERTS, GET_FACILITIES, SET_ALERTS, SET_FACILITIES, SORT_FACILITY_CARDS, VIEW_ALERT} from '../actions/action-types';
import cloneDeep from 'lodash/cloneDeep';
import facilityUtils from '../utils/facilityUtils';
import { FacilityState, UiState } from '../models/redux.model';
import { IFacility } from '../models/facility.model';
import { Action } from 'redux';
const defaultFacilityState: FacilityState = {
isLoading: false,
data: []
};
export const facilities = (state: FacilityState = defaultFacilityState, action: Action, ui: UiState): FacilityState => {
const facilitySort = (a: IFacility, b: IFacility) => {
// True = 1, False = 0;
const favoriteCheck = Number(ui.favorites.includes(b.slug)) - Number(ui.favorites.includes(a.slug));
if (favoriteCheck !== 0) {
return favoriteCheck;
}
const openCheck = facilityUtils.isFacilityOpen(b) - facilityUtils.isFacilityOpen(a);
if (openCheck !== 0) {
return openCheck;
}
if (a.facility_name < b.facility_name) {
return -1;
}
if (a.facility_name > b.facility_name) {
return 1;
}
return 0;
};
const newData = cloneDeep(state.data);
switch (action.type) {
case GET_FACILITIES:
return {
...state,
isLoading: true
};
case SET_FACILITIES:
return {
...state,
data: action.facilities.sort(facilitySort),
isLoading: false
}
case SORT_FACILITY_CARDS:
return {
...state,
data: newData.sort(facilitySort)
}
default:
return state;
}
};
export const alerts = (state = [], action) => {
switch (action.type) {
case GET_ALERTS:
return state;
case SET_ALERTS:
return [...state, ...action.alerts];
case VIEW_ALERT:
const index = state.findIndex((alert) => alert.id === action.alert.id);
const alert = cloneDeep(state[index]);
alert.viewed = true;