setup redux app for new announcements page
refs COMMS-554 test plan: - in a course, create some announcements - navigate to the announcements page and note it loads the old page - turn on section specific announcements feature flag at the account level - navigate to the announcements page - note that you see a 'Announcements (2) items on page x' with the number in the parens matching the number of announcements in that course Change-Id: I6982aa2b20864f96ae61a052b33948f65bf549e2 Reviewed-on: https://gerrit.instructure.com/134425 Reviewed-by: Steven Burnett <sburnett@instructure.com> QA-Review: Venk Natarajan <vnatarajan@instructure.com> Tested-by: Jenkins Product-Review: Aaron Kc Hsu <ahsu@instructure.com>
This commit is contained in:
parent
e3e94e68a1
commit
e069f3ebef
|
@ -42,6 +42,12 @@ class AnnouncementsController < ApplicationController
|
|||
js_env atom_feed_url: feeds_announcements_format_path((@context_enrollment || @context).feed_code, :atom)
|
||||
js_env(COURSE_ID: @context.id.to_s) if @context.is_a?(Course)
|
||||
|
||||
if @context.account.feature_enabled?(:section_specific_announcements)
|
||||
js_bundle :announcements_index_v2
|
||||
else
|
||||
js_bundle :announcements_index
|
||||
end
|
||||
|
||||
set_tutorial_js_env
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import I18n from 'i18n!announcements_v2'
|
||||
import { createActions } from 'redux-actions'
|
||||
|
||||
import { getAnnouncements } from './apiClient'
|
||||
import { notificationActions } from '../shared/reduxNotifications'
|
||||
import { createPaginationActions } from '../shared/reduxPagination'
|
||||
|
||||
function fetchAnnouncements(dispatch, getState, payload) {
|
||||
return (resolve, reject) => {
|
||||
getAnnouncements(getState(), payload)
|
||||
.then((res) => {
|
||||
resolve(res)
|
||||
dispatch(notificationActions.notifyInfo(I18n.t('Announcements Loaded!'))) // dummy notification, remove me later
|
||||
})
|
||||
.catch(err => reject({ err, message: I18n.t('An error ocurred while loading announcements') }))
|
||||
}
|
||||
}
|
||||
const announcementActions = createPaginationActions('announcements', fetchAnnouncements)
|
||||
|
||||
const types = [
|
||||
...announcementActions.actionTypes,
|
||||
]
|
||||
const actions = Object.assign(
|
||||
createActions(...types),
|
||||
announcementActions.actionCreators,
|
||||
)
|
||||
|
||||
const actionTypes = types.reduce((typesMap, actionType) =>
|
||||
Object.assign(typesMap, { [actionType]: actionType }), {})
|
||||
|
||||
export { actionTypes, actions as default }
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { encodeQueryString } from '../shared/queryString'
|
||||
|
||||
export function getAnnouncements ({ contextCodes, announcements }, { page }) {
|
||||
const params = encodeQueryString([
|
||||
{ 'context_codes[]': contextCodes },
|
||||
{ page: page || announcements.currentPage },
|
||||
])
|
||||
|
||||
return axios.get(`/api/v1/announcements?${params}`)
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import I18n from 'i18n!announcements_v2'
|
||||
import React, { Component } from 'react'
|
||||
import { func, bool, number } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { bindActionCreators } from 'redux'
|
||||
|
||||
import Spinner from 'instructure-ui/lib/components/Spinner'
|
||||
import Heading from 'instructure-ui/lib/components/Heading'
|
||||
import Typography from 'instructure-ui/lib/components/Typography'
|
||||
|
||||
import select from '../../shared/select'
|
||||
import { selectPaginationState } from '../../shared/reduxPagination'
|
||||
import propTypes from '../propTypes'
|
||||
import actions from '../actions'
|
||||
|
||||
export default class AnnouncementsIndex extends Component {
|
||||
static propTypes = {
|
||||
announcements: propTypes.announcementList.isRequired,
|
||||
announcementsPage: number.isRequired,
|
||||
isLoadingAnnouncements: bool.isRequired,
|
||||
hasLoadedAnnouncements: bool.isRequired,
|
||||
getAnnouncements: func.isRequired,
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (!this.props.hasLoadedAnnouncements) {
|
||||
this.props.getAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
renderSpinner (condition, title) {
|
||||
if (condition) {
|
||||
return (
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<Spinner size="small" title={title} />
|
||||
<Typography size="small" as="p">{title}</Typography>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
renderAnnouncements () {
|
||||
if (this.props.hasLoadedAnnouncements) {
|
||||
return (
|
||||
<Typography as="p">
|
||||
{I18n.t('%{count} items on page %{page}', {
|
||||
count: this.props.announcements.length,
|
||||
page: this.props.announcementsPage,
|
||||
})}
|
||||
</Typography>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className="announcements-v2__wrapper">
|
||||
<Heading>{I18n.t('Announcements')}</Heading>
|
||||
{this.renderSpinner(this.props.isLoadingAnnouncements, I18n.t('Loading Announcements'))}
|
||||
{this.renderAnnouncements()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const connectState = state => Object.assign({
|
||||
// other props here
|
||||
}, selectPaginationState(state, 'announcements'))
|
||||
const connectActions = dispatch => bindActionCreators(select(actions, ['getAnnouncements']), dispatch)
|
||||
export const ConnectedAnnouncementsIndex = connect(connectState, connectActions)(AnnouncementsIndex)
|
|
@ -19,7 +19,7 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import I18n from 'i18n!announcements'
|
||||
import FriendlyDatetime from '../shared/FriendlyDatetime'
|
||||
import FriendlyDatetime from '../../shared/FriendlyDatetime'
|
||||
import ToggleDetails from 'instructure-ui/lib/components/ToggleDetails'
|
||||
import Table from 'instructure-ui/lib/components/Table'
|
||||
import Link from 'instructure-ui/lib/components/Link'
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Provider } from 'react-redux'
|
||||
|
||||
import { subscribeFlashNotifications } from '../shared/reduxNotifications'
|
||||
import { ConnectedAnnouncementsIndex } from './components/AnnouncementsIndex'
|
||||
import createStore from './store'
|
||||
|
||||
export default function createAnnouncementsIndex (root, data = {}) {
|
||||
const store = createStore(data)
|
||||
|
||||
function unmount () {
|
||||
ReactDOM.unmountComponentAtNode(root)
|
||||
}
|
||||
|
||||
function render () {
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<ConnectedAnnouncementsIndex />
|
||||
</Provider>,
|
||||
root
|
||||
)
|
||||
}
|
||||
|
||||
subscribeFlashNotifications(store)
|
||||
|
||||
return { unmount, render }
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { shape, arrayOf, string, number, bool, oneOf } from 'prop-types'
|
||||
|
||||
const propTypes = {}
|
||||
|
||||
propTypes.user = shape({
|
||||
id: string.isRequired,
|
||||
display_name: string.isRequired,
|
||||
avatar_image_url: string,
|
||||
html_url: string.isRequired,
|
||||
})
|
||||
|
||||
propTypes.announcement = shape({
|
||||
id: string.isRequired,
|
||||
position: number.isRequired,
|
||||
published: bool.isRequired,
|
||||
title: string.isRequired,
|
||||
message: string.isRequired,
|
||||
posted_at: string.isRequired,
|
||||
author: propTypes.user.isRequired,
|
||||
read_state: oneOf(['read', 'unread']),
|
||||
unread_count: number.isRequired,
|
||||
})
|
||||
|
||||
propTypes.announcementList = arrayOf(propTypes.announcement)
|
||||
|
||||
export default propTypes
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { combineReducers } from 'redux'
|
||||
// TODO: import { handleActions } from 'redux-actions'
|
||||
// TODO: import { actionTypes } from './actions'
|
||||
import { reduceNotifications } from '../shared/reduxNotifications'
|
||||
import { createPaginatedReducer } from '../shared/reduxPagination'
|
||||
|
||||
const identity = (defaultState = null) => (
|
||||
state => (state === undefined ? defaultState : state)
|
||||
)
|
||||
|
||||
export default combineReducers({
|
||||
contextCodes: identity([]),
|
||||
announcements: createPaginatedReducer('announcements'),
|
||||
notifications: reduceNotifications,
|
||||
})
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createStore, applyMiddleware } from 'redux'
|
||||
import ReduxThunk from 'redux-thunk'
|
||||
import rootReducer from './reducer'
|
||||
|
||||
export default function configStore (initialState) {
|
||||
const middleware = [
|
||||
ReduxThunk,
|
||||
|
||||
// this is so redux-logger is not included in the production webpack bundle
|
||||
(process.env.NODE_ENV !== 'production') && require('redux-logger')() // eslint-disable-line global-require
|
||||
].filter(Boolean)
|
||||
return applyMiddleware(...middleware)(createStore)(rootReducer, initialState)
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import createAnnIndex from 'jsx/announcements'
|
||||
|
||||
const root = document.querySelector('#content')
|
||||
const app = createAnnIndex(root, {
|
||||
contextCodes: [ENV.context_asset_string],
|
||||
})
|
||||
app.render()
|
|
@ -20,7 +20,7 @@ import I18n from 'i18n!announcements_on_home_page'
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import axios from 'axios'
|
||||
import AnnouncementList from 'jsx/announcements/AnnouncementList'
|
||||
import AnnouncementList from 'jsx/announcements/components/OldAnnouncementList'
|
||||
import Spinner from 'instructure-ui/lib/components/Spinner'
|
||||
|
||||
if (ENV.SHOW_ANNOUNCEMENTS) {
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function encodeQueryString (params) {
|
||||
return params.map((param) => {
|
||||
const key = Object.keys(param)[0]
|
||||
const value = param[key]
|
||||
return value !== null ? `${key}=${value}` : null
|
||||
}).filter(Boolean).join('&')
|
||||
}
|
||||
|
||||
export function decodeQueryString (string) {
|
||||
return string.split('&')
|
||||
.map(pair => pair.split('='))
|
||||
.map(([key, value]) => ({ [key]: value }))
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createActions, handleActions } from 'redux-actions'
|
||||
import { showFlashAlert } from './FlashAlert'
|
||||
|
||||
/**
|
||||
* Exports action action creators for notification actions:
|
||||
* - notifyInfo(string | { message })
|
||||
* - notifyError(string | { err, message })
|
||||
* - clearNotifications()
|
||||
*/
|
||||
export const notificationActions = createActions({
|
||||
NOTIFY_INFO: payload =>
|
||||
typeof payload === 'string'
|
||||
? { type: 'info', message: payload }
|
||||
: { type: 'info', ...payload },
|
||||
NOTIFY_ERROR: payload =>
|
||||
typeof payload === 'string'
|
||||
? { type: 'error', message: payload }
|
||||
: { type: 'error', ...payload },
|
||||
}, 'CLEAR_NOTIFICATION')
|
||||
|
||||
const createNotification = data => ({
|
||||
id: Math.random().toString(36).substring(2), // pseudo uuid
|
||||
timestamp: Date.now(),
|
||||
type: data.type || (data.err ? 'error' : 'info'),
|
||||
message: data.message,
|
||||
err: data.err,
|
||||
})
|
||||
|
||||
const handleNotificationActions = handleActions({
|
||||
[notificationActions.notifyInfo.toString()]:
|
||||
(state, action) => state.concat([createNotification({ ...action.payload, type: 'info' })]),
|
||||
[notificationActions.notifyError.toString()]:
|
||||
(state, action) => state.concat([createNotification({ ...action.payload, type: 'error' })]),
|
||||
[notificationActions.clearNotification.toString()]:
|
||||
(state, action) => state.slice().filter(not => not.id !== action.payload),
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Reducer function for notifications array. Add it in your root reducer!
|
||||
* Will add or remove notifications to the state depending on the action
|
||||
* It will try to catch generic actions with an `err` and `message` prop on the
|
||||
* payload as error notifications
|
||||
*
|
||||
* @param {array[notification]} state current notifications state
|
||||
* @param {action} action action to reduce notification with
|
||||
*
|
||||
* @example
|
||||
* combineReducers({
|
||||
* notifications: reduceNotifications,
|
||||
* items: handleActions({
|
||||
* // your reducer here
|
||||
* }, []),
|
||||
* users: (state, action) => {
|
||||
* // your reducer here
|
||||
* },
|
||||
* })
|
||||
*/
|
||||
export const reduceNotifications = (state, action) => {
|
||||
let newState = handleNotificationActions(state, action)
|
||||
|
||||
const notErr = action.type !== notificationActions.notifyError.toString()
|
||||
const looksLikeErr = action.payload && action.payload.err && action.payload.message
|
||||
|
||||
// duck typing error notifications from structure of _FAIL actions
|
||||
if (notErr && looksLikeErr) {
|
||||
newState = newState.concat([createNotification(action.payload)])
|
||||
}
|
||||
|
||||
return newState
|
||||
}
|
||||
|
||||
/**
|
||||
* This function watches the given store for notifications, and flashes an
|
||||
* alert component for every one of them
|
||||
*
|
||||
* @param {store} store redux store to subscribe to
|
||||
* @param {string} key optional state key to look look into store state for notifications
|
||||
*/
|
||||
export function subscribeFlashNotifications (store, key = 'notifications') {
|
||||
store.subscribe(() => {
|
||||
const notifications = store.getState()[key]
|
||||
notifications.forEach((notification) => {
|
||||
showFlashAlert(notification)
|
||||
store.dispatch(notificationActions.clearNotification(notification.id))
|
||||
})
|
||||
})
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { combineReducers } from 'redux'
|
||||
import { handleActions } from 'redux-actions'
|
||||
import parseLinkHeader from 'jsx/shared/helpers/parseLinkHeader'
|
||||
|
||||
const DEFAULT_PAGE = 1
|
||||
|
||||
// enum-like helper for load states
|
||||
export const LoadStates = (function initLoadStates () {
|
||||
const statesList = ['NOT_LOADED', 'LOADING', 'LOADED', 'ERRORED']
|
||||
const states = statesList.reduce((map, state) =>
|
||||
Object.assign(map, {
|
||||
[state]: state,
|
||||
}), {})
|
||||
|
||||
return {
|
||||
...states,
|
||||
statesList,
|
||||
isLoading: state => state === states.LOADING,
|
||||
hasLoaded: state => state === states.LOADED,
|
||||
isNotLoaded: state => state === states.NOT_LOADED,
|
||||
}
|
||||
}())
|
||||
|
||||
function createActionTypes (name) {
|
||||
const upperName = name.toUpperCase()
|
||||
return {
|
||||
select: `SELECT_${upperName}_PAGE`,
|
||||
start: `GET_${upperName}_START`,
|
||||
success: `GET_${upperName}_SUCCESS`,
|
||||
fail: `GET_${upperName}_FAIL`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reducer for an individual page that keeps track of loading
|
||||
* state and data for that page
|
||||
*
|
||||
* @param {object} actions object returned by createActionTypes
|
||||
*/
|
||||
function createReducePage (actions) {
|
||||
return combineReducers({
|
||||
loadState: handleActions({
|
||||
[actions.start]: () => LoadStates.LOADING,
|
||||
[actions.success]: () => LoadStates.LOADED,
|
||||
[actions.fail]: () => LoadStates.ERRORED,
|
||||
}, LoadStates.NOT_LOADED),
|
||||
items: handleActions({
|
||||
[actions.success]: (state, action) => action.payload.data,
|
||||
}, []),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reducer that manages the pages collection for a paginated data set
|
||||
*
|
||||
* @param {object} actions object returned by createActionTypes
|
||||
*/
|
||||
function createPagesReducer (actions) {
|
||||
return function reducePages (state = {}, action) {
|
||||
const page = action.payload ? action.payload.page : null
|
||||
if (!page) return state // page is a required prop on payload
|
||||
const pageState = state[page]
|
||||
return Object.assign({}, state, {
|
||||
[page]: createReducePage(actions)(pageState, action)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reducer that manages the state for paginated data
|
||||
* It will keep track of data and load state for individual pages as well as
|
||||
* what page the current page is and the max number of pages
|
||||
* Add it in your root reducer! The given name is used to determine what
|
||||
* actions this reducer will respond to
|
||||
*
|
||||
* @param {string} name name that is used in pagination action types
|
||||
*
|
||||
* @example
|
||||
* combineReducers({
|
||||
* users: handleActions({
|
||||
* // your reducer here
|
||||
* }, []),
|
||||
*
|
||||
* // items will have .currentPage, .lastPage, etc
|
||||
* // items will respond to GET_ITEMS_START, etc
|
||||
* items: createPaginatedReducer('items'),
|
||||
* })
|
||||
*/
|
||||
export function createPaginatedReducer (name) {
|
||||
const actions = createActionTypes(name)
|
||||
return combineReducers({
|
||||
currentPage: handleActions({
|
||||
[actions.select]: (state, action) => action.payload.page,
|
||||
}, DEFAULT_PAGE),
|
||||
lastPage: handleActions({
|
||||
[actions.success]: (state, action) => action.payload.lastPage || state,
|
||||
}, DEFAULT_PAGE),
|
||||
pages: createPagesReducer(actions),
|
||||
})
|
||||
}
|
||||
|
||||
function wrapGetPageThunk (actions, name, thunk) {
|
||||
/**
|
||||
* payload params:
|
||||
* @param {integer} page page to select/fetch
|
||||
* @param {bool} select whether to select the page we are fetching
|
||||
* @param {bool} forceGet if page is already loaded, force get it anyway
|
||||
*/
|
||||
return (payload = {}) => (dispatch, getState) => {
|
||||
if (payload.select) {
|
||||
dispatch({ type: actions.select, payload: { page: payload.page } })
|
||||
}
|
||||
|
||||
const state = getState()
|
||||
const page = payload.page || state[name].currentPage
|
||||
const pageData = state[name].pages[page] || {}
|
||||
|
||||
// only fetch page data is it has not been loaded or we are force getting it
|
||||
if (!LoadStates.hasLoaded(pageData.loadState) || payload.forceGet) {
|
||||
dispatch({ type: actions.start, payload: { page }})
|
||||
|
||||
new Promise(thunk(dispatch, getState, { page }))
|
||||
.then(res => {
|
||||
const successPayload = { page, data: res.data }
|
||||
|
||||
// sometimes the canvas API provides us with link header that gives
|
||||
// us the URL to the last page. we can try parse that URL to determine
|
||||
// how many pages there are in total
|
||||
// works only with axios res objects, aka assumes thunk is axios promise
|
||||
const links = parseLinkHeader(res)
|
||||
if (links.last) {
|
||||
try {
|
||||
successPayload.lastPage = Number(/&page=([0-9]+)&/.exec(links.last)[1])
|
||||
} catch (e) {} // eslint-disable-line
|
||||
}
|
||||
dispatch({ type: actions.success, payload: successPayload })
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch({ type: actions.fail, payload: { page, ...err } })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates actions types and action creators for paginating a set of data
|
||||
*
|
||||
* for name "items", action types will be:
|
||||
* - SELECT_ITEMS_PAGE
|
||||
* - GET_ITEMS_START
|
||||
* - GET_ITEMS_SUCCESS
|
||||
* - GET_ITEMS_FAIL
|
||||
*
|
||||
* for name "items", action creators will be:
|
||||
* - getItems - redux-thunk action creator that will execute the given thunk
|
||||
*
|
||||
* @param {string} name name of the data set
|
||||
* @param {function} thunk function that will get our data
|
||||
*
|
||||
* thunk must follow a promise-like interface:
|
||||
* @example
|
||||
* thunk = (dispatch, getState) => (resolve, reject) => {
|
||||
* // your async logic here that calls resolve / reject
|
||||
* // if actions is success / fail and has access to the store's
|
||||
* // dispatch / getState just like any other thunk
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* function fetchItems (dispatch, getState) {
|
||||
* return (resolve, reject) =>
|
||||
* axios.get('/api/v1/items')
|
||||
* .then(resolve)
|
||||
* .catch(reject)
|
||||
* }
|
||||
* const itemActions = createPaginationActions('items', fetchItems)
|
||||
*
|
||||
* // calls fetchItems but dispatches START/SUCCESS/FAIL to store for the page
|
||||
* itemActions.actionCreators.getItems({ page: 3 })
|
||||
*/
|
||||
export function createPaginationActions (name, thunk) {
|
||||
const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1)
|
||||
const actionTypes = createActionTypes(name)
|
||||
return {
|
||||
actionTypes: Object.keys(actionTypes).map(key => actionTypes[key]),
|
||||
actionCreators: {
|
||||
[`get${capitalizedName}`]: wrapGetPageThunk(actionTypes, name, thunk),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redux state selector function that transforms internal redux pagination
|
||||
* state into props that are more friendly for a component to use
|
||||
*
|
||||
* @param {obj} state redux store state to look into
|
||||
* @param {string} name key into state for paginated data
|
||||
*/
|
||||
export function selectPaginationState (state, name) {
|
||||
const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1)
|
||||
const itemsState = state[name]
|
||||
const page = itemsState.pages[itemsState.currentPage] || {}
|
||||
return {
|
||||
[name]: page.items || [],
|
||||
[`${name}Page`]: itemsState.currentPage,
|
||||
[`isLoading${capitalizedName}`]: LoadStates.isLoading(page.loadState),
|
||||
[`hasLoaded${capitalizedName}`]: LoadStates.hasLoaded(page.loadState),
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@
|
|||
@body_classes << 'hide-content-while-scripts-not-loaded right-side-optional'
|
||||
content_for :page_title, join_title(t('#titles.announcements', "Announcements"), @context.name)
|
||||
feed_code = @context_enrollment.try(:feed_code) || (@context.available? && @context.feed_code)
|
||||
js_bundle :announcements_index
|
||||
css_bundle :discussions_list
|
||||
%>
|
||||
<% content_for :auto_discovery do %>
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { mount, shallow } from 'enzyme'
|
||||
|
||||
import AnnouncementsIndex from 'jsx/announcements/components/AnnouncementsIndex'
|
||||
|
||||
const defaultProps = () => ({
|
||||
announcements: [],
|
||||
announcementsPage: 1,
|
||||
isLoadingAnnouncements: false,
|
||||
hasLoadedAnnouncements: false,
|
||||
getAnnouncements: () => {},
|
||||
})
|
||||
|
||||
QUnit.module('AnnouncementsIndex component')
|
||||
|
||||
test('renders the component', () => {
|
||||
const tree = mount(<AnnouncementsIndex {...defaultProps()} />)
|
||||
const node = tree.find('AnnouncementsIndex')
|
||||
ok(node.exists())
|
||||
})
|
||||
|
||||
test('displays spinner when loading announcements', () => {
|
||||
const props = defaultProps()
|
||||
props.isLoadingAnnouncements = true
|
||||
const tree = shallow(<AnnouncementsIndex {...props} />)
|
||||
const node = tree.find('Spinner')
|
||||
ok(node.exists())
|
||||
})
|
||||
|
||||
test('calls getAnnouncements if hasLoadedAnnouncements is false', () => {
|
||||
const props = defaultProps()
|
||||
props.getAnnouncements = sinon.spy()
|
||||
mount(<AnnouncementsIndex {...props} />)
|
||||
equal(props.getAnnouncements.callCount, 1)
|
||||
})
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
define([
|
||||
'react', 'react-addons-test-utils', 'jsx/announcements/AnnouncementList',
|
||||
'react', 'react-addons-test-utils', 'jsx/announcements/components/OldAnnouncementList',
|
||||
], (React, TestUtils, AnnouncementList) => {
|
||||
QUnit.module('AnnouncementList')
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import createAnnIndex from 'jsx/announcements/index'
|
||||
|
||||
let app = null
|
||||
const container = document.getElementById('fixtures')
|
||||
|
||||
QUnit.module('Announcements app', {
|
||||
teardown: () => {
|
||||
if (app) {
|
||||
app.unmount()
|
||||
app = null
|
||||
}
|
||||
container.innerHTML = ''
|
||||
}
|
||||
})
|
||||
|
||||
const defaultData = () => ({
|
||||
contextCodes: ['course_1'],
|
||||
})
|
||||
|
||||
test('mounts Announcements to container component', () => {
|
||||
app = createAnnIndex(container, defaultData())
|
||||
app.render()
|
||||
ok(container.querySelector('.announcements-v2__wrapper'))
|
||||
})
|
||||
|
||||
test('unmounts Announcements from container component', () => {
|
||||
app = createAnnIndex(container, defaultData())
|
||||
app.render()
|
||||
app.unmount()
|
||||
notOk(document.querySelector('.announcements-v2__wrapper'))
|
||||
})
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (C) 2016 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createStore, applyMiddleware } from 'redux'
|
||||
import ReduxThunk from 'redux-thunk'
|
||||
import rootReducer from 'jsx/announcements/reducer'
|
||||
|
||||
export default function mockStore (initialState) {
|
||||
return applyMiddleware(ReduxThunk)(createStore)(rootReducer, initialState)
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import actions from 'jsx/announcements/actions'
|
||||
import reducer from 'jsx/announcements/reducer'
|
||||
import sampleData from './sampleData'
|
||||
|
||||
QUnit.module('Announcements reducer')
|
||||
|
||||
const reduce = (action, state = {}) => reducer(state, action)
|
||||
|
||||
// test('does something on SOME_ACTION', () => {
|
||||
// const newState = reduce(actions.someAction())
|
||||
// deepEqual(newState.foo, 'bar')
|
||||
// })
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const data = {
|
||||
announcements: [{
|
||||
id: '1',
|
||||
position: 2,
|
||||
published: true,
|
||||
title: 'hello world',
|
||||
posted_at: (new Date).toString(),
|
||||
author: {
|
||||
display_name: 'John Doe',
|
||||
},
|
||||
read_state: 'read',
|
||||
unread_count: 0,
|
||||
}],
|
||||
}
|
||||
|
||||
export default data
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { encodeQueryString, decodeQueryString } from 'jsx/shared/queryString'
|
||||
|
||||
QUnit.module('Query String util')
|
||||
|
||||
QUnit.module('encodeQueryString')
|
||||
|
||||
test('encodes a query string from array of params', () => {
|
||||
const params = [{ foo: 'bar' }, { hello: 'world' }]
|
||||
const query = encodeQueryString(params)
|
||||
equal(query, 'foo=bar&hello=world')
|
||||
})
|
||||
|
||||
test('encodes a query string from array of params with duplicate keys', () => {
|
||||
const params = [{ 'foo[]': 'bar' }, { 'foo[]': 'world' }]
|
||||
const query = encodeQueryString(params)
|
||||
equal(query, 'foo[]=bar&foo[]=world')
|
||||
})
|
||||
|
||||
QUnit.module('decodeQueryString')
|
||||
|
||||
test('decodes a query string into an array of params', () => {
|
||||
const params = decodeQueryString('foo=bar&hello=world')
|
||||
deepEqual(params, [{ foo: 'bar' }, { hello: 'world' }])
|
||||
})
|
||||
|
||||
test('decodes a query string with duplicate keys into an array of params', () => {
|
||||
const params = decodeQueryString('foo[]=bar&foo[]=world')
|
||||
deepEqual(params, [{ 'foo[]': 'bar' }, { 'foo[]': 'world' }])
|
||||
})
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { subscribeFlashNotifications, notificationActions, reduceNotifications } from 'jsx/shared/reduxNotifications'
|
||||
import * as FlashAlert from 'jsx/shared/FlashAlert'
|
||||
|
||||
const createMockStore = state => ({
|
||||
subs: [],
|
||||
subscribe (cb) { this.subs.push(cb) },
|
||||
getState: () => state,
|
||||
dispatch: () => {},
|
||||
mockStateChange () { this.subs.forEach(sub => sub()) },
|
||||
})
|
||||
|
||||
QUnit.module('Redux Notifications')
|
||||
|
||||
QUnit.module('subscribeFlashNotifications', {
|
||||
teardown () {
|
||||
FlashAlert.destroyContainer()
|
||||
}
|
||||
})
|
||||
|
||||
test('subscribes to a store and calls showFlashAlert for each notification in state', (assert) => {
|
||||
const done = assert.async()
|
||||
const flashAlertSpy = sinon.spy(FlashAlert, 'showFlashAlert')
|
||||
const mockStore = createMockStore({
|
||||
notifications: [{ id: '1', message: 'hello' }, { id: '2', message: 'world' }]
|
||||
})
|
||||
|
||||
subscribeFlashNotifications(mockStore)
|
||||
mockStore.mockStateChange()
|
||||
|
||||
setTimeout(() => {
|
||||
equal(flashAlertSpy.callCount, 2)
|
||||
deepEqual(flashAlertSpy.firstCall.args, [{ id: '1', message: 'hello' }])
|
||||
deepEqual(flashAlertSpy.secondCall.args, [{ id: '2', message: 'world' }])
|
||||
flashAlertSpy.restore()
|
||||
done()
|
||||
}, 1)
|
||||
})
|
||||
|
||||
test('subscribes to a store and dispatches clearNotifications for each notification in state', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
notifications: [{ id: '1', message: 'hello' }, { id: '2', message: 'world' }]
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
|
||||
subscribeFlashNotifications(mockStore)
|
||||
mockStore.mockStateChange()
|
||||
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 2)
|
||||
deepEqual(dispatchSpy.firstCall.args, [notificationActions.clearNotification('1')])
|
||||
deepEqual(dispatchSpy.secondCall.args, [notificationActions.clearNotification('2')])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
}, 1)
|
||||
})
|
||||
|
||||
QUnit.module('notificationActions')
|
||||
|
||||
test('notifyInfo creates action NOTIFY_INFO with type "info" and payload', () => {
|
||||
const action = notificationActions.notifyInfo({ message: 'test' })
|
||||
deepEqual(action, { type: 'NOTIFY_INFO', payload: { type: 'info', message: 'test' } })
|
||||
})
|
||||
|
||||
test('notifyError creates action NOTIFY_ERROR with type "error" and payload', () => {
|
||||
const action = notificationActions.notifyError({ message: 'test' })
|
||||
deepEqual(action, { type: 'NOTIFY_ERROR', payload: { type: 'error', message: 'test' } })
|
||||
})
|
||||
|
||||
test('clearNotification creates action CLEAR_NOTIFICATION', () => {
|
||||
const action = notificationActions.clearNotification()
|
||||
deepEqual(action, { type: 'CLEAR_NOTIFICATION' })
|
||||
})
|
||||
|
||||
QUnit.module('reduceNotifications')
|
||||
|
||||
test('catches any action with err and message and treats it as an error notification', () => {
|
||||
const action = { type: '_NOT_A_REAL_ACTION_', payload: { message: 'hello world', err: 'bad things happened' } }
|
||||
const newState = reduceNotifications([], action)
|
||||
equal(newState.length, 1)
|
||||
equal(newState[0].type, 'error')
|
||||
equal(newState[0].message, 'hello world')
|
||||
equal(newState[0].err, 'bad things happened')
|
||||
})
|
||||
|
||||
test('adds new info notification on NOTIFY_INFO', () => {
|
||||
const newState = reduceNotifications([], notificationActions.notifyInfo({ message: 'hello world' }))
|
||||
equal(newState.length, 1)
|
||||
equal(newState[0].type, 'info')
|
||||
equal(newState[0].message, 'hello world')
|
||||
})
|
||||
|
||||
test('adds new error notification on NOTIFY_ERROR', () => {
|
||||
const newState = reduceNotifications([], notificationActions.notifyError({ message: 'hello world', err: 'bad things happened' }))
|
||||
equal(newState.length, 1)
|
||||
equal(newState[0].type, 'error')
|
||||
equal(newState[0].message, 'hello world')
|
||||
equal(newState[0].err, 'bad things happened')
|
||||
})
|
||||
|
||||
test('removes notification on CLEAR_NOTIFICATION', () => {
|
||||
const newState = reduceNotifications([
|
||||
{ id: '1', message: 'hello world', type: 'info' }
|
||||
], notificationActions.clearNotification('1'))
|
||||
equal(newState.length, 0)
|
||||
})
|
|
@ -0,0 +1,338 @@
|
|||
/*
|
||||
* Copyright (C) 2017 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createPaginatedReducer, createPaginationActions, selectPaginationState, LoadStates } from 'jsx/shared/reduxPagination'
|
||||
|
||||
const createMockStore = state => ({
|
||||
subs: [],
|
||||
subscribe (cb) { this.subs.push(cb) },
|
||||
getState: () => state,
|
||||
dispatch: () => {},
|
||||
mockStateChange () { this.subs.forEach(sub => sub()) },
|
||||
})
|
||||
|
||||
QUnit.module('Redux Pagination')
|
||||
|
||||
QUnit.module('createPaginationActions')
|
||||
|
||||
test('creates proper actionTypes', () => {
|
||||
const { actionTypes } = createPaginationActions('things')
|
||||
deepEqual(actionTypes, ['SELECT_THINGS_PAGE', 'GET_THINGS_START', 'GET_THINGS_SUCCESS', 'GET_THINGS_FAIL'])
|
||||
})
|
||||
|
||||
test('creates get action creator', () => {
|
||||
const { actionCreators } = createPaginationActions('things')
|
||||
equal(typeof actionCreators.getThings, 'function')
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that calls start with current page', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 3,
|
||||
pages: { 3: { item: [], loadState: LoadStates.NOT_LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, _reject) => resolve({ data: [] })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings()(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 2)
|
||||
deepEqual(dispatchSpy.firstCall.args, [{ type: 'GET_THINGS_START', payload: { page: 3 } }])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that calls start with a payload page', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: { 1: { item: [], loadState: LoadStates.NOT_LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, _reject) => resolve({ data: [] })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings({ page: 5 })(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 2)
|
||||
deepEqual(dispatchSpy.firstCall.args, [{ type: 'GET_THINGS_START', payload: { page: 5 } }])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that calls success', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: { 1: { item: [], loadState: LoadStates.NOT_LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, _reject) => resolve({ data: ['item1'] })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings({ page: 5 })(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 2)
|
||||
deepEqual(dispatchSpy.secondCall.args, [{ type: 'GET_THINGS_SUCCESS', payload: { page: 5, data: ['item1'] } }])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that calls success with lastPage is provided in response link header', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: { 1: { item: [], loadState: LoadStates.NOT_LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, _reject) => resolve({ data: ['item1'], headers: { link: '<http://canvas.example.com/api/v1/someendpoint&page=5&per_page=50>; rel="last"' } })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings()(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 2)
|
||||
deepEqual(dispatchSpy.secondCall.args, [{ type: 'GET_THINGS_SUCCESS', payload: { page: 1, data: ['item1'], lastPage: 5 } }])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that calls fail', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: { 1: { item: [], loadState: LoadStates.NOT_LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, reject) => reject({ message: 'oops error' })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings({ page: 5 })(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 2)
|
||||
deepEqual(dispatchSpy.secondCall.args, [{ type: 'GET_THINGS_FAIL', payload: { page: 5, message: 'oops error' } }])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that does not call thunk if page is already loaded', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: { 1: { item: [], loadState: LoadStates.LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, _reject) => resolve({ data: ['item1'] })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings()(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 0)
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that calls thunk if page is already loaded and forgetGet is true', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: { 1: { item: [], loadState: LoadStates.LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, _reject) => resolve({ data: ['item1'] })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings({ forceGet: true })(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 2)
|
||||
deepEqual(dispatchSpy.secondCall.args, [{ type: 'GET_THINGS_SUCCESS', payload: { page: 1, data: ['item1'] } }])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
test('creates get action creator from thunk that selects the page if select is true', (assert) => {
|
||||
const done = assert.async()
|
||||
const mockStore = createMockStore({
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: { 1: { item: [], loadState: LoadStates.LOADED } }
|
||||
},
|
||||
})
|
||||
const dispatchSpy = sinon.spy(mockStore, 'dispatch')
|
||||
const thunk = () => (resolve, _reject) => resolve({ data: ['item1'] })
|
||||
const { actionCreators } = createPaginationActions('things', thunk)
|
||||
actionCreators.getThings({ select: true, page: 5 })(mockStore.dispatch, mockStore.getState)
|
||||
setTimeout(() => {
|
||||
equal(dispatchSpy.callCount, 3)
|
||||
deepEqual(dispatchSpy.firstCall.args, [{ type: 'SELECT_THINGS_PAGE', payload: { page: 5 } }])
|
||||
dispatchSpy.restore()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('createPaginatedReducer')
|
||||
|
||||
test('sets current page on SELECT_PAGE', () => {
|
||||
const state = {
|
||||
currentPage: 1,
|
||||
pages: {
|
||||
1: {
|
||||
items: [],
|
||||
loadState: LoadStates.NOT_LOADED,
|
||||
},
|
||||
},
|
||||
}
|
||||
const reduce = createPaginatedReducer('things')
|
||||
const action = { type: 'SELECT_THINGS_PAGE', payload: { page: 5 } }
|
||||
const newState = reduce(state, action)
|
||||
equal(newState.currentPage, 5)
|
||||
})
|
||||
|
||||
test('sets last page on GET_SUCCESS', () => {
|
||||
const state = {
|
||||
currentPage: 1,
|
||||
pages: {
|
||||
1: {
|
||||
items: [],
|
||||
loadState: LoadStates.NOT_LOADED,
|
||||
},
|
||||
},
|
||||
}
|
||||
const reduce = createPaginatedReducer('things')
|
||||
const action = { type: 'GET_THINGS_SUCCESS', payload: { lastPage: 5, page: 1, data: ['item1'] } }
|
||||
const newState = reduce(state, action)
|
||||
equal(newState.lastPage, 5)
|
||||
})
|
||||
|
||||
test('sets items for page on GET_SUCCESS', () => {
|
||||
const state = {
|
||||
currentPage: 1,
|
||||
pages: {
|
||||
1: {
|
||||
items: [],
|
||||
loadState: LoadStates.NOT_LOADED,
|
||||
},
|
||||
},
|
||||
}
|
||||
const reduce = createPaginatedReducer('things')
|
||||
const action = { type: 'GET_THINGS_SUCCESS', payload: { page: 1, data: ['item1'] } }
|
||||
const newState = reduce(state, action)
|
||||
deepEqual(newState.pages[1].items, ['item1'])
|
||||
})
|
||||
|
||||
test('sets loadState for page to LOADING on GET_START', () => {
|
||||
const state = {
|
||||
currentPage: 1,
|
||||
pages: {
|
||||
1: {
|
||||
items: [],
|
||||
loadState: LoadStates.NOT_LOADED,
|
||||
},
|
||||
},
|
||||
}
|
||||
const reduce = createPaginatedReducer('things')
|
||||
const action = { type: 'GET_THINGS_START', payload: { page: 1 } }
|
||||
const newState = reduce(state, action)
|
||||
equal(newState.pages[1].loadState, LoadStates.LOADING)
|
||||
})
|
||||
|
||||
test('sets loadState for page to LOADED on GET_SUCCESS', () => {
|
||||
const state = {
|
||||
currentPage: 1,
|
||||
pages: {
|
||||
1: {
|
||||
items: [],
|
||||
loadState: LoadStates.LOADING,
|
||||
},
|
||||
},
|
||||
}
|
||||
const reduce = createPaginatedReducer('things')
|
||||
const action = { type: 'GET_THINGS_SUCCESS', payload: { page: 1, data: [] } }
|
||||
const newState = reduce(state, action)
|
||||
equal(newState.pages[1].loadState, LoadStates.LOADED)
|
||||
})
|
||||
|
||||
test('sets loadState for page to ERRORED on GET_FAIL', () => {
|
||||
const state = {
|
||||
currentPage: 1,
|
||||
pages: {
|
||||
1: {
|
||||
items: [],
|
||||
loadState: LoadStates.LOADING,
|
||||
},
|
||||
},
|
||||
}
|
||||
const reduce = createPaginatedReducer('things')
|
||||
const action = { type: 'GET_THINGS_FAIL', payload: { page: 1, message: 'oops error' } }
|
||||
const newState = reduce(state, action)
|
||||
equal(newState.pages[1].loadState, LoadStates.ERRORED)
|
||||
})
|
||||
|
||||
QUnit.module('selectPaginationState')
|
||||
|
||||
test('derives state for existing page', () => {
|
||||
const state = {
|
||||
things: {
|
||||
currentPage: 1,
|
||||
pages: {
|
||||
1: {
|
||||
items: ['item1'],
|
||||
loadState: LoadStates.LOADING,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const derivedState = selectPaginationState(state, 'things')
|
||||
deepEqual(derivedState.things, ['item1'])
|
||||
deepEqual(derivedState.thingsPage, 1)
|
||||
deepEqual(derivedState.isLoadingThings, true)
|
||||
deepEqual(derivedState.hasLoadedThings, false)
|
||||
})
|
||||
|
||||
test('derives state for not yet existing page', () => {
|
||||
const state = {
|
||||
things: {
|
||||
currentPage: 5,
|
||||
pages: {
|
||||
1: {
|
||||
items: ['item1'],
|
||||
loadState: LoadStates.LOADING,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const derivedState = selectPaginationState(state, 'things')
|
||||
deepEqual(derivedState.things, [])
|
||||
deepEqual(derivedState.thingsPage, 5)
|
||||
deepEqual(derivedState.isLoadingThings, false)
|
||||
deepEqual(derivedState.hasLoadedThings, false)
|
||||
})
|
|
@ -0,0 +1,46 @@
|
|||
#
|
||||
# Copyright (C) 2017 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
require_relative '../common'
|
||||
|
||||
describe "announcements index v2" do
|
||||
include_context "in-process server selenium tests"
|
||||
let(:url) { "/courses/#{@course.id}/announcements/" }
|
||||
|
||||
context "announcements as a teacher" do
|
||||
before :once do
|
||||
@teacher = user_with_pseudonym(active_user: true)
|
||||
course_with_teacher(user: @teacher, active_course: true, active_enrollment: true)
|
||||
end
|
||||
|
||||
before :each do
|
||||
user_session(@teacher)
|
||||
end
|
||||
|
||||
it 'should display the old announcements if the feature flas is off' do
|
||||
@course.account.set_feature_flag! :section_specific_announcements, 'off'
|
||||
get url
|
||||
expect(f('#external_feed_url')).not_to be_nil
|
||||
end
|
||||
|
||||
it 'should display the new announcements if the feature flas is on' do
|
||||
@course.account.set_feature_flag! :section_specific_announcements, 'on'
|
||||
get url
|
||||
expect(f('.announcements-v2__wrapper')).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue