Fetch K-5 dashboard announcements and apps in batches

Updates the latest announcement-fetching logic for the K-5 dashboard
homeroom page to retrieve all announcements in one request. A new
"latest_only" parameter was added to the announcements API in order to
support this.

Also updates the dashboard_card logic for the K-5 dashboard to not
show any info associated with courses with outstanding invitations.
This means that if a user has not accepted an invite to be enrolled in
a course, that course should not show up anywhere on the K-5
dashboard.

fixes LS-2227
flag = canvas_for_elementary

Test plan:
  - Log in as a teacher enrolled in a homeroom course and subject
    course
  - Create announcements for both of those courses (if recent
    announcements don't already exist for them)
  - Expect the homeroom announcement to show up at the top of the
    teacher's dashboard (like it did before this change)
  - Expect the subject announement to show up on the bottom of the
    subject course card on the dashboard (like before)
  - Log in as a student enrolled in the same courses and ensure that
    both announcements show up as expected on their dashboard too

  - Invite a student or teacher to a L-5 subject course that they're
    not already in
  - Log in as the invited user and go to their dashboard, but do not
    accept the invite yet
  - Except to not see cards, LTIs, grades, or planner items associated
    with that course

Change-Id: I8fae07ec101ff34811f42e05e1f1845e76f482b7
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/265234
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jackson Howe <jackson.howe@instructure.com>
QA-Review: Jackson Howe <jackson.howe@instructure.com>
Product-Review: Jeff Largent <jeff.largent@instructure.com>
This commit is contained in:
Jeff Largent 2021-05-18 17:28:06 -04:00
parent ed479b96a2
commit 7d71e6dc8f
13 changed files with 265 additions and 99 deletions

View File

@ -52,6 +52,11 @@ class AnnouncementsApiController < ApplicationController
# unpublished items.
# Defaults to false for users with access to view unpublished items,
# otherwise true and unmodifiable.
# @argument latest_only [Optional, Boolean]
# Only return the latest announcement for each associated context.
# The response will include at most one announcement for each
# specified context in the context_codes[] parameter.
# Defaults to false.
#
# @argument include [Optional, array]
# Optional list of resources to include with the response. May include
@ -97,7 +102,12 @@ class AnnouncementsApiController < ApplicationController
@start_date ||= 14.days.ago.beginning_of_day
@end_date ||= @start_date + 28.days
scope = scope.ordered_between(@start_date, @end_date)
if value_to_boolean(params[:latest_only])
scope = scope.ordered_between_by_context(@start_date, @end_date)
scope = scope.select("DISTINCT ON (context_id) *")
else
scope = scope.ordered_between(@start_date, @end_date)
end
# only filter by section visibility if user has no course manage rights
skip_section_filtering = courses.all? do |course|

View File

@ -37,9 +37,16 @@ class Announcement < DiscussionTopic
acts_as_list scope: { context: self, type: 'Announcement' }
scope :ordered_between, lambda { |start_date, end_date|
scope :between , lambda { |start_date, end_date|
where('COALESCE(delayed_post_at, posted_at, created_at) BETWEEN ? AND ?', start_date, end_date)
.order(Arel.sql('COALESCE(delayed_post_at, posted_at, created_at) DESC'))
}
scope :ordered_between, lambda { |start_date, end_date|
between(start_date, end_date).order(Arel.sql("COALESCE(delayed_post_at, posted_at, created_at) DESC"))
}
scope :ordered_between_by_context, lambda { |start_date, end_date|
between(start_date, end_date).order(Arel.sql("context_id, COALESCE(delayed_post_at, posted_at, created_at) DESC"))
}
def validate_draft_state_change

View File

@ -51,6 +51,7 @@ class CourseForMenuPresenter
href: course_path(course, invitation: course.read_attribute(:invitation)),
term: term || nil,
subtitle: subtitle,
enrollmentState: course.primary_enrollment_state,
enrollmentType: course.primary_enrollment_type,
observee: observee,
id: course.id,

View File

@ -161,6 +161,50 @@ describe "Announcements API", type: :request do
expect(json).to be_empty
end
end
describe "latest_only" do
before :once do
course_with_teacher :active_all => true, :user => @teacher
student_in_course :active_enrollment => true, :user => @student
@course3 = @course
@ann3 = @course3.announcements.build :title => "Announcement New", :message => '<p>This is the latest</p>'
@ann3.posted_at = 2.days.ago
@ann3.save!
old = @course3.announcements.build :title => "Announcement Old", :message => '<p>This is older</p>'
old.posted_at = 5.days.ago
old.save!
end
let(:start_date) { 10.days.ago.iso8601 }
let(:end_date) { 30.days.from_now.iso8601 }
it "only returns the latest announcement by posted date" do
json = api_call_as_user(@teacher, :get, "/api/v1/announcements",
@params.merge(:context_codes => %W[course_#{@course1.id} course_#{@course2.id} course_#{@course3.id}],
start_date => start_date, :end_date => end_date, :latest_only => true))
expect(json.length).to be 3
expect(json.map{|a| a['id']}).to include(@anns.last[:id], @ann2[:id], @ann3[:id])
end
it "excludes courses not in the context_ids list" do
json = api_call_as_user(@teacher, :get, "/api/v1/announcements",
@params.merge(:context_codes => %W[course_#{@course1.id} course_#{@course3.id}],
start_date => start_date, :end_date => end_date, :latest_only => true))
expect(json.length).to be 2
expect(json.map{|a| a['id']}).to include(@anns.last[:id], @ann3[:id])
end
it "works properly in conjunction with the active_only param" do
json = api_call_as_user(@teacher, :get, "/api/v1/announcements",
@params.merge(:context_codes => %W[course_#{@course1.id} course_#{@course2.id} course_#{@course3.id}],
start_date => start_date, :end_date => end_date, :active_only => true, :latest_only => true))
expect(json.length).to be 2
expect(json.map{|a| a['id']}).to include(@anns.last[:id], @ann3[:id])
end
end
end
context "as student" do

View File

@ -32,28 +32,19 @@ import {Img} from '@instructure/ui-img'
import K5DashboardCard, {CARD_SIZE_PX} from './K5DashboardCard'
import {createDashboardCards} from '@canvas/dashboard-card'
import {fetchLatestAnnouncement, parseAnnouncementDetails} from '@canvas/k5/react/utils'
import HomeroomAnnouncementsLayout from './HomeroomAnnouncementsLayout'
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
import LoadingSkeleton from '@canvas/k5/react/LoadingSkeleton'
import {CreateCourseModal} from './CreateCourseModal'
import EmptyDashPandaUrl from '../images/empty-dashboard.svg'
export const fetchHomeroomAnnouncements = cards =>
Promise.all(
cards
.filter(c => c.isHomeroom)
.map(course =>
fetchLatestAnnouncement(course.id).then(announcement =>
parseAnnouncementDetails(announcement, course)
)
)
).then(announcements => announcements.filter(a => a))
export const HomeroomPage = ({cards, cardsSettled, visible, createPermissions}) => {
export const HomeroomPage = ({
cards,
createPermissions,
homeroomAnnouncements,
loadingAnnouncements,
visible
}) => {
const [dashboardCards, setDashboardCards] = useState([])
const [homeroomAnnouncements, setHomeroomAnnouncements] = useState([])
const [announcementsLoading, setAnnouncementsLoading] = useState(true)
const [courseModalOpen, setCourseModalOpen] = useState(false)
useImmediate(
@ -64,19 +55,10 @@ export const HomeroomPage = ({cards, cardsSettled, visible, createPermissions})
headingLevel: 'h3'
})
)
if (cardsSettled) {
setAnnouncementsLoading(true)
fetchHomeroomAnnouncements(cards)
.then(setHomeroomAnnouncements)
.catch(showFlashError(I18n.t('Failed to load announcements.')))
.finally(() => setAnnouncementsLoading(false))
}
}
},
[cards, cardsSettled],
[cards],
// Need to do deep comparison on cards to only re-trigger if they actually changed
// (they shouldn't after they're set the first time)
{deep: true}
)
@ -108,7 +90,7 @@ export const HomeroomPage = ({cards, cardsSettled, visible, createPermissions})
<View as="section">
<HomeroomAnnouncementsLayout
homeroomAnnouncements={homeroomAnnouncements}
loading={announcementsLoading}
loading={loadingAnnouncements}
/>
</View>
<View as="section">
@ -158,9 +140,10 @@ export const HomeroomPage = ({cards, cardsSettled, visible, createPermissions})
HomeroomPage.propTypes = {
cards: PropTypes.array,
cardsSettled: PropTypes.bool.isRequired,
visible: PropTypes.bool.isRequired,
createPermissions: PropTypes.oneOf(['admin', 'teacher', 'none']).isRequired
createPermissions: PropTypes.oneOf(['admin', 'teacher', 'none']).isRequired,
homeroomAnnouncements: PropTypes.array.isRequired,
loadingAnnouncements: PropTypes.bool.isRequired,
visible: PropTypes.bool.isRequired
}
export default HomeroomPage

View File

@ -15,11 +15,10 @@
* 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, {useEffect, useState} from 'react'
import React, {useCallback, useEffect, useState} from 'react'
import {connect, Provider} from 'react-redux'
import I18n from 'i18n!k5_dashboard'
import PropTypes from 'prop-types'
import {Heading} from '@instructure/ui-heading'
import {
createTeacherPreview,
@ -29,6 +28,7 @@ import {
store,
toggleMissingItems
} from '@instructure/canvas-planner'
import {Heading} from '@instructure/ui-heading'
import {
IconBankLine,
IconCalendarMonthLine,
@ -46,10 +46,12 @@ import loadCardDashboard from '@canvas/dashboard-card'
import {mapStateToProps} from '@canvas/k5/redux/redux-helpers'
import SchedulePage from '@canvas/k5/react/SchedulePage'
import ResourcesPage from '@canvas/k5/react/ResourcesPage'
import {FOCUS_TARGETS, TAB_IDS} from '@canvas/k5/react/utils'
import {groupAnnouncementsByHomeroom, FOCUS_TARGETS, TAB_IDS} from '@canvas/k5/react/utils'
import {theme} from '@canvas/k5/react/k5-theme'
import useTabState from '@canvas/k5/react/hooks/useTabState'
import usePlanner from '@canvas/k5/react/hooks/usePlanner'
import useFetchApi from '@canvas/use-fetch-api-hook'
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
const DASHBOARD_TABS = [
{
@ -92,6 +94,9 @@ export const K5Dashboard = ({
const {activeTab, currentTab, handleTabChange} = useTabState(defaultTab, DASHBOARD_TABS)
const [cards, setCards] = useState(null)
const [cardsSettled, setCardsSettled] = useState(false)
const [homeroomAnnouncements, setHomeroomAnnouncements] = useState([])
const [subjectAnnouncements, setSubjectAnnouncements] = useState([])
const [loadingAnnouncements, setLoadingAnnouncements] = useState(true)
const [tabsRef, setTabsRef] = useState(null)
const plannerInitialized = usePlanner({
plannerEnabled,
@ -103,12 +108,38 @@ export const K5Dashboard = ({
useEffect(() => {
if (!cards && (currentTab === TAB_IDS.HOMEROOM || currentTab === TAB_IDS.RESOURCES)) {
loadCardDashboard((dc, cardsFinishedLoading) => {
setCards(dc)
setCards(dc.filter(({enrollmentState}) => enrollmentState !== 'invited'))
setCardsSettled(cardsFinishedLoading)
})
}
}, [cards, currentTab])
useFetchApi({
path: '/api/v1/announcements',
loading: setLoadingAnnouncements,
success: useCallback(
data => {
if (data) {
const groupedAnnouncements = groupAnnouncementsByHomeroom(data, cards)
setHomeroomAnnouncements(groupedAnnouncements.true)
setSubjectAnnouncements(groupedAnnouncements.false)
}
},
[cards]
),
error: useCallback(showFlashError(I18n.t('Failed to load announcements.')), []),
// This is a bit hacky, but we need to wait to fetch the announcements until the cards have
// settled. Setting forceResult skips the fetch until it changes to undefined.
forceResult: cardsSettled ? undefined : false,
fetchAllPages: true,
params: {
active_only: true,
context_codes: cards && cards.map(({id}) => `course_${id}`),
latest_only: true,
per_page: '100'
}
})
const handleSwitchToToday = () => {
handleTabChange(TAB_IDS.SCHEDULE, FOCUS_TARGETS.TODAY)
switchToToday()
@ -133,10 +164,11 @@ export const K5Dashboard = ({
assignmentsDueToday,
assignmentsMissing,
assignmentsCompletedForToday,
cardsSettled,
loadingAnnouncements,
loadingOpportunities,
isStudent: plannerEnabled,
responsiveSize,
subjectAnnouncements,
switchToMissingItems: handleSwitchToMissingItems,
switchToToday: handleSwitchToToday
}}
@ -153,11 +185,10 @@ export const K5Dashboard = ({
)}
<HomeroomPage
cards={cards}
cardsSettled={cardsSettled}
isStudent={plannerEnabled}
responsiveSize={responsiveSize}
visible={currentTab === TAB_IDS.HOMEROOM}
createPermissions={createPermissions}
homeroomAnnouncements={homeroomAnnouncements}
loadingAnnouncements={loadingAnnouncements}
visible={currentTab === TAB_IDS.HOMEROOM}
/>
{plannerInitialized && <SchedulePage visible={currentTab === TAB_IDS.SCHEDULE} />}
{!plannerEnabled && currentTab === TAB_IDS.SCHEDULE && createTeacherPreview(timeZone)}

View File

@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {useContext, useState} from 'react'
import React, {useContext} from 'react'
import PropTypes from 'prop-types'
import I18n from 'i18n!k5_dashboard'
@ -28,13 +28,11 @@ import {Link} from '@instructure/ui-link'
import {Text} from '@instructure/ui-text'
import {TruncateText} from '@instructure/ui-truncate-text'
import {View} from '@instructure/ui-view'
import {showFlashError} from '@canvas/alerts/react/FlashAlert'
import LoadingSkeleton from '@canvas/k5/react/LoadingSkeleton'
import useImmediate from '@canvas/use-immediate-hook'
import k5Theme from '@canvas/k5/react/k5-theme'
import K5DashboardContext from '@canvas/k5/react/K5DashboardContext'
import {fetchLatestAnnouncement, DEFAULT_COURSE_COLOR, FOCUS_TARGETS} from '@canvas/k5/react/utils'
import {DEFAULT_COURSE_COLOR, FOCUS_TARGETS} from '@canvas/k5/react/utils'
import instFSOptimizedImageUrl from '@canvas/dashboard-card/util/instFSOptimizedImageUrl'
@ -240,8 +238,6 @@ const K5DashboardCard = ({
image,
isDragging = false
}) => {
const [latestAnnouncement, setLatestAnnouncement] = useState(null)
const [loadingAnnouncement, setLoadingAnnouncement] = useState(false)
const backgroundColor = courseColor || DEFAULT_COURSE_COLOR
const k5Context = useContext(K5DashboardContext)
@ -251,24 +247,15 @@ const K5DashboardCard = ({
(k5Context?.assignmentsMissing && k5Context.assignmentsMissing[id]) || 0
const assignmentsCompletedForToday =
(k5Context?.assignmentsCompletedForToday && k5Context.assignmentsCompletedForToday[id]) || 0
const cardsSettled = k5Context.cardsSettled || false
const loadingAnnouncements = k5Context?.loadingAnnouncements || false
const loadingOpportunities = k5Context?.loadingOpportunities || false
const isStudent = k5Context?.isStudent || false
const latestAnnouncement = k5Context.subjectAnnouncements.find(
a => a.context_code === `course_${id}`
)
const switchToMissingItems = k5Context?.switchToMissingItems
const switchToToday = k5Context?.switchToToday
useImmediate(() => {
if (cardsSettled) {
setLoadingAnnouncement(true)
fetchLatestAnnouncement(id)
.then(setLatestAnnouncement)
.catch(
showFlashError(I18n.t('Failed to load announcement for %{originalName}.', {originalName}))
)
.finally(() => setLoadingAnnouncement(false))
}
}, [cardsSettled, id, originalName])
const handleHeaderClick = e => {
if (e) {
e.preventDefault()
@ -344,7 +331,7 @@ const K5DashboardCard = ({
)}
<LatestAnnouncementLink
color={backgroundColor}
loading={loadingAnnouncement}
loading={loadingAnnouncements}
{...latestAnnouncement}
/>
</View>

View File

@ -36,7 +36,7 @@ describe('HomeroomPage', () => {
})
it('shows loading skeletons while loading for announcements and cards', () => {
const {getAllByText, getByText} = render(<HomeroomPage {...getProps()} />)
const {getAllByText, getByText} = render(<HomeroomPage {...getProps()} loadingAnnouncements />)
const cards = getAllByText('Loading Card')
expect(cards[0]).toBeInTheDocument()
expect(getByText('Loading Homeroom Announcement Content')).toBeInTheDocument()

View File

@ -44,6 +44,7 @@ const dashboardCards = [
shortName: 'Econ 101',
originalName: 'Economics 101',
courseCode: 'ECON-001',
enrollmentState: 'active',
isHomeroom: false,
canManage: true,
published: true
@ -55,13 +56,27 @@ const dashboardCards = [
shortName: 'Homeroom1',
originalName: 'Home Room',
courseCode: 'HOME-001',
enrollmentState: 'active',
isHomeroom: true,
canManage: true,
published: false
},
{
id: '3',
assetString: 'course_3',
href: '/courses/3',
originalName: 'The Maths',
courseCode: 'DA-MATHS',
enrollmentState: 'invited',
isHomeroom: false,
canManage: true,
published: true
}
]
const homeroomAnnouncement = [
const announcements = [
{
id: '20',
context_code: 'course_2',
title: 'Announcement here',
message: '<p>This is the announcement</p>',
html_url: 'http://google.com/announcement',
@ -75,6 +90,13 @@ const homeroomAnnouncement = [
filename: '1608134586_366__exam1.pdf'
}
]
},
{
id: '21',
context_code: 'course_1',
title: "This sure isn't a homeroom",
message: '<p>Definitely not!</p>',
html_url: '/courses/1/announcements/21'
}
]
const gradeCourses = [
@ -178,6 +200,7 @@ const defaultEnv = {
}
const defaultProps = {
currentUser,
createPermissions: 'none',
plannerEnabled: false,
loadAllOpportunities: () => {},
timeZone: defaultEnv.TIMEZONE
@ -278,11 +301,7 @@ beforeEach(() => {
response: opportunities
})
fetchMock.get('/api/v1/courses/1/activity_stream/summary', JSON.stringify(cardSummary))
fetchMock.get(
/\/api\/v1\/announcements\?context_codes=course_2.*/,
JSON.stringify(homeroomAnnouncement)
)
fetchMock.get(/\/api\/v1\/announcements\?context_codes=course_1.*/, '[]')
fetchMock.get(/\/api\/v1\/announcements.*/, announcements)
fetchMock.get(/\/api\/v1\/users\/self\/courses.*/, JSON.stringify(gradeCourses))
fetchMock.get(encodeURI('api/v1/courses/2?include[]=syllabus_body'), JSON.stringify(syllabus))
fetchMock.get('/api/v1/courses/1/external_tools/visible_course_nav_tools', JSON.stringify(apps))
@ -358,10 +377,11 @@ describe('K-5 Dashboard', () => {
expect(await findByText('My Subjects')).toBeInTheDocument()
})
it('shows course cards, excluding homerooms', async () => {
it('shows course cards, excluding homerooms and subjects with pending invites', async () => {
const {findByText, queryByText} = render(<K5Dashboard {...defaultProps} />)
expect(await findByText('Economics 101')).toBeInTheDocument()
expect(queryByText('Home Room')).not.toBeInTheDocument()
expect(queryByText('The Maths')).not.toBeInTheDocument()
})
it('shows latest announcement from each homeroom', async () => {
@ -390,6 +410,13 @@ describe('K-5 Dashboard', () => {
// correct element is focused since that occurs at the end of the scrolling animation
})
it('shows the latest announcement for each subject course if one exists', async () => {
const {findByText} = render(<K5Dashboard {...defaultProps} />)
const announcementLink = await findByText("This sure isn't a homeroom")
expect(announcementLink).toBeInTheDocument()
expect(announcementLink.closest('a').href).toMatch('/courses/1/announcements/21')
})
it('shows a missing items link pointing to the missing items section on the schedule tab', async () => {
const {findByText, getByRole, getByText} = render(
<K5Dashboard {...defaultProps} plannerEnabled />
@ -431,8 +458,8 @@ describe('K-5 Dashboard', () => {
response: dashboardCards
})
.then(() => {
// Expect one announcement request for each card
expect(fetchMock.calls(/\/api\/v1\/announcements.*/).length).toBe(2)
// Expect just one announcement request for all cards
expect(fetchMock.calls(/\/api\/v1\/announcements.*/).length).toBe(1)
// Expect one LTI request for each non-homeroom card
expect(
fetchMock.calls('/api/v1/courses/1/external_tools/visible_course_nav_tools').length

View File

@ -17,7 +17,6 @@
*/
import React from 'react'
import fetchMock from 'fetch-mock'
import {render} from '@testing-library/react'
import K5DashboardCard, {
DashboardCardHeaderHero,
@ -30,27 +29,20 @@ const defaultContext = {
assignmentsDueToday: {},
assignmentsMissing: {},
assignmentsCompletedForToday: {},
cardsSettled: true,
loadingAnnouncements: false,
loadingOpportunities: false,
isStudent: true,
subjectAnnouncements: [],
switchToMissingItems: jest.fn(),
switchToToday: jest.fn()
}
const defaultProps = {
id: 'test',
href: '/courses/5',
href: '/courses/test',
originalName: 'test course'
}
beforeEach(() => {
fetchMock.get('/api/v1/announcements?context_codes=course_test&active_only=true&per_page=1', '[]')
})
afterEach(() => {
fetchMock.restore()
jest.clearAllMocks()
})
describe('DashboardCardHeaderHero', () => {
const heroProps = {
backgroundColor: '#FFFFFF',
@ -92,19 +84,16 @@ describe('K-5 Dashboard Card', () => {
})
it('displays a link to the latest announcement if one exists', async () => {
fetchMock.get(
'/api/v1/announcements?context_codes=course_test&active_only=true&per_page=1',
JSON.stringify([
{
id: '55',
html_url: '/courses/test/discussion_topics/55',
title: 'How do you do, fellow kids?'
}
]),
{overwriteRoutes: true}
)
const subjectAnnouncements = [
{
id: '55',
context_code: 'course_test',
html_url: '/courses/test/discussion_topics/55',
title: 'How do you do, fellow kids?'
}
]
const {findByText} = render(
<K5DashboardContext.Provider value={defaultContext}>
<K5DashboardContext.Provider value={{...defaultContext, subjectAnnouncements}}>
<K5DashboardCard {...defaultProps} />
</K5DashboardContext.Provider>
)
@ -186,7 +175,14 @@ describe('LatestAnnouncementLink', () => {
describe('AssignmentLinks', () => {
it('renders loading skeleton while loading', () => {
const {getByText, queryByText} = render(
<AssignmentLinks loading color="red" requestTabChange={jest.fn()} numMissing={2} />
<AssignmentLinks
loading
color="red"
courseName="test"
switchToMissingItems={jest.fn()}
switchToToday={jest.fn()}
numMissing={2}
/>
)
expect(getByText('Loading missing assignments link')).toBeInTheDocument()
expect(queryByText('2 missing')).not.toBeInTheDocument()

View File

@ -22,10 +22,11 @@ const DEFAULT_CONTEXT = {
assignmentsDueToday: {},
assignmentsMissing: {},
assignmentsCompletedForToday: {},
cardsSettled: false,
loadingAnnouncements: false,
loadingOpportunities: false,
isStudent: false,
responsiveSize: 'large',
subjectAnnouncements: [],
switchToMissingItems: () => {},
switchToToday: () => {}
}

View File

@ -32,7 +32,8 @@ import {
getAccountsFromEnrollments,
getTotalGradeStringFromEnrollments,
fetchImportantInfos,
parseAnnouncementDetails
parseAnnouncementDetails,
groupAnnouncementsByHomeroom
} from '../utils'
const ANNOUNCEMENT_URL =
@ -634,3 +635,64 @@ describe('parseAnnouncementDetails', () => {
expect(announcementDetails.announcement.attachment).toBeUndefined()
})
})
describe('groupAnnouncementsByHomeroom', () => {
const announcements = [
{
id: '10',
context_code: 'course_1'
},
{
id: '11',
context_code: 'course_2',
permissions: {
update: false
},
attachments: []
},
{
id: '12',
context_code: 'course_3'
}
]
const courses = [
{
id: '1',
isHomeroom: false
},
{
id: '2',
isHomeroom: true
}
]
it('groups returned announcements by whether they are associated with a homeroom or not', () => {
const grouped = groupAnnouncementsByHomeroom(announcements, courses)
expect(Object.keys(grouped)).toEqual(['true', 'false'])
expect(grouped.true).toHaveLength(1)
expect(grouped.false).toHaveLength(1)
expect(grouped.true[0].announcement.id).toBe('11')
expect(grouped.false[0].id).toBe('10')
})
it('parses announcement details on homeroom announcements only', () => {
const grouped = groupAnnouncementsByHomeroom(announcements, courses)
expect(grouped.true[0].courseId).toBe('2')
expect(grouped.false[0].courseId).toBeUndefined()
})
it('ignores announcements not associated with a passed-in course', () => {
const grouped = groupAnnouncementsByHomeroom(announcements, courses)
expect([...grouped.true, ...grouped.false]).toHaveLength(2)
})
it('handles missing announcements and courses gracefully', () => {
const emptyGroups = {true: [], false: []}
expect(groupAnnouncementsByHomeroom([], courses)).toEqual({
true: [{courseId: '2'}],
false: []
})
expect(groupAnnouncementsByHomeroom(announcements, [])).toEqual(emptyGroups)
expect(groupAnnouncementsByHomeroom()).toEqual(emptyGroups)
})
})

View File

@ -278,6 +278,7 @@ export const parseAnnouncementDetails = (announcement, course) => {
canEdit: announcement.permissions.update,
published: course.published,
announcement: {
id: announcement.id,
title: announcement.title,
message: announcement.message,
url: announcement.html_url,
@ -287,6 +288,22 @@ export const parseAnnouncementDetails = (announcement, course) => {
}
}
/* Helper function to take a list of announcements coming back from API
and partition them into homeroom and non-homeroom groups */
export const groupAnnouncementsByHomeroom = (announcements = [], courses = []) =>
courses.reduce(
(acc, course) => {
const announcement = announcements.find(a => a.context_code === `course_${course.id}`)
const group = acc[course.isHomeroom]
const parsedAnnouncement = course.isHomeroom
? parseAnnouncementDetails(announcement, course)
: announcement
if (parsedAnnouncement) acc[course.isHomeroom] = [...group, parsedAnnouncement]
return acc
},
{true: [], false: []}
)
export const TAB_IDS = {
HOME: 'tab-home',
HOMEROOM: 'tab-homeroom',