diff --git a/public/css/app.css b/public/css/app.css index 8d64b62..a89ae48 100755 --- a/public/css/app.css +++ b/public/css/app.css @@ -1,4 +1,4 @@ -@import "https://cdnjs.cloudflare.com/ajax/libs/foundation/6.4.3/css/foundation.min.css"; +@import 'https://cdnjs.cloudflare.com/ajax/libs/foundation/6.4.3/css/foundation.min.css'; /*@import "../../node_modules/codemirror/lib/codemirror.css";*/ /** ----- Begin Global ----- **/ @@ -15,7 +15,7 @@ textarea::placeholder { } .button { - background-color: #009BDF; + background-color: #009bdf; } .align-right { @@ -99,6 +99,15 @@ textarea::placeholder { margin-bottom: 0; } +.olin-build-logo { + color: #c7254e; +} + +.olin-build-logo:hover { + color: #c7254e; + text-decoration: underline; +} + @media screen and (max-width: 700px) { .app-sidebar { left: -33.3%; @@ -152,9 +161,9 @@ textarea::placeholder { .header-content { padding: 1.2em; - background-color: #009BDF; + background-color: #009bdf; flex-basis: content; - width: 100% + width: 100%; } .olin-logo { @@ -168,7 +177,7 @@ textarea::placeholder { } .olin-logo .beta-box { - fill: #FFC20E; + fill: #ffc20e; } .olin-logo .beta-text { @@ -301,7 +310,7 @@ footer { margin-bottom: 0.2em; } -@media screen and (max-width:400px) { +@media screen and (max-width: 400px) { .app-sidebar { font-size: 80%; } @@ -505,14 +514,14 @@ span.label { .m-input-moment .options button.is-active, .m-calendar .toolbar button, .content-container button:not(.im-btn .label) { - background-color: #009BDF; + background-color: #009bdf; } .page-title>.menu-icon-button { position: relative; z-index: 20; cursor: pointer; - margin-right: .5em; + margin-right: 0.5em; } .page-title>.menu-icon-button>.menu-icon { @@ -703,7 +712,7 @@ textarea.markdown-editor { font-size: 1em; } -.radio-option input[type=radio] { +.radio-option input[type='radio'] { margin: 0; } @@ -742,3 +751,25 @@ textarea.markdown-editor { } /** ----- End View ----- **/ + +/* From https://www.w3schools.com/howto/howto_css_loader.asp */ + +.loading { + border: 16px solid #f3f3f3; + border-top: 16px solid #3498db; + border-radius: 50%; + width: 120px; + height: 120px; + animation: spin 2s linear infinite; + color: white; + margin: auto; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/__test__/__snapshots__/sidebar.test.jsx.snap b/src/__test__/__snapshots__/sidebar.test.jsx.snap new file mode 100644 index 0000000..0a14f86 --- /dev/null +++ b/src/__test__/__snapshots__/sidebar.test.jsx.snap @@ -0,0 +1,526 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Sidebar renders ICS pane 1`] = ` +
+
+
+
+
+
+
+ Subscribe +
+
+
+ + Use an ICS feed to add events with the selected tags to your own calendar ( + + instructions + + ). + +
+ +
+
+
+
+
+
+
+
+`; + +exports[`Sidebar renders event actions 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+`; + +exports[`Sidebar renders event labels 1`] = ` +
+
+
+
+
+
+
+ Labels +
+
+
+ featured +
+
+
+
+
+
+
+
+`; + +exports[`Sidebar renders filter pane 1`] = ` +
+
+
+
+
+
+
+ Filter Events +
+
+
+
+ + Labels + +
+ + All + +  |  + + Default + +  |  + + None + +
+
+
+ featured +
+
+
+
+
+
+
+
+
+`; + +exports[`Sidebar renders link pane 1`] = ` +
+
+
+
+
+
+ + +
+
+
+
+
+
+`; + +exports[`Sidebar renders markdown pane 1`] = ` +
+
+
+
+
+
+
+ Markdown Guide +
+
+
+
+
+ + Links + +

+ [link text](url) +

+

+ <url> +

+
+
+ + Headers + +

+ # big header +

+

+ ## smaller header +

+

+ And so on, adding more hashes to make the header smaller. +
+ + Don’t forget the space between the hash and the text. + +

+
+
+ + Emphasis + +

+ *italic* +

+

+ **bold** +

+

+ ***bold and italic*** +

+
+
+ + Images + +

+ ![mouse hover text](url_to_image) +

+

+ Currently images must be hosted elsewhere. If you need to upload your photo somewhere, put it on Google Drive or Dropbox and use the public link to the file. +

+
+
+ + Lists + +

+ Indent each list item with + + two spaces + + , followed by an asterisk, number, or letter, and then another space. +

+
+
+ + Bulletted + +

+   * List item +
+   * List item +
+   * List item +

+
+
+ + Numbered + +

+   1 List item +
+   2 List item +
+   3 List item +

+
+
+ + Lettered + +

+   a List item +
+   b List item +
+   c List item +

+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`Sidebar renders sign in 1`] = ` +
+
+
+
+
+
+
+ Sign In +
+
+
+

+ You are viewing public events. +

+

+ + Sign in + + to + + olin.build + + to see and add Olin Community events. +

+
+
+
+
+
+
+
+
+`; diff --git a/src/__test__/setup.js b/src/__test__/setup.js index 78c9a4e..5334224 100644 --- a/src/__test__/setup.js +++ b/src/__test__/setup.js @@ -33,3 +33,6 @@ class LocalStorageMock { } global.localStorage = new LocalStorageMock(); + +process.env.ABE_URL = 'http://abe_url'; +process.env.CLIENT_ID = '$abe-client-id'; diff --git a/src/__test__/sidebar.test.jsx b/src/__test__/sidebar.test.jsx new file mode 100644 index 0000000..816a15f --- /dev/null +++ b/src/__test__/sidebar.test.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import moment from 'moment'; +import Sidebar from '../sidebar/sidebar'; + +jest.mock('../components/label-pane', () => props => ( +
{props.selectedLabels.join(' ')}
+)); +jest.mock('../components/sidebar-header', () => () =>
); +jest.mock('../sidebar/footer', () => () =>
); + +describe('Sidebar', () => { + const user = { + scope: new Set(['create:events', 'edit:events', 'read:all_events']), + }; + const signedOutUser = { + scope: new Set(), + }; + const event = { + title: 'An event title', + description: 'some *markdown*', + labels: ['label'], + start: moment('2018-05-04T09:00:00Z'), + end: moment('2018-05-04T10:00:00Z'), + }; + const props = { + currentEvent: event, + isCollapsed: false, + possibleLabels: { featured: { name: 'featured' } }, + selectedLabels: ['featured'], + user, + }; + const handlers = { + addEvent: () => undefined, + deleteCurrentEvent: () => undefined, + editCurrentEvent: () => undefined, + editEventSeries: () => undefined, + homeClicked: () => undefined, + icsUrlCopiedToClipboard: () => undefined, + importICSClicked: () => undefined, + labelToggled: () => undefined, + setVisibleLabels: () => undefined, + toggleSidebarCollapsed: () => undefined, + }; + + test('renders event actions', () => { + const jsx = ; + const component = renderer.create(jsx); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + test('renders event labels', () => { + const jsx = ; + const component = renderer.create(jsx); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + test('renders filter pane', () => { + const jsx = ; + const component = renderer.create(jsx); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + test('renders ICS pane', () => { + const jsx = ; + const component = renderer.create(jsx); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + test('renders link pane', () => { + const jsx = ; + const component = renderer.create(jsx); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + test('renders markdown pane', () => { + const jsx = ; + const component = renderer.create(jsx); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + test('renders sign in', () => { + const jsx = ; + const component = renderer.create(jsx); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/app.jsx b/src/app.jsx index feec56c..3f4c2bf 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -2,9 +2,8 @@ import createHistory from 'history/createBrowserHistory'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Provider } from 'react-redux'; +import { Provider, connect } from 'react-redux'; import { Route, Router, Switch } from 'react-router'; -import { initializeAccessToken } from './data/auth'; import AddEditContainer from './containers/add-edit-container'; import CalendarContainer from './containers/calendar-container'; import ViewEventContainer from './containers/event-details-container'; @@ -12,15 +11,11 @@ import ImportContainer from './containers/import-container'; import LabelsContainer from './containers/labels-container'; import SidebarContainer from './containers/sidebar-container'; import SubscriptionContainer from './containers/subscription-container'; -import { fetchUser, fetchLabels, toggleSidebarCollapsed } from './data/actions'; +import { withAccountInfo } from './containers/with-server-data'; +import { fetchLabels, fetchAccessInfo, toggleSidebarCollapsed } from './data/actions'; +import { initializeAccessToken } from './data/auth'; import setupStore from './data/setup-store'; -// Remove the trailing slash, if present -// -// TODO: change this from a global variable to an API Client that's supplied via -// the provider pattern. -window.abe_url = process.env.ABE_URL.replace(/\/$/, ''); - // React Router (with Redux middleware) const history = createHistory(); @@ -29,29 +24,39 @@ const store = setupStore(history); initializeAccessToken(); -// Fetch the labels -store.dispatch(fetchUser()); +store.dispatch(fetchAccessInfo()); store.dispatch(fetchLabels()); +const App = () => ( +
+ +
+
store.dispatch(toggleSidebarCollapsed())} /> + + + + + + + + + + + +
+
+); + +// Guard the entire app, in order to get a single loading indicator instead of +// one for the sidebar and another for the main calendar view +const mapStateToProps = state => ({ + user: state.user, +}); +const AppContainer = connect(mapStateToProps)(withAccountInfo(App)); + ReactDOM.render( -
- -
-
store.dispatch(toggleSidebarCollapsed())} /> - - - - - - - - - - - -
-
+ , document.getElementById('app'), ); diff --git a/src/components/label-pane.jsx b/src/components/label-pane.jsx index 4049f90..5228c4b 100644 --- a/src/components/label-pane.jsx +++ b/src/components/label-pane.jsx @@ -103,9 +103,11 @@ LabelPane.propTypes = { editable: PropTypes.bool, disableProtectedLabels: PropTypes.bool, showUnselected: PropTypes.bool, + // TODO: DRY w/ sidebar.jsx possibleLabels: PropTypes.objectOf(PropTypes.shape({ color: PropTypes.string, description: PropTypes.string, + name: PropTypes.string.isRequired, protected: PropTypes.bool, visbility: PropTypes.string, })), diff --git a/src/containers/with-server-data.jsx b/src/containers/with-server-data.jsx index db785e2..15848a7 100644 --- a/src/containers/with-server-data.jsx +++ b/src/containers/with-server-data.jsx @@ -1,14 +1,16 @@ import * as React from 'react'; +export const Loading = () =>
; + // Guard the wrapped component behind a loading message, until the required // server data has been loaded. Currently this is the user info and the // labels. export const withAccountInfo = WrappedComponent => props => - (props.user ? :

Loading…

); + (props.user ? : ); // Guard the wrapped component behind a loading message, until the required // server data has been loaded. Currently this is the labels. const withServerData = WrappedComponent => props => - (props.labels && props.labels.labelList ? :

Loading…

); + (props.labels && props.labels.labelList ? : ); export default withServerData; diff --git a/src/data/actions.js b/src/data/actions.js index 12ad77c..71ccc75 100644 --- a/src/data/actions.js +++ b/src/data/actions.js @@ -1,11 +1,13 @@ // This file contains a bunch of Redux actions import axios from 'axios'; +import _ from 'lodash'; import moment from 'moment'; import ReactGA from 'react-ga'; import { push } from 'react-router-redux'; -import { setAccessTokenFromResponse } from './auth'; +import { getAccessToken, setAccessTokenFromResponse } from './auth'; import { decodeEvent } from './encoding'; +import { API_SERVER_URL, TOKEN_INFO_ENDPOINT } from './settings'; /* eslint-disable max-len */ export const ActionTypes = { @@ -25,7 +27,7 @@ export const ActionTypes = { SET_FILTER_LABEL_SELECTED: 'SET_FILTER_LABEL_SELECTED', // Sets whether or not a specific label is selected as part // of the event filter SET_VIEW_MODE: 'SET_VIEW_MODE', // Sets which view mode (month, week, day, etc) the calendar is in - SET_USER: 'SET_USER', + SET_ACCESS_INFO: 'SET_ACCESS_INFO', // Event data SET_CURRENT_EVENT: 'SET_CURRENT_EVENT', // Keeps track of the data for the event currently being viewed or edited FETCH_EVENTS_IF_NEEDED: 'FETCH_EVENTS_IF_NEEDED', // Triggers FETCH_EVENTS if no event data is loaded @@ -231,24 +233,27 @@ export function setViewMode(mode) { /** * Performs a server request to refresh the user info. */ -export function fetchUser() { - return dispatch => - axios - .get(`${window.abe_url}/user/`) - .then(setAccessTokenFromResponse) - .then(response => response.data, error => dispatch(displayError(error))) - .then(user => dispatch(setUser(user))); -} - -/** - * Updates the user in the Redux store. - */ -export function setUser(user) { - const data = { - authenticated: user.authenticated, - scope: new Set(user.scope), - }; - return { type: ActionTypes.SET_USER, data }; +export function fetchAccessInfo() { + function setAccessInfo(user) { + const token = user.token || {}; + // TODO: remove support for string array scope + const { scope } = user; + const scopes = _.isString(scope) ? user.scope.split(' ') : scope; + const data = { + authenticated: token.active, + scope: new Set(scopes), + }; + return { type: ActionTypes.SET_ACCESS_INFO, data }; + } + const token = getAccessToken(); + return token + ? dispatch => + axios + .get(TOKEN_INFO_ENDPOINT, { params: { token } }) + .then(setAccessTokenFromResponse) + .then(response => response.data, error => dispatch(displayError(error))) + .then(user => dispatch(setAccessInfo(user))) + : dispatch => dispatch(setAccessInfo({})); } // ########## Begin Event Data Actions ########## // @@ -276,8 +281,8 @@ export function setCurrentEventById(id, recId) { } else { // We don't have the data, so request it from the server const url = recId - ? `${window.abe_url}/events/${id}/${recId}` - : `${window.abe_url}/events/${id}`; + ? `${API_SERVER_URL}/events/${id}/${recId}` + : `${API_SERVER_URL}/events/${id}`; axios .get(url) .then(response => dispatch(setCurrentEventData(response.data))) @@ -308,7 +313,7 @@ export function refreshEvents(start, end) { const startString = `${start.year()}-${start.month() + 1}-${start.date()}`; const endString = `${end.year()}-${end.month() + 1}-${end.date()}`; return axios - .get(`${window.abe_url}/events/?start=${startString}&end=${endString}`) + .get(`${API_SERVER_URL}/events/?start=${startString}&end=${endString}`) .then(response => response.data, error => dispatch(displayError(error))) .then((data) => { const events = data.map(decodeEvent); @@ -378,7 +383,7 @@ export function deleteCurrentEvent() { const store = getStore(); const event = store.events.current; axios - .delete(`${window.abe_url}/events/${event.id || event.sid}`) + .delete(`${API_SERVER_URL}/events/${event.id || event.sid}`) .then(() => dispatch(eventDeletedSuccessfully(event.id || event.sid))) .catch(response => eventDeleteFailed(event, response)); }; @@ -511,7 +516,7 @@ export function refreshLabelsIfNeeded() { export function fetchLabels() { return dispatch => axios - .get(`${window.abe_url}/labels/`) + .get(`${API_SERVER_URL}/labels/`) .then(response => response.data, error => dispatch(displayError(error))) .then(labels => dispatch(setLabels(labels))); } @@ -573,7 +578,7 @@ export function updateLabel(data) { return () => { // TODO: update model on success axios - .post(`${window.abe_url}/labels/${data.id}`, data) + .post(`${API_SERVER_URL}/labels/${data.id}`, data) .catch(error => alert(`Update label failed:\n${error}`)); }; } diff --git a/src/data/auth.js b/src/data/auth.js index 0b58ff2..0d0013e 100644 --- a/src/data/auth.js +++ b/src/data/auth.js @@ -1,15 +1,29 @@ import axios from 'axios'; +import { CLIENT_ID, OAUTH_AUTH_ENDPOINT } from './settings'; +// localStorage key const ACCESS_TOKEN_KEY = 'abeAccessToken'; +export const Scopes = new Set([ + 'create:events', + 'create:ics', + 'create:protected_events', + 'delete:events', + 'delete:protected_events', + 'edit:events', + 'edit:protected_events', + 'read:all_events', + 'read:labels', +]); + export function authorizationUrl(redirectUri) { - const oauthBaseUrl = `${window.abe_url}/oauth/authorize`; - const clientId = process.env.ABE_CLIENT_ID; - let url = `${oauthBaseUrl}?redirect_uri=${encodeURIComponent(redirectUri)}`; - url += '&response_type=token'; - if (clientId) { - url += `&client_id=${clientId}`; - } + const url = [ + OAUTH_AUTH_ENDPOINT, + `?client_id=${CLIENT_ID}`, + `&redirect_uri=${encodeURIComponent(redirectUri)}`, + '&response_type=token', + `&scope=${encodeURIComponent([...Scopes].join(' '))}`, + ].join(''); return url; } @@ -18,33 +32,49 @@ export function clearAccessToken() { axios.defaults.headers.common.Authorization = null; } +export function getAccessToken() { + return localStorage[ACCESS_TOKEN_KEY]; +} + +// Remember the access token into localStorage (where it's available the next +// time the page is visited), and also present it in subsequent API requests. export function setAccessToken(accessToken) { localStorage[ACCESS_TOKEN_KEY] = accessToken; axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; } +// Returns true if the user can sign out. export function canSignOut() { - return Boolean(localStorage[ACCESS_TOKEN_KEY]); + return Boolean(getAccessToken()); } +// Remove the OAuth flow parameters from the URL fragment export function removeOauthFragments(url) { return url - .replace(/([#&])(access_token|expires_in|state|token_type)=[^&]*/g, '$1') + .replace(/([#&])(access_token|expires_in|nonce|state|token_type)=[^&]*/g, '$1') .replace(/([#&])&+/, '$1') .replace(/[#&]$/, ''); } +// Called on startup. Reads the access token from localStorage or from the URL +// fragment (for an OAuth implicit grant callback), and sanitizes the URL in +// the latter case. export function initializeAccessToken() { - const token = localStorage[ACCESS_TOKEN_KEY]; + let token = localStorage[ACCESS_TOKEN_KEY]; const match = document.location.hash.match(/[#&]access_token=([^&]*)/); + // If there's a token in the URL, this replaces any locally-stored token. if (match) { - setAccessToken(decodeURIComponent(match[1])); + token = decodeURIComponent(match[1]); window.history.replaceState({}, document.title, removeOauthFragments(window.location.href)); - } else if (token) { + } + if (token) { setAccessToken(token); } } +// Set the token from the 'Access-Token' HTTP response header. +// +// TODO: I think this is obsolete — ows 2018-05-26 export function setAccessTokenFromResponse(response) { const token = response.headers['access-token']; if (token) { diff --git a/src/data/reducers.js b/src/data/reducers.js index c2d127b..179a176 100644 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -25,7 +25,7 @@ export function general(state = {}, action) { export function user(state = {}, action) { switch (action.type) { - case ActionTypes.SET_USER: + case ActionTypes.SET_ACCESS_INFO: return action.data; default: return state; diff --git a/src/data/settings.js b/src/data/settings.js new file mode 100644 index 0000000..367633c --- /dev/null +++ b/src/data/settings.js @@ -0,0 +1,13 @@ +export const API_SERVER_URL = process.env.ABE_URL.replace(/\/$/, ''); +export const OAUTH_AUTH_ENDPOINT = + process.env.OAUTH_AUTH_ENDPOINT || `${API_SERVER_URL}/oauth/authorize`; +// export const TOKEN_INFO_ENDPOINT = process.env.TOKEN_INFO_ENDPOINT || `${API_SERVER_URL}/user/`; +export const TOKEN_INFO_ENDPOINT = `${API_SERVER_URL}/oauth/introspect`; +export const CLIENT_ID = process.env.CLIENT_ID || '0'; + +export const debug = process.env.DEBUG || false; +export const googleAnalyticsId = process.env.GA_ID; + +// TODO: Replace these with some user-configurable option +export const dayEndHour = process.env.DAY_END_HOUR || 24; +export const dayStartHour = process.env.DAY_START_HOUR || 8; diff --git a/src/data/setup-store.js b/src/data/setup-store.js index 7644fce..2a2e227 100644 --- a/src/data/setup-store.js +++ b/src/data/setup-store.js @@ -4,14 +4,9 @@ import { applyMiddleware, combineReducers, createStore } from 'redux'; import thunkMiddleware from 'redux-thunk'; import * as reducers from './reducers'; import SidebarMode from './sidebar-modes'; +import { debug, googleAnalyticsId, dayStartHour, dayEndHour } from './settings'; export default function setupStore(history) { - const debug = process.env.DEBUG || false; - const googleAnalyticsId = process.env.GA_ID; - // TODO: Replace these with some user-configurable option - const dayStartHour = process.env.DAY_START_HOUR || 8; - const dayEndHour = process.env.DAY_END_HOUR || 24; - const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); const initialState = { general: { diff --git a/src/pages/add-edit/add-edit-page.jsx b/src/pages/add-edit/add-edit-page.jsx index fcdc6f0..600b43b 100644 --- a/src/pages/add-edit/add-edit-page.jsx +++ b/src/pages/add-edit/add-edit-page.jsx @@ -16,6 +16,7 @@ import LocationField from './location-field'; import RecurrenceSelector from './recurrence-selector'; import SaveCancelButtons from './save-cancel-buttons'; import EventVisibilitySelector from './visibility-selector'; +import { API_SERVER_URL } from '../../data/settings'; export default class AddEditEventPage extends React.Component { constructor(props) { @@ -58,7 +59,7 @@ export default class AddEditEventPage extends React.Component { // Editing a specific recurrence in a series of events // Get the series data so we know how this event differs from the rest in the series axios - .get(`${window.abe_url}/events/${props.match.params.id}`) + .get(`${API_SERVER_URL}/events/${props.match.params.id}`) .then(this.receivedSuccessfulSeriesDataResponse); // TODO: Handle an unsuccessful response } @@ -154,17 +155,17 @@ export default class AddEditEventPage extends React.Component { } }); - url = `${window.abe_url}/events/${eventData.id || eventData.sid}/${ + url = `${API_SERVER_URL}/events/${eventData.id || eventData.sid}/${ this.props.match.params.recId }`; } else { - url = `${window.abe_url}/events/${eventData.id || eventData.sid}`; + url = `${API_SERVER_URL}/events/${eventData.id || eventData.sid}`; } delete eventData.color; // Don't send the color used for rendering the calendar requestMethod = axios.put; } else { // We're adding a new event - url = `${window.abe_url}/events/`; + url = `${API_SERVER_URL}/events/`; requestMethod = axios.post; } @@ -241,7 +242,7 @@ export default class AddEditEventPage extends React.Component { const { scope } = this.props.user; const editingExisting = this.state.eventData.id || this.state.eventData.sid; - const requiredScope = editingExisting ? 'create:protected_events' : 'edit:protected_events'; + const requiredScope = 'edit:protected_events'; const pageTitle = editingExisting ? 'Edit Event' : 'Add Event'; const submitButtonText = editingExisting ? 'Update Event' : 'Add Event'; const formUrl = 'https://goo.gl/forms/2cqVijokICZ5S20R2'; @@ -339,7 +340,9 @@ export default class AddEditEventPage extends React.Component { } AddEditEventPage.propTypes = { + // TODO: DRY w/ sidebar.jsx user: PropTypes.shape({ scope: PropTypes.instanceOf(Map) }).isRequired, + // TODO: DRY w/ sidebar.jsx eventData: PropTypes.shape({ title: PropTypes.string, description: PropTypes.string, diff --git a/src/pages/import/import.jsx b/src/pages/import/import.jsx index cd36baa..bb17167 100644 --- a/src/pages/import/import.jsx +++ b/src/pages/import/import.jsx @@ -1,10 +1,11 @@ // This component allows the user to import a calendar from an ICS feed -import * as React from 'react'; import axios from 'axios'; -import SidebarModes from '../../data/sidebar-modes'; +import * as React from 'react'; import TagPane from '../../components/label-pane'; import MenuIconButton from '../../components/menu-icon-button'; +import SidebarModes from '../../data/sidebar-modes'; +import { API_SERVER_URL } from '../../data/settings'; export default class ImportPage extends React.Component { constructor(props) { @@ -36,7 +37,7 @@ export default class ImportPage extends React.Component { submitICS = () => { axios - .post(`${window.abe_url}/ics/`, this.state.importData) + .post(`${API_SERVER_URL}/ics/`, this.state.importData) .then( response => this.props.importSuccess(response, this.state.importData), (jqXHR, textStatus, errorThrown) => this.props.importFailed(errorThrown, jqXHR.message), diff --git a/src/pages/subscription/subscription.jsx b/src/pages/subscription/subscription.jsx index 93d1c6f..08c3abf 100644 --- a/src/pages/subscription/subscription.jsx +++ b/src/pages/subscription/subscription.jsx @@ -7,6 +7,7 @@ import TagPane from '../../components/label-pane'; import MenuIconButton from '../../components/menu-icon-button'; import SidebarModes from '../../data/sidebar-modes'; import docs from '../../docs'; +import { API_SERVER_URL } from '../../data/settings'; export default class SubscriptionEditorPage extends React.Component { constructor(props) { @@ -20,7 +21,7 @@ export default class SubscriptionEditorPage extends React.Component { }; axios - .get(`${window.abe_url}/subscriptions/${this.getIdFromURL(props)}`) + .get(`${API_SERVER_URL}/subscriptions/${this.getIdFromURL(props)}`) .then( response => this.setState({ data: Object.assign({}, this.state.data, response.data) }), (jqXHR, textStatus, errorThrown) => this.props.importFailed(errorThrown, jqXHR.message), @@ -56,7 +57,7 @@ export default class SubscriptionEditorPage extends React.Component { // }; submitSubscription = () => { - axios.put(`${window.abe_url}/subscriptions/${this.state.data.id}`, this.state.data).then( + axios.put(`${API_SERVER_URL}/subscriptions/${this.state.data.id}`, this.state.data).then( (response) => { this.setState({ data: Object.assign({}, this.state.data, response.data) }); this.props.importSuccess(response, response.data); @@ -66,7 +67,7 @@ export default class SubscriptionEditorPage extends React.Component { }; copyToClipboard() { - const url = window.abe_url + this.state.data.ics_url; + const url = API_SERVER_URL + this.state.data.ics_url; copy(url); alert('Link copied to clipboard'); } @@ -99,7 +100,7 @@ export default class SubscriptionEditorPage extends React.Component { />
Import into Outlook diff --git a/src/sidebar/footer.jsx b/src/sidebar/footer.jsx index b24973a..33f4fc7 100644 --- a/src/sidebar/footer.jsx +++ b/src/sidebar/footer.jsx @@ -2,7 +2,7 @@ import * as React from 'react'; -const Footer = props => ( +const Footer = () => ( ); diff --git a/src/sidebar/generate-ics-pane.jsx b/src/sidebar/generate-ics-pane.jsx index e4edb13..04fc755 100644 --- a/src/sidebar/generate-ics-pane.jsx +++ b/src/sidebar/generate-ics-pane.jsx @@ -6,6 +6,7 @@ import copy from 'copy-to-clipboard'; import React from 'react'; import { OutboundLink } from 'react-ga'; import docs from '../docs'; +import { API_SERVER_URL } from '../data/settings'; export default class GenerateICSPane extends React.Component { constructor(props) { @@ -31,7 +32,7 @@ export default class GenerateICSPane extends React.Component { const labels = this.props.selectedLabels; - const url = `${window.abe_url}/subscriptions/`; + const url = `${API_SERVER_URL}/subscriptions/`; axios.post(url, { labels }).then( (response) => { @@ -42,7 +43,7 @@ export default class GenerateICSPane extends React.Component { } copyToClipboard(url) { - copy(window.abe_url + url); + copy(API_SERVER_URL + url); this.props.icsUrlCopiedToClipboard(url); alert('Feed URL copied to clipboard'); @@ -86,7 +87,7 @@ export default class GenerateICSPane extends React.Component { Import into Outlook diff --git a/src/sidebar/sidebar.jsx b/src/sidebar/sidebar.jsx index 91568a6..111de69 100644 --- a/src/sidebar/sidebar.jsx +++ b/src/sidebar/sidebar.jsx @@ -2,6 +2,8 @@ // content merely changes when pages are changed. import React from 'react'; +import moment from 'moment'; +import PropTypes from 'prop-types'; import LabelPane from '../components/label-pane'; import SidebarHeader from '../components/sidebar-header'; import { authorizationUrl, canSignOut, clearAccessToken } from '../data/auth'; @@ -16,28 +18,51 @@ import SidebarItem from './sidebar-item'; const isProtectedEvent = (event, possibleLabels) => event && event.labels.some(label => (possibleLabels[label] || {}).protected); +const olinBuildLogo = ( + + olin.build + +); + const Sidebar = (props) => { const { user: { scope }, sidebarMode: mode, } = props; + const onSignOut = () => { + clearAccessToken(); + window.location.reload(); + }; + const sidebarClasses = `app-sidebar${props.isCollapsed ? ' collapsed' : ' expanded'}`; const content = (
{!scope.has('read:all_events') && (
-

You are viewing the public calendar.

+

You are viewing public events.

- Sign in to view and add Olin - Community events. + Sign in to {olinBuildLogo} to see + and add Olin Community events.

)} + {mode.FILTER_PANE && ( + // For viewing the calendar + + + + )} + {mode.LINK_PANE && - scope.has('create:events') && ( + scope.has('edit:events') && ( { )} - {mode.FILTER_PANE && ( - // For viewing the calendar - - - - )} - {mode.GENERATE_ICS_PANE && ( @@ -82,19 +100,23 @@ const Sidebar = (props) => { )} + + {canSignOut() && ( + // For viewing the calendar + + Sign out of {olinBuildLogo}. While signed out, you will only be + able to view public events when you are outside the intranet. + + )}
); - const onSignOut = () => { - clearAccessToken(); - window.location.reload(); - }; - const sidebarClasses = `app-sidebar${props.isCollapsed ? ' collapsed' : ' expanded'}`; return (
{content}
@@ -104,4 +126,52 @@ const Sidebar = (props) => { ); }; +Sidebar.propTypes = { + // TODO: DRY w/ add-edit-page.js + currentEvent: PropTypes.shape({ + title: PropTypes.string, + description: PropTypes.string, + start: PropTypes.instanceOf(moment), + end: PropTypes.instanceOf(moment), + labels: PropTypes.arrayOf(PropTypes.string), + }), + // TODO: DRY w/ label-pane.js + possibleLabels: PropTypes.objectOf(PropTypes.shape({ + color: PropTypes.string, + description: PropTypes.string, + name: PropTypes.string.isRequired, + protected: PropTypes.bool, + visbility: PropTypes.string, + })), + sidebarMode: PropTypes.shape({ + LINK_PANE: PropTypes.bool, + FILTER_PANE: PropTypes.bool, + GENERATE_ICS_PANE: PropTypes.bool, + MARKDOWN_GUIDE: PropTypes.bool, + EVENT_ACTIONS: PropTypes.bool, + EVENT_LABELS_PANE: PropTypes.bool, + }).isRequired, + isCollapsed: PropTypes.bool.isRequired, + selectedLabels: PropTypes.arrayOf(PropTypes.string), + // TODO: DRY w/ add-edit-page.js + user: PropTypes.shape({ scope: PropTypes.instanceOf(Set) }).isRequired, + + addEvent: PropTypes.func.isRequired, + deleteCurrentEvent: PropTypes.func.isRequired, + editCurrentEvent: PropTypes.func.isRequired, + editEventSeries: PropTypes.func.isRequired, + homeClicked: PropTypes.func.isRequired, + icsUrlCopiedToClipboard: PropTypes.func.isRequired, + importICSClicked: PropTypes.func.isRequired, + labelToggled: PropTypes.func.isRequired, + setVisibleLabels: PropTypes.func.isRequired, + toggleSidebarCollapsed: PropTypes.func.isRequired, +}; + +Sidebar.defaultProps = { + currentEvent: null, + possibleLabels: {}, + selectedLabels: [], +}; + export default Sidebar; diff --git a/webpack.config.js b/webpack.config.js index bfcd401..6a4147c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -33,9 +33,10 @@ const config = { plugins: [ new webpack.EnvironmentPlugin({ ABE_URL: 'http://localhost:3000/', - ABE_CLIENT_ID: null, + CLIENT_ID: null, DEBUG: true, GA_ID: null, + OAUTH_AUTH_ENDPOINT: null, }), ], devServer: {