Skip to content

Commit

Permalink
[Teacher][MBL-8756] Route to module items from module list (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
JordanMarshall authored May 3, 2019
1 parent 2fc434b commit 179adf2
Show file tree
Hide file tree
Showing 26 changed files with 863 additions and 141 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
package com.instructure.teacher.ui.renderTests

import android.graphics.Color
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import com.instructure.canvasapi2.models.Course
import com.instructure.espresso.assertCompletelyDisplayed
import com.instructure.espresso.assertDisplayed
Expand All @@ -30,6 +32,7 @@ import com.instructure.teacher.features.modules.list.ui.ModuleListViewState
import com.instructure.teacher.ui.renderTests.pages.ModuleListRenderPage
import com.instructure.teacher.ui.utils.TeacherRenderTest
import com.spotify.mobius.runners.WorkRunner
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Test

Expand Down Expand Up @@ -333,6 +336,22 @@ class ModuleListRenderTest : TeacherRenderTest() {
page.assertHasItemIndent(item.indent)
}

@Test
fun displaysModuleItemLoadingIndicator() {
val item = moduleItemTemplate.copy(
iconResId = null,
enabled = false,
isLoading = true
)
val state = ModuleListViewState(
items = listOf(item)
)
loadPageWithViewState(state)
page.moduleItemIcon.assertNotDisplayed()
page.moduleItemLoadingView.assertDisplayed()
page.moduleItemRoot.check(matches(not(isEnabled())))
}

private fun loadPageWithViewState(
state: ModuleListViewState,
course: Course = Course(name = "Test Course")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ class ModuleListRenderPage : BasePage(R.id.moduleList) {
val modulePublishedIcon by OnViewWithId(R.id.publishedIcon)
val moduleUnpublishedIcon by OnViewWithId(R.id.unpublishedIcon)

/* Module Item views. Can only be used is there is a single module item present. */
/* Module Item views. Can only be used if there is a single module item present. */
val moduleItemRoot by OnViewWithId(R.id.moduleItemRoot)
val moduleItemIcon by OnViewWithId(R.id.moduleItemIcon)
val moduleItemTitle by OnViewWithId(R.id.moduleItemTitle)
val moduleItemIndent by OnViewWithId(R.id.moduleItemIndent)
val moduleItemSubtitle by OnViewWithId(R.id.moduleItemSubtitle)
val moduleItemPublishedIcon by OnViewWithId(R.id.moduleItemPublishedIcon)
val moduleItemUnpublishedIcon by OnViewWithId(R.id.moduleItemUnpublishedIcon)
val moduleItemLoadingView by OnViewWithId(R.id.moduleItemLoadingView)

fun assertDisplaysToolbarText(title: String) {
onView(allOf(withText(title), withAncestor(R.id.toolbar))).assertDisplayed()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,84 @@
*/
package com.instructure.teacher.features.modules.list

import com.instructure.canvasapi2.CanvasRestAdapter
import com.instructure.canvasapi2.managers.FeaturesManager
import com.instructure.canvasapi2.managers.FileFolderManager
import com.instructure.canvasapi2.managers.ModuleManager
import com.instructure.canvasapi2.models.CanvasContext
import com.instructure.canvasapi2.models.ModuleObject
import com.instructure.canvasapi2.models.*
import com.instructure.canvasapi2.utils.*
import com.instructure.canvasapi2.utils.weave.awaitApi
import com.instructure.canvasapi2.utils.weave.awaitApiResponse
import com.instructure.canvasapi2.utils.weave.awaitOrThrow
import com.instructure.teacher.features.modules.list.ui.ModuleListView
import com.instructure.teacher.mobius.common.ui.EffectHandler
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import retrofit2.Response

class ModuleListEffectHandler : EffectHandler<ModuleListView, ModulesListEvent, ModulesListEffect>() {
class ModuleListEffectHandler : EffectHandler<ModuleListView, ModuleListEvent, ModuleListEffect>() {

override fun accept(effect: ModulesListEffect) {
private var fileInfoJob: Job? = null

override fun accept(effect: ModuleListEffect) {
when (effect) {
is ModulesListEffect.ShowModuleItemDetailView -> view?.routeToModuleItem(effect.moduleItem)
is ModulesListEffect.LoadNextPage -> loadNextPage(
is ModuleListEffect.ShowModuleItemDetailView -> {
view?.routeToModuleItem(effect.moduleItem, effect.canvasContext)
}
is ModuleListEffect.LoadFileInfo -> {
loadFileInfo(effect.item, effect.canvasContext)
}
is ModuleListEffect.LoadNextPage -> loadNextPage(
effect.canvasContext,
effect.pageData,
effect.scrollToItemId
)
is ModulesListEffect.ScrollToItem -> view?.scrollToItem(effect.moduleItemId)
is ModulesListEffect.MarkModuleExpanded -> {
is ModuleListEffect.ScrollToItem -> view?.scrollToItem(effect.moduleItemId)
is ModuleListEffect.MarkModuleExpanded -> {
CollapsedModulesStore.markModuleCollapsed(effect.canvasContext, effect.moduleId, !effect.isExpanded)
}
is ModuleListEffect.UpdateModuleItems -> updateModuleItems(effect.canvasContext, effect.items)
}.exhaustive
}

private fun updateModuleItems(canvasContext: CanvasContext, items: List<ModuleItem>) {
launch {
val ids = items.map { it.id }.toSet()
consumer.accept(ModuleListEvent.ModuleItemLoadStatusChanged(ids, true))
tryOrNull {
val updatedItems = items
.map { item -> ModuleManager.getModuleItemAsync(canvasContext, item.moduleId, item.id, true) }
.mapNotNull { it.await().dataOrNull }
consumer.accept(ModuleListEvent.ReplaceModuleItems(updatedItems))
CanvasRestAdapter.clearCacheUrls("""/modules""")
}
consumer.accept(ModuleListEvent.ModuleItemLoadStatusChanged(ids, false))
}
}

private fun loadFileInfo(item: ModuleItem, canvasContext: CanvasContext) {
fileInfoJob?.cancel()
fileInfoJob = launch {
consumer.accept(ModuleListEvent.ModuleItemLoadStatusChanged(setOf(item.id), true))
tryOrNull {
// Get the file
val file: FileFolder = FileFolderManager.getFileFolderFromUrlAsync(item.url!!, false).awaitOrThrow()

// Get usage rights and licenses, if applicable
val features = FeaturesManager.getEnabledFeaturesForCourseAsync(canvasContext.id, false).awaitOrThrow()

val requiresUsageRights = features.contains("usage_rights_required")
val licenses = if (requiresUsageRights) {
FileFolderManager.getCourseFileLicensesAsync(canvasContext.id).awaitOrThrow()
} else {
emptyList<License>()
}
view?.routeToFile(canvasContext, file, requiresUsageRights, licenses)
}
consumer.accept(ModuleListEvent.ModuleItemLoadStatusChanged(setOf(item.id), false))
}
}

private fun loadNextPage(canvasContext: CanvasContext, lastPageData: ModuleListPageData, scrollToItemId: Long?) {
launch {
try {
Expand All @@ -52,10 +102,11 @@ class ModuleListEffectHandler : EffectHandler<ModuleListView, ModulesListEvent,
} else {
fetchPageData(canvasContext, lastPageData)
}
consumer.accept(ModulesListEvent.PageLoaded(newPageData))
consumer.accept(ModuleListEvent.PageLoaded(newPageData))
} catch (e: Throwable) {
e.printStackTrace()
consumer.accept(
ModulesListEvent.PageLoaded(
ModuleListEvent.PageLoaded(
lastPageData.copy(lastPageResult = DataResult.Fail(Failure.Network(e.message)))
)
)
Expand Down Expand Up @@ -131,3 +182,4 @@ class ModuleListEffectHandler : EffectHandler<ModuleListView, ModulesListEvent,
}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (C) 2019 - present Instructure, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3 of the License.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.instructure.teacher.features.modules.list

import com.instructure.canvasapi2.models.ModuleItem
import com.instructure.teacher.events.*
import com.instructure.teacher.mobius.common.EventBusSource
import org.greenrobot.eventbus.Subscribe

@Suppress("unused")
class ModuleListEventBusSource : EventBusSource<ModuleListEvent>() {

private val subId = ModuleListEventBusSource::class.java.name

@Subscribe(sticky = true)
fun onAssignmentEdited(event: AssignmentUpdatedEvent) {
event.once(subId) { assignmentId -> updateModuleItem("Assignment") { it.contentId == assignmentId } }
}

@Subscribe(sticky = true)
fun onAssignmentDeleted(event: AssignmentDeletedEvent) {
event.once(subId) { assignmentId -> deleteModuleItem("Assignment") { it.contentId == assignmentId } }
}

@Subscribe(sticky = true)
fun onDiscussionUpdate(event: DiscussionUpdatedEvent) {
event.once(subId) { discussion -> updateModuleItem("Discussion") { it.contentId == discussion.id } }
}

@Subscribe(sticky = true)
fun onDiscussionDeleted(event: DiscussionTopicHeaderDeletedEvent) {
event.once(subId) { discussionId -> deleteModuleItem("Discussion") { it.contentId == discussionId } }
}

@Subscribe(sticky = true)
fun onFileUpdated(event: FileFolderUpdatedEvent) {
event.once(subId) { file -> updateModuleItem("File") { it.contentId == file.id } }
}

@Subscribe(sticky = true)
fun onFileDeleted(event: FileFolderDeletedEvent) {
event.once(subId) { file -> deleteModuleItem("File") { it.contentId == file.id } }
}

@Subscribe(sticky = true)
fun onPageUpdated(event : PageUpdatedEvent) {
event.once(subId) { page ->
/* The module API does not expose the page ID in module items, so we must use the page URL to identify
which page was updated. Unfortunately that URL is based on the page name, so if the page name was
updated then we have no way to uniquely identify and update that page. */
updateModuleItem("Page") { it.pageUrl == page.url }
}
}

@Subscribe(sticky = true)
fun onPageDeleted(event : PageDeletedEvent) {
event.once(subId) { page -> deleteModuleItem("Page") { it.pageUrl == page.url } }
}

@Subscribe(sticky = true)
fun onQuizUpdated(event: QuizUpdatedEvent) {
event.once(subId) { quizId -> updateModuleItem("Quiz") { it.contentId == quizId } }
}

private fun updateModuleItem(type: String, predicate: (item: ModuleItem) -> Boolean) {
sendEvent(ModuleListEvent.ItemRefreshRequested(type, predicate))
}

private fun deleteModuleItem(type: String, predicate: (item: ModuleItem) -> Boolean) {
sendEvent(ModuleListEvent.RemoveModuleItems(type, predicate))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,48 @@ import com.instructure.canvasapi2.models.ModuleObject
import com.instructure.canvasapi2.utils.DataResult
import com.instructure.canvasapi2.utils.isValid

sealed class ModulesListEvent {
object PullToRefresh : ModulesListEvent()
object NextPageRequested : ModulesListEvent()
data class ModuleItemClicked(val moduleItemId: Long) : ModulesListEvent()
data class ModuleExpanded(val moduleId: Long, val isExpanded: Boolean) : ModulesListEvent()
data class PageLoaded(val pageData: ModuleListPageData) : ModulesListEvent()
sealed class ModuleListEvent {
object PullToRefresh : ModuleListEvent()
object NextPageRequested : ModuleListEvent()
data class ModuleItemClicked(val moduleItemId: Long) : ModuleListEvent()
data class ModuleExpanded(val moduleId: Long, val isExpanded: Boolean) : ModuleListEvent()
data class PageLoaded(val pageData: ModuleListPageData) : ModuleListEvent()
data class ModuleItemLoadStatusChanged(val moduleItemIds: Set<Long>, val isLoading: Boolean) : ModuleListEvent()
data class ItemRefreshRequested(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent()
data class ReplaceModuleItems(val items: List<ModuleItem>) : ModuleListEvent()
data class RemoveModuleItems(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent()
}

sealed class ModulesListEffect {
data class ShowModuleItemDetailView(val moduleItem: ModuleItem) : ModulesListEffect()
sealed class ModuleListEffect {
data class ShowModuleItemDetailView(
val moduleItem: ModuleItem,
val canvasContext: CanvasContext
) : ModuleListEffect()
data class LoadFileInfo(
val item: ModuleItem,
val canvasContext: CanvasContext
) : ModuleListEffect()
data class LoadNextPage(
val canvasContext: CanvasContext,
val pageData: ModuleListPageData,
val scrollToItemId: Long?
) : ModulesListEffect()
data class ScrollToItem(val moduleItemId: Long) : ModulesListEffect()
) : ModuleListEffect()
data class ScrollToItem(val moduleItemId: Long) : ModuleListEffect()
data class MarkModuleExpanded(
val canvasContext: CanvasContext,
val moduleId: Long,
val isExpanded: Boolean
) : ModulesListEffect()
) : ModuleListEffect()
data class UpdateModuleItems(val canvasContext: CanvasContext, val items: List<ModuleItem>) : ModuleListEffect()
}

data class ModuleListModel(
val course: CanvasContext,
val isLoading: Boolean = false,
val scrollToItemId: Long? = null,
val pageData: ModuleListPageData = ModuleListPageData(),
val modules: List<ModuleObject> = emptyList()
val modules: List<ModuleObject> = emptyList(),
val loadingModuleItemIds: Set<Long> = emptySet()
)

data class ModuleListPageData(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
package com.instructure.teacher.features.modules.list

import android.content.Context
import android.util.TypedValue
import com.instructure.canvasapi2.models.ModuleItem
import com.instructure.canvasapi2.utils.DateHelper
import com.instructure.canvasapi2.utils.tryOrNull
Expand All @@ -39,11 +38,6 @@ object ModuleListPresenter : Presenter<ModuleListModel, ModuleListViewState> {

val courseColor = model.course.color

val selectableBackgroundId = with(TypedValue()) {
context.theme.resolveAttribute(android.R.attr.selectableItemBackground, this, true)
resourceId
}

items += model.modules.map { module ->
val moduleItems = module.items.map { item ->
if (item.type.equals(ModuleItem.Type.SubHeader.name, ignoreCase = true)) {
Expand All @@ -58,7 +52,7 @@ object ModuleListPresenter : Presenter<ModuleListModel, ModuleListViewState> {
enabled = false
)
} else {
createModuleItemData(item, context, indentWidth, courseColor, selectableBackgroundId)
createModuleItemData(item, context, indentWidth, courseColor, item.id in model.loadingModuleItemIds)
}
}
ModuleListItemData.ModuleData(
Expand Down Expand Up @@ -97,7 +91,7 @@ object ModuleListPresenter : Presenter<ModuleListModel, ModuleListViewState> {
context: Context,
indentWidth: Int,
courseColor: Int,
selectableBackgroundId: Int
loading: Boolean
): ModuleListItemData.ModuleItemData {
val subtitle = item.moduleDetails?.dueDate?.let {
context.getString(
Expand All @@ -121,11 +115,12 @@ object ModuleListPresenter : Presenter<ModuleListModel, ModuleListViewState> {
id = item.id,
title = item.title,
subtitle = subtitle,
iconResId = iconRes,
iconResId = iconRes.takeUnless { loading },
isPublished = item.published,
indent = item.indent * indentWidth,
tintColor = courseColor,
enabled = true
enabled = !loading,
isLoading = loading
)
}

Expand Down
Loading

0 comments on commit 179adf2

Please sign in to comment.