From 65ac3095a3fbcbac774cfea0d0f44e2c7c7633a2 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Fri, 19 Jan 2024 17:19:05 +0100 Subject: [PATCH 01/52] [MBL-17276][Teacher] Bulk module update actions (#2305) Test plan: See ticket for design. Test all the publish/unpublish actions. Compare with the web if needed. refs: MBL-17276 affects: Teacher release note: none --- .../ui/renderTests/ModuleListRenderTest.kt | 27 +- .../modules/list/ModuleListEffectHandler.kt | 100 +++++++- .../features/modules/list/ModuleListModels.kt | 28 ++ .../modules/list/ModuleListPresenter.kt | 40 +-- .../features/modules/list/ModuleListUpdate.kt | 88 +++++++ .../modules/list/ui/ModuleListFragment.kt | 68 +++-- .../list/ui/ModuleListMobiusFragment.kt | 59 +++++ .../list/ui/ModuleListRecyclerAdapter.kt | 5 + .../modules/list/ui/ModuleListView.kt | 66 +++++ .../modules/list/ui/ModuleListViewState.kt | 15 +- .../list/ui/binders/ModuleListItemBinder.kt | 45 +++- .../list/ui/binders/ModuleListModuleBinder.kt | 39 ++- .../ui/binders/ModuleListSubHeaderBinder.kt | 65 +++++ .../src/main/res/layout/adapter_module.xml | 124 +++++---- .../main/res/layout/adapter_module_item.xml | 155 +++++++---- .../res/layout/adapter_module_sub_header.xml | 115 +++++++++ .../teacher/src/main/res/menu/menu_module.xml | 39 +++ .../src/main/res/menu/menu_module_list.xml | 39 +++ .../teacher/SingleFragmentTestActivity.kt | 2 + .../list/ModuleListEffectHandlerTest.kt | 78 +++++- .../modules/list/ModuleListPresenterTest.kt | 15 +- .../unit/modules/list/ModuleListUpdateTest.kt | 242 +++++++++++++++++- .../instructure/canvasapi2/apis/ModuleAPI.kt | 7 + .../models/postmodels/BulkUpdateResponse.kt | 29 +++ .../src/main/res/drawable/ic_publish.xml | 13 + .../src/main/res/drawable/ic_unpublish.xml | 10 + libs/pandares/src/main/res/values/strings.xml | 25 ++ .../pandautils/utils/Extensions.kt | 35 ++- 28 files changed, 1391 insertions(+), 182 deletions(-) create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListMobiusFragment.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListSubHeaderBinder.kt create mode 100644 apps/teacher/src/main/res/layout/adapter_module_sub_header.xml create mode 100644 apps/teacher/src/main/res/menu/menu_module.xml create mode 100644 apps/teacher/src/main/res/menu/menu_module_list.xml create mode 100644 libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/postmodels/BulkUpdateResponse.kt create mode 100644 libs/pandares/src/main/res/drawable/ic_publish.xml create mode 100644 libs/pandares/src/main/res/drawable/ic_unpublish.xml diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt index 960d9b2744..968552cf31 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt @@ -51,12 +51,14 @@ class ModuleListRenderTest : TeacherRenderTest() { id = 1L, name = "Module 1", isPublished = true, + isLoading = false, moduleItems = emptyList() ) moduleItemTemplate = ModuleListItemData.ModuleItemData( id = 2L, title = "Assignment Module Item", subtitle = "Due Tomorrow", + subtitle2 = "10 pts", iconResId = R.drawable.ic_assignment, isPublished = true, indent = 0, @@ -86,9 +88,9 @@ class ModuleListRenderTest : TeacherRenderTest() { fun displaysInlineError() { val state = ModuleListViewState( items = listOf( - ModuleListItemData.ModuleData(1, "Module 1", true, emptyList()), - ModuleListItemData.ModuleData(2, "Module 2", true, emptyList()), - ModuleListItemData.ModuleData(3, "Module 3", true, emptyList()), + ModuleListItemData.ModuleData(1, "Module 1", true, emptyList(), false), + ModuleListItemData.ModuleData(2, "Module 2", true, emptyList(), false), + ModuleListItemData.ModuleData(3, "Module 3", true, emptyList(), false), ModuleListItemData.InlineError(Color.BLUE) ) ) @@ -107,7 +109,7 @@ class ModuleListRenderTest : TeacherRenderTest() { @Test fun displaysEmptyModule() { - val module = ModuleListItemData.ModuleData(1, "Module 1", true, emptyList()) + val module = ModuleListItemData.ModuleData(1, "Module 1", true, emptyList(), false) val state = ModuleListViewState( items = listOf(module) ) @@ -128,7 +130,7 @@ class ModuleListRenderTest : TeacherRenderTest() { fun displaysInlineLoadingView() { val state = ModuleListViewState( items = listOf( - ModuleListItemData.ModuleData(1, "Module 1", true, emptyList()), + ModuleListItemData.ModuleData(1, "Module 1", true, emptyList(), false), ModuleListItemData.Loading ) ) @@ -215,13 +217,15 @@ class ModuleListRenderTest : TeacherRenderTest() { id = idx + 2L, title = "Module Item ${idx + 1}", subtitle = null, + subtitle2 = null, iconResId = R.drawable.ic_assignment, isPublished = false, + isLoading = false, indent = 0, tintColor = Color.BLUE, enabled = true ) - } + }, false ) ) ) @@ -308,13 +312,15 @@ class ModuleListRenderTest : TeacherRenderTest() { id = idx + 2L, title = "Module Item ${idx + 1}", subtitle = null, + subtitle2 = null, iconResId = R.drawable.ic_assignment, isPublished = false, + isLoading = false, indent = 0, tintColor = Color.BLUE, enabled = true ) - } + }, false ) ), collapsedModuleIds = setOf(1L) @@ -327,7 +333,7 @@ class ModuleListRenderTest : TeacherRenderTest() { fun scrollsToTargetItem() { val itemCount = 50 val targetItem = ModuleListItemData.ModuleItemData( - 1234L, "This is the target item", null, R.drawable.ic_attachment, false, 0, Color.BLUE, true + 1234L, "This is the target item", null, null, R.drawable.ic_attachment, false, 0, Color.BLUE, true ) val state = ModuleListViewState( items = listOf( @@ -339,10 +345,11 @@ class ModuleListRenderTest : TeacherRenderTest() { } else { moduleItemTemplate.copy( id = idx + 2L, - title = "Module Item ${idx + 1}" + title = "Module Item ${idx + 1}", + isLoading = false ) } - } + }, false ) ) ) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt index 1041a022dc..9ecba0488d 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt @@ -17,10 +17,14 @@ package com.instructure.teacher.features.modules.list import com.instructure.canvasapi2.CanvasRestAdapter +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.ProgressAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.ModuleManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.Progress import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.Failure @@ -29,27 +33,48 @@ import com.instructure.canvasapi2.utils.isValid import com.instructure.canvasapi2.utils.tryOrNull import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.awaitApiResponse +import com.instructure.pandautils.utils.poll +import com.instructure.pandautils.utils.retry import com.instructure.teacher.features.modules.list.ui.ModuleListView import com.instructure.teacher.mobius.common.ui.EffectHandler import kotlinx.coroutines.launch import retrofit2.Response -class ModuleListEffectHandler : EffectHandler() { +class ModuleListEffectHandler( + private val moduleApi: ModuleAPI.ModuleInterface, + private val progressApi: ProgressAPI.ProgressInterface +) : EffectHandler() { override fun accept(effect: ModuleListEffect) { when (effect) { is ModuleListEffect.ShowModuleItemDetailView -> { view?.routeToModuleItem(effect.moduleItem, effect.canvasContext) } + is ModuleListEffect.LoadNextPage -> loadNextPage( effect.canvasContext, effect.pageData, effect.scrollToItemId ) + 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) + is ModuleListEffect.BulkUpdateModules -> bulkUpdateModules( + effect.canvasContext, + effect.moduleIds, + effect.action, + effect.skipContentTags + ) + + is ModuleListEffect.UpdateModuleItem -> updateModuleItem( + effect.canvasContext, + effect.moduleId, + effect.itemId, + effect.published + ) }.exhaustive } @@ -130,9 +155,11 @@ class ModuleListEffectHandler : EffectHandler awaitApiResponse { ModuleManager.getFirstPageModulesWithItems(canvasContext, it, pageData.forceNetwork) } + pageData.nextPageUrl.isValid() -> awaitApiResponse { ModuleManager.getNextPageModuleObjects(pageData.nextPageUrl, it, pageData.forceNetwork) } + else -> throw IllegalStateException("Unable to fetch page data; invalid nextPageUrl") } @@ -154,4 +181,75 @@ class ModuleListEffectHandler : EffectHandler, + action: BulkModuleUpdateAction, + skipContentTags: Boolean, + async: Boolean = true + ) { + launch { + val restParams = RestParams( + canvasContext = canvasContext, + isForceReadFromNetwork = true + ) + val progress = moduleApi.bulkUpdateModules( + canvasContext.type.apiString, + canvasContext.id, + moduleIds, + action.event, + skipContentTags, + async, + restParams + ).dataOrNull?.progress + + progress?.progress?.let { + trackUpdateProgress(it) + } ?: consumer.accept(ModuleListEvent.BulkUpdateFailed) + } + } + + private suspend fun trackUpdateProgress(progress: Progress) { + val params = RestParams(isForceReadFromNetwork = true) + + val result = poll(500, maxAttempts = -1, + validate = { + it.hasRun + }, + block = { + var newProgress: Progress? = null + retry(initialDelay = 500) { + newProgress = progressApi.getProgress(progress.id.toString(), params).dataOrThrow + } + newProgress + }) + + if (result?.hasRun == true && result.isCompleted) { + consumer.accept(ModuleListEvent.BulkUpdateSuccess) + } else { + consumer.accept(ModuleListEvent.BulkUpdateFailed) + } + } + + private fun updateModuleItem(canvasContext: CanvasContext, moduleId: Long, itemId: Long, published: Boolean) { + launch { + val restParams = RestParams( + canvasContext = canvasContext, + isForceReadFromNetwork = true + ) + val moduleItem = moduleApi.publishModuleItem( + canvasContext.type.apiString, + canvasContext.id, + moduleId, + itemId, + published, + restParams + ).dataOrNull + + moduleItem?.let { + consumer.accept(ModuleListEvent.ModuleItemUpdateSuccess(it)) + } ?: consumer.accept(ModuleListEvent.ModuleItemUpdateFailed(itemId)) + } + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt index 221d3ede60..89f7bf5dc4 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt @@ -25,6 +25,8 @@ import com.instructure.canvasapi2.utils.isValid sealed class ModuleListEvent { object PullToRefresh : ModuleListEvent() object NextPageRequested : ModuleListEvent() + object BulkUpdateSuccess : ModuleListEvent() + object BulkUpdateFailed : 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() @@ -32,6 +34,11 @@ sealed class ModuleListEvent { data class ItemRefreshRequested(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent() data class ReplaceModuleItems(val items: List) : ModuleListEvent() data class RemoveModuleItems(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent() + data class BulkUpdateModule(val moduleId: Long, val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : ModuleListEvent() + data class BulkUpdateAllModules(val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : ModuleListEvent() + data class UpdateModuleItem(val itemId: Long, val isPublished: Boolean) : ModuleListEvent() + data class ModuleItemUpdateSuccess(val item: ModuleItem): ModuleListEvent() + data class ModuleItemUpdateFailed(val itemId: Long): ModuleListEvent() } sealed class ModuleListEffect { @@ -39,18 +46,34 @@ sealed class ModuleListEffect { val moduleItem: ModuleItem, val canvasContext: CanvasContext ) : ModuleListEffect() + data class LoadNextPage( val canvasContext: CanvasContext, val pageData: ModuleListPageData, val scrollToItemId: Long? ) : ModuleListEffect() + data class ScrollToItem(val moduleItemId: Long) : ModuleListEffect() data class MarkModuleExpanded( val canvasContext: CanvasContext, val moduleId: Long, val isExpanded: Boolean ) : ModuleListEffect() + data class UpdateModuleItems(val canvasContext: CanvasContext, val items: List) : ModuleListEffect() + data class BulkUpdateModules( + val canvasContext: CanvasContext, + val moduleIds: List, + val action: BulkModuleUpdateAction, + val skipContentTags: Boolean + ) : ModuleListEffect() + + data class UpdateModuleItem( + val canvasContext: CanvasContext, + val moduleId: Long, + val itemId: Long, + val published: Boolean + ) : ModuleListEffect() } data class ModuleListModel( @@ -70,3 +93,8 @@ data class ModuleListPageData( val isFirstPage get() = lastPageResult == null val hasMorePages get() = isFirstPage || nextPageUrl.isValid() } + +enum class BulkModuleUpdateAction(val event: String) { + PUBLISH("publish"), + UNPUBLISH("unpublish") +} diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt index 9526c732d4..96d7ad094a 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt @@ -27,6 +27,7 @@ import com.instructure.teacher.R import com.instructure.teacher.features.modules.list.ui.ModuleListItemData import com.instructure.teacher.features.modules.list.ui.ModuleListViewState import com.instructure.teacher.mobius.common.ui.Presenter +import kotlin.math.roundToInt object ModuleListPresenter : Presenter { @@ -42,18 +43,22 @@ object ModuleListPresenter : Presenter { val moduleItems: List = if (module.items.isNotEmpty()) { module.items.map { item -> if (item.type.equals(ModuleItem.Type.SubHeader.name, ignoreCase = true)) { - ModuleListItemData.ModuleItemData( - id = item.id, - title = null, - subtitle = item.title, - iconResId = null, - isPublished = item.published, - indent = item.indent * indentWidth, - tintColor = 0, - enabled = false + ModuleListItemData.SubHeader( + id = item.id, + title = item.title, + indent = item.indent * indentWidth, + enabled = false, + published = item.published, + isLoading = item.id in model.loadingModuleItemIds ) } else { - createModuleItemData(item, context, indentWidth, iconTint, item.id in model.loadingModuleItemIds) + createModuleItemData( + item, + context, + indentWidth, + iconTint, + item.id in model.loadingModuleItemIds + ) } } } else { @@ -63,7 +68,8 @@ object ModuleListPresenter : Presenter { id = module.id, name = module.name.orEmpty(), isPublished = module.published, - moduleItems = moduleItems + moduleItems = moduleItems, + isLoading = module.id in model.loadingModuleItemIds ) } @@ -98,12 +104,13 @@ object ModuleListPresenter : Presenter { loading: Boolean ): ModuleListItemData.ModuleItemData { val subtitle = item.moduleDetails?.dueDate?.let { - context.getString( - R.string.due, - DateHelper.getMonthDayTimeMaybeMinutesMaybeYear(context, it, R.string.at) - ) + DateHelper.getMonthDayTimeMaybeMinutesMaybeYear(context, it, R.string.at) } + val pointsPossible = item.moduleDetails?.pointsPossible?.toFloatOrNull() + val subtitle2 = + pointsPossible?.let { context.resources.getQuantityString(R.plurals.moduleItemPoints, it.toInt(), it) } + val iconRes: Int? = when (tryOrNull { ModuleItem.Type.valueOf(item.type.orEmpty()) }) { ModuleItem.Type.Assignment -> R.drawable.ic_assignment ModuleItem.Type.Discussion -> R.drawable.ic_discussion @@ -119,7 +126,8 @@ object ModuleListPresenter : Presenter { id = item.id, title = item.title, subtitle = subtitle, - iconResId = iconRes.takeUnless { loading }, + subtitle2 = subtitle2, + iconResId = iconRes, isPublished = item.published, indent = item.indent * indentWidth, tintColor = courseColor, diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt index 1087454574..a2427835e7 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt @@ -141,6 +141,94 @@ class ModuleListUpdate : UpdateInit { + val affectedIds = mutableListOf(event.moduleId) + if (!event.skipContentTags) { + affectedIds.addAll(model.modules.filter { it.id == event.moduleId } + .flatMap { it.items } + .map { it.id }) + } + + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + affectedIds + ) + val effect = ModuleListEffect.BulkUpdateModules( + model.course, + listOf(event.moduleId), + event.action, + event.skipContentTags + ) + return Next.next(newModel, setOf(effect)) + } + is ModuleListEvent.BulkUpdateAllModules -> { + val affectedIds = mutableListOf() + affectedIds.addAll(model.modules.map { it.id }) + if (!event.skipContentTags) { + affectedIds.addAll(model.modules.flatMap { it.items }.map { it.id }) + } + + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + affectedIds + ) + val effect = ModuleListEffect.BulkUpdateModules( + model.course, + model.modules.map { it.id }, + event.action, + event.skipContentTags + ) + return Next.next(newModel, setOf(effect)) + } + is ModuleListEvent.BulkUpdateSuccess -> { + val newModel = model.copy( + isLoading = true, + modules = emptyList(), + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val effect = ModuleListEffect.LoadNextPage( + newModel.course, + newModel.pageData, + newModel.scrollToItemId + ) + return Next.next(newModel, setOf(effect)) + } + is ModuleListEvent.BulkUpdateFailed -> { + val newModel = model.copy( + loadingModuleItemIds = emptySet() + ) + return Next.next(newModel) + } + is ModuleListEvent.UpdateModuleItem -> { + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + event.itemId + ) + val effect = ModuleListEffect.UpdateModuleItem( + model.course, + model.modules.first { it.items.any { it.id == event.itemId } }.id, + event.itemId, + event.isPublished + ) + return Next.next(newModel, setOf(effect)) + } + is ModuleListEvent.ModuleItemUpdateSuccess -> { + val newModel = model.copy( + modules = model.modules.map { module -> + if (event.item.moduleId == module.id) { + module.copy(items = module.items.patchedBy(listOf(event.item)) { it.id }) + } else { + module + } + }, + loadingModuleItemIds = model.loadingModuleItemIds - event.item.id + ) + return Next.next(newModel) + } + is ModuleListEvent.ModuleItemUpdateFailed -> { + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds - event.itemId + ) + return Next.next(newModel) + } } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt index 4c4358f49b..94dcc2922f 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt @@ -1,57 +1,48 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * Copyright (C) 2024 - 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. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * 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. + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . * */ + package com.instructure.teacher.features.modules.list.ui import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.utils.pageview.PageView -import com.instructure.pandautils.analytics.SCREEN_VIEW_MODULE_LIST -import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.utils.Const -import com.instructure.pandautils.utils.NLongArg -import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.withArgs -import com.instructure.teacher.databinding.FragmentModuleListBinding -import com.instructure.teacher.features.modules.list.* -import com.instructure.teacher.mobius.common.ui.MobiusFragment -import com.instructure.teacher.mobius.common.ui.Presenter - -@PageView(url = "{canvasContext}/modules") -@ScreenView(SCREEN_VIEW_MODULE_LIST) -class ModuleListFragment : MobiusFragment() { - - val canvasContext by ParcelableArg(key = Const.COURSE) +import com.instructure.teacher.features.modules.list.ModuleListEffectHandler +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject - private val scrollToItemId by NLongArg(key = Const.MODULE_ITEM_ID) +@AndroidEntryPoint +class ModuleListFragment : ModuleListMobiusFragment() { - override fun makeEffectHandler() = ModuleListEffectHandler() + @Inject + lateinit var moduleApi: ModuleAPI.ModuleInterface - override fun makeUpdate() = ModuleListUpdate() + @Inject + lateinit var progressApi: ProgressAPI.ProgressInterface - override fun makeView(inflater: LayoutInflater, parent: ViewGroup) = ModuleListView(inflater, parent, canvasContext) + override fun makeEffectHandler() = ModuleListEffectHandler(moduleApi, progressApi) - override fun makePresenter(): Presenter = ModuleListPresenter - - override fun makeInitModel(): ModuleListModel = ModuleListModel(course = canvasContext, scrollToItemId = scrollToItemId) - - override val eventSources = listOf(ModuleListEventBusSource()) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + retainInstance = false + } companion object { @@ -63,5 +54,4 @@ class ModuleListFragment : MobiusFragment. + * + */ +package com.instructure.teacher.features.modules.list.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.utils.pageview.PageView +import com.instructure.pandautils.analytics.SCREEN_VIEW_MODULE_LIST +import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.NLongArg +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.withArgs +import com.instructure.teacher.databinding.FragmentModuleListBinding +import com.instructure.teacher.features.modules.list.* +import com.instructure.teacher.mobius.common.ui.MobiusFragment +import com.instructure.teacher.mobius.common.ui.Presenter +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@PageView(url = "{canvasContext}/modules") +@ScreenView(SCREEN_VIEW_MODULE_LIST) +abstract class ModuleListMobiusFragment : MobiusFragment() { + + val canvasContext by ParcelableArg(key = Const.COURSE) + + private val scrollToItemId by NLongArg(key = Const.MODULE_ITEM_ID) + + override fun makeUpdate() = ModuleListUpdate() + + override fun makeView(inflater: LayoutInflater, parent: ViewGroup) = ModuleListView(inflater, parent, canvasContext) + + override fun makePresenter(): Presenter = ModuleListPresenter + + override fun makeInitModel(): ModuleListModel = ModuleListModel(course = canvasContext, scrollToItemId = scrollToItemId) + + override val eventSources = listOf(ModuleListEventBusSource()) + + + +} diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt index 5e8e6b20f5..facaf612e5 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt @@ -26,6 +26,10 @@ interface ModuleListCallback : ListItemCallback { fun retryNextPage() fun moduleItemClicked(moduleItemId: Long) fun markModuleExpanded(moduleId: Long, isExpanded: Boolean) + fun updateModuleItem(itemId: Long, isPublished: Boolean) + fun publishModule(moduleId: Long) + fun publishModuleAndItems(moduleId: Long) + fun unpublishModuleAndItems(moduleId: Long) } class ModuleListRecyclerAdapter( @@ -49,6 +53,7 @@ class ModuleListRecyclerAdapter( register(ModuleListItemBinder()) register(ModuleListLoadingBinder()) register(ModuleListEmptyItemBinder()) + register(ModuleListSubHeaderBinder()) } fun setData(items: List, collapsedModuleIds: Set) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt index c635d19b38..513fe23619 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt @@ -18,13 +18,17 @@ package com.instructure.teacher.features.modules.list.ui import android.view.LayoutInflater import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.LinearLayoutManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleItem import com.instructure.pandarecycler.PaginatedScrollListener import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.showThemed +import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentModuleListBinding +import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.ModuleListEvent import com.instructure.teacher.features.modules.progression.ModuleProgressionFragment import com.instructure.teacher.mobius.common.ui.MobiusView @@ -59,6 +63,33 @@ class ModuleListView( consumer?.accept(ModuleListEvent.ModuleExpanded(moduleId, isExpanded)) } + override fun publishModule(moduleId: Long) { + showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModuleDialogMessage, R.string.publish, R.string.cancel) { + consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, true)) + } + } + + override fun publishModuleAndItems(moduleId: Long) { + showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModuleAndItemsDialogMessage, R.string.publish, R.string.cancel) { + consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, false)) + } + } + + override fun unpublishModuleAndItems(moduleId: Long) { + showConfirmationDialog(R.string.unpublishDialogTitle, R.string.unpublishModuleAndItemsDialogMessage, R.string.unpublish, R.string.cancel) { + consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.UNPUBLISH, false)) + } + } + + override fun updateModuleItem(itemId: Long, isPublished: Boolean) { + val title = if (isPublished) R.string.publishDialogTitle else R.string.unpublishDialogTitle + val message = if (isPublished) R.string.publishModuleItemDialogMessage else R.string.unpublishModuleItemDialogMessage + val positiveButton = if (isPublished) R.string.publish else R.string.unpublish + + showConfirmationDialog(title, message, positiveButton, R.string.cancel) { + consumer?.accept(ModuleListEvent.UpdateModuleItem(itemId, isPublished)) + } + } }) init { @@ -67,6 +98,30 @@ class ModuleListView( subtitle = course.name setupBackButton(activity) ViewStyler.themeToolbarColored(activity, this, course) + inflateMenu(R.menu.menu_module_list) + setOnMenuItemClickListener { + when (it.itemId) { + R.id.actionPublishModulesItems -> { + showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModulesAndItemsDialogMessage, R.string.publish, R.string.cancel) { + consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.PUBLISH, false)) + } + true + } + R.id.actionPublishModules -> { + showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModulesDialogMessage, R.string.publish, R.string.cancel) { + consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.PUBLISH, true)) + } + true + } + R.id.actionUnpublishModulesItems -> { + showConfirmationDialog(R.string.unpublishDialogTitle, R.string.unpublishModulesAndItemsDialogMessage, R.string.unpublish, R.string.cancel) { + consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.UNPUBLISH, false)) + } + true + } + else -> false + } + } } binding.recyclerView.apply { @@ -103,4 +158,15 @@ class ModuleListView( val itemPosition = adapter.getItemVisualPosition(itemId) binding.recyclerView.scrollToPosition(itemPosition) } + + fun showConfirmationDialog(title: Int, message: Int, positiveButton: Int, negativeButton: Int, onConfirmed: () -> Unit) { + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(positiveButton) { _, _ -> + onConfirmed() + } + .setNegativeButton(negativeButton) { _, _ -> } + .showThemed() + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt index 5b000ef475..6828e12728 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt @@ -36,11 +36,21 @@ sealed class ModuleListItemData { data class InlineError(val buttonColor: Int): ModuleListItemData() + data class SubHeader( + val id: Long, + val title: String?, + val indent: Int, + val enabled: Boolean, + val published: Boolean?, + val isLoading: Boolean + ) : ModuleListItemData() + data class ModuleData( val id: Long, val name: String, val isPublished: Boolean?, - val moduleItems: List + val moduleItems: List, + val isLoading: Boolean ): ModuleListItemData() data class ModuleItemData( @@ -53,6 +63,9 @@ sealed class ModuleListItemData { /** The subtitle. If null, the subtitle should be hidden. */ val subtitle: String?, + /** The second line of subtitle. If null, it should be hidden. */ + val subtitle2: String?, + /** The resource ID of the icon to show for this item. If null, the icon should be hidden. */ val iconResId: Int?, diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt index 7f6ef0fc00..2f1bd1fd3b 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt @@ -17,6 +17,11 @@ package com.instructure.teacher.features.modules.list.ui.binders import android.content.res.ColorStateList +import android.view.Gravity +import android.view.View +import androidx.appcompat.widget.PopupMenu +import com.instructure.pandautils.utils.onClickWithRequireNetwork +import com.instructure.pandautils.utils.setHidden import com.instructure.pandautils.utils.setTextForVisibility import com.instructure.pandautils.utils.setVisible import com.instructure.teacher.R @@ -37,16 +42,48 @@ class ModuleListItemBinder : ListItemBinder menu.add(0, 0, 0, R.string.unpublish) + false -> menu.add(0, 1, 1, R.string.publish) + else -> { + menu.add(0, 0, 0, R.string.unpublish) + menu.add(0, 1, 1, R.string.publish) + } + } + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + 0 -> { + callback.updateModuleItem(item.id, false) + true + } + 1 -> { + callback.updateModuleItem(item.id, true) + true + } + else -> false + } + } + + overflow.contentDescription = it.context.getString(R.string.a11y_contentDescription_moduleOptions, item.title) + popup.show() + } } } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt index 74c6233299..bfbb353c73 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListModuleBinder.kt @@ -16,6 +16,9 @@ */ package com.instructure.teacher.features.modules.list.ui.binders +import android.view.Gravity +import androidx.appcompat.widget.PopupMenu +import com.instructure.pandautils.utils.onClickWithRequireNetwork import com.instructure.pandautils.utils.setVisible import com.instructure.teacher.R import com.instructure.teacher.adapters.ListItemBinder @@ -31,13 +34,41 @@ class ModuleListModuleBinder : ListItemBinder callback.markModuleExpanded(item.id, isExpanded) }, - onBind = { item, view, isCollapsed, _ -> + onBind = { item, view, isCollapsed, callback -> val binding = AdapterModuleBinding.bind(view) with(binding) { moduleName.text = item.name - publishedIcon.setVisible(item.isPublished == true) - unpublishedIcon.setVisible(item.isPublished == false) - collapseIcon.rotation = if (isCollapsed) 0f else 180f + publishedIcon.setVisible(item.isPublished == true && !item.isLoading) + unpublishedIcon.setVisible(item.isPublished == false && !item.isLoading) + collapseIcon.rotation = if (isCollapsed) 180f else 0f + + loadingView.setVisible(item.isLoading) + + overflow.onClickWithRequireNetwork { + val popup = PopupMenu(it.context, it, Gravity.START.and(Gravity.TOP)) + popup.inflate(R.menu.menu_module) + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.publishModuleItems -> { + callback.publishModuleAndItems(item.id) + true + } + R.id.publishModule -> { + callback.publishModule(item.id) + true + } + R.id.unpublishModuleItems -> { + callback.unpublishModuleAndItems(item.id) + true + } + else -> false + } + } + + overflow.contentDescription = it.context.getString(R.string.a11y_contentDescription_moduleOptions, item.name) + popup.show() + } } } ) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListSubHeaderBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListSubHeaderBinder.kt new file mode 100644 index 0000000000..b7f663febc --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListSubHeaderBinder.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.features.modules.list.ui.binders + +import android.view.Gravity +import androidx.appcompat.widget.PopupMenu +import com.instructure.pandautils.utils.onClickWithRequireNetwork +import com.instructure.pandautils.utils.setHidden +import com.instructure.pandautils.utils.setVisible +import com.instructure.teacher.R +import com.instructure.teacher.adapters.ListItemBinder +import com.instructure.teacher.databinding.AdapterModuleSubHeaderBinding +import com.instructure.teacher.features.modules.list.ui.ModuleListCallback +import com.instructure.teacher.features.modules.list.ui.ModuleListItemData + +class ModuleListSubHeaderBinder : ListItemBinder() { + override val layoutResId = R.layout.adapter_module_sub_header + + override fun getItemId(item: ModuleListItemData.SubHeader) = item.id + + override val bindBehavior: BindBehavior = Item { item, view, callback -> + val binding = AdapterModuleSubHeaderBinding.bind(view) + with(binding) { + subHeaderTitle.text = item.title + moduleItemPublishedIcon.setVisible(item.published == true && !item.isLoading) + moduleItemUnpublishedIcon.setVisible(item.published == false && !item.isLoading) + moduleItemIndent.layoutParams.width = item.indent + + moduleItemLoadingView.setVisible(item.isLoading) + + overflow.onClickWithRequireNetwork { + val popup = PopupMenu(it.context, it, Gravity.START.and(Gravity.TOP)) + val menu = popup.menu + + when (item.published) { + true -> menu.add(0, 0, 0, R.string.unpublish) + false -> menu.add(0, 0, 0, R.string.publish) + else -> { + menu.add(0, 0, 0, R.string.unpublish) + menu.add(0, 1, 1, R.string.publish) + } + } + + overflow.contentDescription = it.context.getString(R.string.a11y_contentDescription_moduleOptions, item.title) + popup.show() + } + } + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/res/layout/adapter_module.xml b/apps/teacher/src/main/res/layout/adapter_module.xml index 19dda22397..74c226b877 100644 --- a/apps/teacher/src/main/res/layout/adapter_module.xml +++ b/apps/teacher/src/main/res/layout/adapter_module.xml @@ -1,5 +1,4 @@ - - + android:orientation="vertical"> - + android:background="@color/backgroundLight" + android:foreground="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:minHeight="48dp" + android:orientation="horizontal" + android:paddingStart="8dp" + android:paddingTop="8dp" + android:paddingEnd="4dp" + android:paddingBottom="8dp"> - + + + + + + + + + - + - + + diff --git a/apps/teacher/src/main/res/layout/adapter_module_item.xml b/apps/teacher/src/main/res/layout/adapter_module_item.xml index d4ffe7c6bd..5495aa2ec4 100644 --- a/apps/teacher/src/main/res/layout/adapter_module_item.xml +++ b/apps/teacher/src/main/res/layout/adapter_module_item.xml @@ -1,5 +1,4 @@ - - - + android:paddingTop="12dp" + android:paddingEnd="4dp" + android:paddingBottom="12dp"> + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + android:tint="@color/textDark" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@id/moduleItemIndent" + app:layout_constraintTop_toTopOf="parent" /> - + - + android:layout_marginHorizontal="16dp" + android:ellipsize="end" + android:maxLines="1" + android:textColor="@color/textDark" + app:layout_constraintBottom_toTopOf="@+id/moduleItemSubtitle2" + app:layout_constraintEnd_toStartOf="@+id/statusWrapper" + app:layout_constraintStart_toEndOf="@id/moduleItemIcon" + app:layout_constraintTop_toBottomOf="@id/moduleItemTitle" + tools:text="Due Apr 25 at 11:59pm" /> + + - + - + - + - + + + + android:id="@+id/overflow" + android:layout_width="48dp" + android:layout_height="48dp" + android:background="?android:selectableItemBackground" + android:clickable="true" + android:contentDescription="@string/moduleOptions" + android:focusable="true" + android:paddingStart="14dp" + android:paddingTop="14dp" + android:paddingEnd="14dp" + android:paddingBottom="14dp" + android:tint="@color/textDark" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_overflow_white_18dp" /> - + diff --git a/apps/teacher/src/main/res/layout/adapter_module_sub_header.xml b/apps/teacher/src/main/res/layout/adapter_module_sub_header.xml new file mode 100644 index 0000000000..ab3f7db8a1 --- /dev/null +++ b/apps/teacher/src/main/res/layout/adapter_module_sub_header.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/main/res/menu/menu_module.xml b/apps/teacher/src/main/res/menu/menu_module.xml new file mode 100644 index 0000000000..6f8b60a129 --- /dev/null +++ b/apps/teacher/src/main/res/menu/menu_module.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/main/res/menu/menu_module_list.xml b/apps/teacher/src/main/res/menu/menu_module_list.xml new file mode 100644 index 0000000000..a5d56562b9 --- /dev/null +++ b/apps/teacher/src/main/res/menu/menu_module_list.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt b/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt index be89c34695..50125e5773 100644 --- a/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt +++ b/apps/teacher/src/qa/java/com/instructure/teacher/SingleFragmentTestActivity.kt @@ -23,7 +23,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import com.instructure.pandautils.binding.viewBinding import com.instructure.teacher.databinding.ActivitySingleFragmentTestBinding +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class SingleFragmentTestActivity : AppCompatActivity() { private val binding by viewBinding(ActivitySingleFragmentTestBinding::inflate) diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt index fc93c7acc4..92fa4dac98 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt @@ -15,15 +15,19 @@ */ package com.instructure.teacher.unit.modules.list +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.managers.ModuleManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.Progress import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.awaitApiResponse +import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.CollapsedModulesStore import com.instructure.teacher.features.modules.list.ModuleListEffect import com.instructure.teacher.features.modules.list.ModuleListEffectHandler @@ -62,12 +66,15 @@ class ModuleListEffectHandlerTest : Assert() { private lateinit var connection: Connection private val course: CanvasContext = Course() + private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) + private val progressApi: ProgressAPI.ProgressInterface = mockk(relaxed = true) + @ExperimentalCoroutinesApi @Before fun setUp() { Dispatchers.setMain(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) view = mockk(relaxed = true) - effectHandler = ModuleListEffectHandler().apply { view = this@ModuleListEffectHandlerTest.view } + effectHandler = ModuleListEffectHandler(moduleApi, progressApi).apply { view = this@ModuleListEffectHandlerTest.view } consumer = mockk(relaxed = true) connection = effectHandler.connect(consumer) } @@ -281,6 +288,75 @@ class ModuleListEffectHandlerTest : Assert() { unmockkStatic("com.instructure.canvasapi2.utils.weave.AwaitApiKt") } + @Test + fun `BulkUpdateModules results in correct success event`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateSuccess + + coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Success(mockk(relaxed = true)) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(Progress(1L, workflowState = "completed")) + + connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false)) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `BulkUpdateModules results in correct failed event when call fails`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateFailed + + coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Fail() + + connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false)) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `BulkUpdateModules results in correct failed event when progress fails`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateFailed + + coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Success(mockk(relaxed = true)) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(Progress(1L, workflowState = "failed")) + + connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false)) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `UpdateModuleItem results in correct success event`() { + val moduleId = 1L + val itemId = 2L + val expectedEvent = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(id = itemId, moduleId = moduleId, published = true)) + + coEvery { moduleApi.publishModuleItem(any(), any(), any(), any(), any(), any()) } returns DataResult.Success(ModuleItem(2L, 1L, published = true)) + + connection.accept(ModuleListEffect.UpdateModuleItem(course, moduleId, itemId, true)) + + verify(timeout = 100) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `UpdateModuleItem results in correct failed event`() { + val moduleId = 1L + val itemId = 2L + val expectedEvent = ModuleListEvent.ModuleItemUpdateFailed(itemId) + + coEvery { moduleApi.publishModuleItem(any(), any(), any(), any(), any(), any()) } returns DataResult.Fail() + + connection.accept(ModuleListEffect.UpdateModuleItem(course, moduleId, itemId, true)) + + verify(timeout = 100) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + private fun makeModulePage( moduleCount: Int = 3, itemsPerModule: Int = 3, diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt index a26de81beb..458889143f 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt @@ -63,6 +63,7 @@ class ModuleListPresenterTest : Assert() { id = 1L, name = "Module 1", isPublished = true, + isLoading = false, moduleItems = listOf(ModuleListItemData.EmptyItem(1L)) ) moduleItemTemplate = ModuleItem( @@ -78,7 +79,8 @@ class ModuleListPresenterTest : Assert() { moduleItemDataTemplate = ModuleListItemData.ModuleItemData( id = 1000L, title = "Module Item 1", - subtitle = "Due February 12, 2050 at 3:07 PM", + subtitle = "February 12, 2050 at 3:07 PM", + subtitle2 = null, iconResId = R.drawable.ic_assignment, isPublished = true, indent = 0, @@ -328,12 +330,7 @@ class ModuleListPresenterTest : Assert() { moduleTemplate.copy(items = listOf(item)) ) ) - val expectedState = moduleItemDataTemplate.copy( - title = null, - subtitle = item.title, - iconResId = null, - enabled = false - ) + val expectedState = ModuleListItemData.SubHeader(1000L, "This is a header", 0, false, true, false) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() assertEquals(expectedState, itemState) @@ -353,9 +350,9 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = null, enabled = false, - isLoading = true + isLoading = true, + iconResId = R.drawable.ic_attachment ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt index eaa44b83ea..5fcfc36137 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt @@ -20,6 +20,7 @@ import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.DataResult +import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.ModuleListEffect import com.instructure.teacher.features.modules.list.ModuleListEvent import com.instructure.teacher.features.modules.list.ModuleListModel @@ -196,7 +197,7 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( modules = listOf(ModuleObject(items = items)) ) - val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id in 1L..3L} + val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id in 1L..3L } val expectedEffect = ModuleListEffect.UpdateModuleItems(model.course, listOf(items[1])) updateSpec .given(model) @@ -218,7 +219,7 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( modules = listOf(ModuleObject(items = items)) ) - val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id == 3L} + val event = ModuleListEvent.ItemRefreshRequested("Discussion") { it.id == 3L } updateSpec .given(model) .whenEvent(event) @@ -414,4 +415,241 @@ class ModuleListUpdateTest : Assert() { ) } + @Test + fun `BulkUpdateModule sets loading for module`() { + val module = ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))) + val event = ModuleListEvent.BulkUpdateModule(1L, BulkModuleUpdateAction.PUBLISH, true) + val model = initModel.copy(modules = listOf(module)) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L), + BulkModuleUpdateAction.PUBLISH, + true + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateModule sets loading for module and items`() { + val module = ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))) + val event = ModuleListEvent.BulkUpdateModule(1L, BulkModuleUpdateAction.PUBLISH, false) + val model = initModel.copy(modules = listOf(module)) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L, 100L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L), + BulkModuleUpdateAction.PUBLISH, + false + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateAllModules sets loading for modules`() { + val modules = listOf( + ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))), + ModuleObject( + id = 2L, items = listOf( + ModuleItem(id = 200L, moduleId = 2L), + ModuleItem(id = 201L, moduleId = 2L) + ) + ) + ) + val event = ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.UNPUBLISH, true) + val model = initModel.copy(modules = modules) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L, 2L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L, 2L), + BulkModuleUpdateAction.UNPUBLISH, + true + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateAllModules sets loading for modules and items`() { + val modules = listOf( + ModuleObject(id = 1L, items = listOf(ModuleItem(id = 100L, moduleId = 1L))), + ModuleObject( + id = 2L, items = listOf( + ModuleItem(id = 200L, moduleId = 2L), + ModuleItem(id = 201L, moduleId = 2L) + ) + ) + ) + val event = ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.UNPUBLISH, false) + val model = initModel.copy(modules = modules) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(1L, 100L, 2L, 200L, 201L) + ) + val expectedEffect = ModuleListEffect.BulkUpdateModules( + expectedModel.course, + listOf(1L, 2L), + BulkModuleUpdateAction.UNPUBLISH, + false + ) + + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess emits refresh effect and clears loading`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L)), + loadingModuleItemIds = setOf(1L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `BulkUpdateFailed clears loading`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L)), + loadingModuleItemIds = setOf(1L) + ) + + val expectedModel = model.copy( + loadingModuleItemIds = emptySet() + ) + + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateFailed) + .then( + assertThatNext( + hasModel(expectedModel) + ) + ) + } + + @Test + fun `UpdateModuleItem emits UpdateModuleItem effect`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L)))), + ) + val expectedModel = model.copy( + loadingModuleItemIds = setOf(100L) + ) + val expectedEffect = ModuleListEffect.UpdateModuleItem( + model.course, + 1L, + 100L, + true + ) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.UpdateModuleItem(100L, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect) + ) + ) + } + + @Test + fun `ModuleItemUpdateSuccess replaces module item`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = false)))), + loadingModuleItemIds = setOf(100L) + ) + val expectedModel = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = true)))), + loadingModuleItemIds = emptySet() + ) + val event = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(100L, 1L, published = true)) + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel) + ) + ) + } + + @Test + fun `ModuleItemUpdateFailed clears loading`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = false)))), + loadingModuleItemIds = setOf(100L) + ) + val expectedModel = model.copy( + loadingModuleItemIds = emptySet() + ) + val event = ModuleListEvent.ModuleItemUpdateFailed(100L) + updateSpec + .given(model) + .whenEvent(event) + .then( + assertThatNext( + hasModel(expectedModel) + ) + ) + } + + } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt index 0b7553d3da..b3b9129f92 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt @@ -20,6 +20,7 @@ import com.instructure.canvasapi2.StatusCallback import com.instructure.canvasapi2.builders.RestBuilder import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.postmodels.BulkUpdateResponse import com.instructure.canvasapi2.utils.DataResult import okhttp3.ResponseBody import retrofit2.Call @@ -81,6 +82,12 @@ object ModuleAPI { @GET("{contextId}/modules/{moduleId}/items/{itemId}?include[]=content_details") fun getModuleItem(@Path("contextId") contextId: Long, @Path("moduleId") moduleId: Long, @Path("itemId") itemId: Long) : Call + + @PUT("{contextType}/{contextId}/modules") + suspend fun bulkUpdateModules(@Path("contextType") contextType: String, @Path("contextId") contextId: Long, @Query("module_ids[]") moduleIds: List, @Query("event") event: String, @Query("skip_content_tags") skipContentTags: Boolean, @Query("async") async: Boolean, @Tag params: RestParams): DataResult + + @PUT("{contextType}/{contextId}/modules/{moduleId}/items/{itemId}") + suspend fun publishModuleItem(@Path("contextType") contextType: String, @Path("contextId") contextId: Long, @Path("moduleId") moduleId: Long, @Path("itemId") itemId: Long, @Query("module_item[published]") publish: Boolean, @Tag params: RestParams): DataResult } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/postmodels/BulkUpdateResponse.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/postmodels/BulkUpdateResponse.kt new file mode 100644 index 0000000000..667df98d3f --- /dev/null +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/postmodels/BulkUpdateResponse.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.canvasapi2.models.postmodels + +import com.instructure.canvasapi2.models.Progress + +data class BulkUpdateResponse( + val progress: BulkUpdateProgress? = null +) + +data class BulkUpdateProgress( + val progress: Progress? = null +) \ No newline at end of file diff --git a/libs/pandares/src/main/res/drawable/ic_publish.xml b/libs/pandares/src/main/res/drawable/ic_publish.xml new file mode 100644 index 0000000000..a8827c653a --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_publish.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_unpublish.xml b/libs/pandares/src/main/res/drawable/ic_unpublish.xml new file mode 100644 index 0000000000..09e495e99a --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_unpublish.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 1e2639fb23..f85510676d 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1491,4 +1491,29 @@ Additional course content Failed to update Dashboard cards order + Publish all Modules and Items + Publish Modules only + Unpublish all Modules and Items + Module Options + Publish Module and all Items + Publish Module only + Unpublish Module and all Items + Module options for %s + Module item options for %s + Publish Module Item + Unpublish Module Item + Publish? + This will make only the module visible to students. + This will make the module and all items visible to students. + Unpublish? + This will make the module and all items invisible to students. + This will make only this item visible to students. + This will make only this item invisible to students. + This will make all modules and items visible to students. + This will make only the modules visible to students. + This will make all modules and items invisible to students. + + %.0f pt + %.0f pts + diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt index c16d98d61c..8b4c390f06 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Extensions.kt @@ -17,8 +17,8 @@ package com.instructure.pandautils.utils import androidx.work.Data -import androidx.work.WorkInfo import com.google.gson.Gson +import kotlinx.coroutines.delay import java.util.* import kotlin.math.ln import kotlin.math.pow @@ -59,3 +59,36 @@ fun Data.newBuilder(): Data.Builder { return Data.Builder() .putAll(this) } + +suspend fun retry(retryCount: Int = 5, initialDelay: Long = 100, factor: Float = 2f, maxDelay: Long = 1000, block: suspend () -> Unit) { + var currentDelay = initialDelay + repeat(retryCount.coerceAtLeast(1)) { + try { + block() + return + } catch (e: Exception) { + delay(currentDelay) + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) + } + } +} + +suspend fun poll( + pollInterval: Long = 1000, + maxAttempts: Int = 10, + block: suspend () -> T?, + validate: suspend (T) -> Boolean +): T? { + var attempts = 0 + while (attempts < maxAttempts || maxAttempts == -1) { + val result = block() + result?.let { + if (validate(it)) { + return result + } + } + attempts++ + delay(pollInterval) + } + return null +} \ No newline at end of file From 7e4822e879960e3f7c3cac3829f43c85a89b3a17 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Mon, 22 Jan 2024 16:26:44 +0100 Subject: [PATCH 02/52] [MBL-17121][Student] - Implement E2E Offline test for student course Files. (#2307) --- .../student/ui/e2e/AssignmentsE2ETest.kt | 29 ++-- .../offline/ManageOfflineContentE2ETest.kt | 5 - .../ui/e2e/offline/OfflineFilesE2ETest.kt | 132 ++++++++++++++++++ .../e2e/offline/OfflineLeftSideMenuE2ETest.kt | 2 + .../ui/e2e/offline/OfflineLoginE2ETest.kt | 1 + .../student/ui/pages/FileListPage.kt | 13 +- .../student/ui/utils/StudentTest.kt | 3 + .../student/ui/utils/StudentTestExtensions.kt | 66 +++++---- .../dataseeding/api/FileFolderApi.kt | 63 +++++++++ .../dataseeding/api/FileUploadsApi.kt | 16 ++- .../model/CourseFolderUploadApiModel.kt | 39 ++++++ .../model/CourseRootFolderApiModel.kt | 30 ++++ .../dataseeding/model/FileUploadType.kt | 3 +- 13 files changed, 346 insertions(+), 56 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt create mode 100644 automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt create mode 100644 automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseFolderUploadApiModel.kt create mode 100644 automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseRootFolderApiModel.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt index e78d1f96cb..3793f2be87 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt @@ -131,7 +131,7 @@ class AssignmentsE2ETest: StudentTest() { fun testLetterGradeTextAssignmentE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -163,7 +163,7 @@ class AssignmentsE2ETest: StudentTest() { fun testPercentageFileAssignmentWithCommentE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -186,7 +186,12 @@ class AssignmentsE2ETest: StudentTest() { assignmentListPage.clickAssignment(percentageFileAssignment) Log.d(PREPARATION_TAG, "Seed a text file.") - val uploadInfo = uploadTextFile(courseId = course.id, assignmentId = percentageFileAssignment.id, token = student.token, fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION) + val uploadInfo = uploadTextFile( + courseId = course.id, + assignmentId = percentageFileAssignment.id, + token = student.token, + fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION + ) Log.d(PREPARATION_TAG,"Submit ${percentageFileAssignment.name} assignment for ${student.name} student.") submitCourseAssignment(course, percentageFileAssignment, uploadInfo, student) @@ -245,7 +250,7 @@ class AssignmentsE2ETest: StudentTest() { fun testMultipleAssignmentsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -288,7 +293,7 @@ class AssignmentsE2ETest: StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) fun testFilterAndSortAssignmentsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -420,7 +425,7 @@ class AssignmentsE2ETest: StudentTest() { fun testMediaCommentsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -464,7 +469,7 @@ class AssignmentsE2ETest: StudentTest() { fun testAddFileCommentE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -481,8 +486,8 @@ class AssignmentsE2ETest: StudentTest() { Log.d(STEP_TAG,"Seed a comment attachment upload.") val commentUploadInfo = uploadTextFile( - assignmentId = assignment.id, courseId = course.id, + assignmentId = assignment.id, token = student.token, fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) @@ -510,7 +515,7 @@ class AssignmentsE2ETest: StudentTest() { fun testSubmissionAttemptSelection() { Log.d(PREPARATION_TAG, "Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -572,7 +577,7 @@ class AssignmentsE2ETest: StudentTest() { fun testCommentsBelongToSubmissionAttempts() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -665,7 +670,7 @@ class AssignmentsE2ETest: StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) fun showOnlyLetterGradeOnDashboardAndAssignmentListPageE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -821,7 +826,7 @@ class AssignmentsE2ETest: StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.E2E) fun showOnlyLetterGradeOnGradesPageE2E() { Log.d(PREPARATION_TAG, "Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(teachers = 1, courses = 1, students = 1) val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index d4f6766120..9aa20f8b6a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -18,8 +18,6 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E @@ -50,9 +48,6 @@ class ManageOfflineContentE2ETest : StudentTest() { val course1 = data.coursesList[0] val course2 = data.coursesList[1] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt new file mode 100644 index 0000000000..a96cba7482 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.student.ui.e2e.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import com.google.android.material.checkbox.MaterialCheckBox +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.FileFolderApi +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import com.instructure.student.ui.utils.uploadTextFile +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Test + +@HiltAndroidTest +class OfflineFilesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.FILES, TestCategory.E2E) + fun testOfflineFilesE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + val testCourseFolderName = "Goodya" + Log.d(PREPARATION_TAG, "Create a course folder within the 'Files' tab with the name: '$testCourseFolderName'.") + val courseRootFolder = FileFolderApi.getCourseRootFolder(course.id, teacher.token) + val courseTestFolder = FileFolderApi.createCourseFolder(courseRootFolder.id, testCourseFolderName, false, teacher.token) + + Log.d(PREPARATION_TAG, "Create a (text) file within the root folder (so the 'Files' tab file list) of the '${course.name}' course.") + val rootFolderTestTextFile = uploadTextFile(courseRootFolder.id, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(PREPARATION_TAG, "Create a (text) file within the '${courseTestFolder.name}' folder of the '${course.name}' course.") + val courseTestFolderTextFile = uploadTextFile(courseTestFolder.id, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Assert that the '${course.name}' course's checkbox state is 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Expand the course. Select the 'Files' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.expandCollapseItem(course.name) + manageOfflineContentPage.changeItemSelectionState("Files") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") + dashboardPage.waitForRender() + dashboardPage.waitForSyncProgressDownloadStartedNotification() + dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() + + Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") + dashboardPage.waitForSyncProgressStartingNotification() + dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. + device.waitForIdle() + device.waitForWindowUpdate(null, 10000) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course.name}' course and click on 'Files' tab to navigate to the File List Page.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectFiles() + + Log.d(STEP_TAG, "Assert that under the 'Files' tab there are 2 items, a folder and a file which has been seeded recently, and there is 1 item within the folder.") + fileListPage.assertFileListCount(2) + fileListPage.assertFolderSize(courseTestFolder.name, 1) + + Log.d(STEP_TAG, "Assert that the folder's name is '${courseTestFolder.name}' and the file's name is '${rootFolderTestTextFile.fileName}'.") + fileListPage.assertItemDisplayed(rootFolderTestTextFile.fileName) + fileListPage.assertItemDisplayed(courseTestFolder.name) + + Log.d(STEP_TAG, "Open '${courseTestFolder.name}' folder and assert that the '${courseTestFolderTextFile.fileName}' file is displayed within it. Navigate back to File List Page.") + fileListPage.selectItem(courseTestFolder.name) + fileListPage.assertItemDisplayed(courseTestFolderTextFile.fileName) + Espresso.pressBack() + + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${rootFolderTestTextFile.fileName}', the file's name to the search input field.") + fileListPage.searchable.clickOnSearchButton() + fileListPage.searchable.typeToSearchBar(rootFolderTestTextFile.fileName) + + Log.d(STEP_TAG, "Assert that only 1 file matches for the search text, and it is '${rootFolderTestTextFile.fileName}', and no directories has been shown in the result.") + fileListPage.assertSearchResultCount(1) + fileListPage.assertSearchItemDisplayed(rootFolderTestTextFile.fileName) + fileListPage.assertItemNotDisplayed(testCourseFolderName) + Espresso.closeSoftKeyboard() + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + turnOnConnectionViaADB() + } + +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt index 16243ecf4d..3b185a5d14 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt @@ -53,6 +53,8 @@ class OfflineLeftSideMenuE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() + Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. + dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt index d8aee5a445..a4718ab009 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt @@ -68,6 +68,7 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() + device.waitForWindowUpdate(null, 10000) Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt index 1a5b4edd07..786d1e2406 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt @@ -20,9 +20,7 @@ import androidx.appcompat.widget.AppCompatButton import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.swipeDown -import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView @@ -58,6 +56,11 @@ class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { waitForView(matcher).scrollTo().assertDisplayed() } + fun assertSearchItemDisplayed(itemName: String) { + val matcher = allOf(withId(R.id.fileName), withAncestor(R.id.fileSearchRecyclerView), withText(itemName)) + waitForView(matcher).scrollTo().assertDisplayed() + } + fun assertItemNotDisplayed(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) onView(matcher).assertNotDisplayed() @@ -127,14 +130,14 @@ class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { fun assertSearchResultCount(expectedCount: Int) { Thread.sleep(2000) onView(withId(R.id.fileSearchRecyclerView) + withAncestor(R.id.container)).check( - ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + matches(hasChildCount(expectedCount)) ) } fun assertFileListCount(expectedCount: Int) { Thread.sleep(2000) - onView(withId(R.id.listView) + withAncestor(R.id.container)).check( - ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + onView(withId(R.id.listView) + withAncestor(R.id.fileListPage)).check( + matches(hasChildCount(expectedCount)) ) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index d11f313b76..e72cd5bd6b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -32,6 +32,7 @@ import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule import com.instructure.espresso.Searchable @@ -112,6 +113,8 @@ import javax.inject.Inject abstract class StudentTest : CanvasTest() { + val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + override val activityRule: InstructureActivityTestRule = StudentActivityTestRule(LoginActivity::class.java) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt index 991c0e1778..ae17fa6e2a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt @@ -58,7 +58,7 @@ fun StudentTest.slowLogIn(enrollmentType: String = EnrollmentTypes.STUDENT_ENROL return user } -fun StudentTest.seedDataForK5( +fun seedDataForK5( teachers: Int = 0, tas: Int = 0, pastCourses: Int = 0, @@ -69,7 +69,8 @@ fun StudentTest.seedDataForK5( announcements: Int = 0, discussions: Int = 0, syllabusBody: String? = null, - gradingPeriods: Boolean = false): SeedApi.SeededDataApiModel { + gradingPeriods: Boolean = false +): SeedApi.SeededDataApiModel { val request = SeedApi.SeedDataRequest ( teachers = teachers, @@ -88,7 +89,7 @@ fun StudentTest.seedDataForK5( return SeedApi.seedDataForSubAccount(request) } -fun StudentTest.seedData( +fun seedData( teachers: Int = 0, tas: Int = 0, pastCourses: Int = 0, @@ -100,7 +101,8 @@ fun StudentTest.seedData( locked: Boolean = false, discussions: Int = 0, syllabusBody: String? = null, - gradingPeriods: Boolean = false): SeedApi.SeededDataApiModel { + gradingPeriods: Boolean = false +): SeedApi.SeededDataApiModel { val request = SeedApi.SeedDataRequest ( teachers = teachers, @@ -119,15 +121,16 @@ fun StudentTest.seedData( return SeedApi.seedData(request) } -fun StudentTest.seedAssignments( - courseId: Long, - assignments: Int = 1, - withDescription: Boolean = false, - lockAt: String = "", - unlockAt: String = "", - dueAt: String = "", - submissionTypes: List = emptyList(), - teacherToken: String): List { +fun seedAssignments( + courseId: Long, + assignments: Int = 1, + withDescription: Boolean = false, + lockAt: String = "", + unlockAt: String = "", + dueAt: String = "", + submissionTypes: List = emptyList(), + teacherToken: String +): List { return AssignmentsApi.seedAssignments(AssignmentsApi.CreateAssignmentRequest( courseId = courseId, @@ -203,7 +206,7 @@ fun StudentTest.tokenLoginElementary(user: CanvasUserApiModel) { elementaryDashboardPage.assertPageObjects() } -fun StudentTest.routeTo(route: String) { +fun routeTo(route: String) { val url = "canvas-student://${CanvasNetworkAdapter.canvasDomain}/$route" val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -213,7 +216,7 @@ fun StudentTest.routeTo(route: String) { context.startActivity(intent) } -fun StudentTest.routeTo(route: String, domain: String) { +fun routeTo(route: String, domain: String) { val url = "canvas-student://$domain/$route" val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -223,39 +226,41 @@ fun StudentTest.routeTo(route: String, domain: String) { context.startActivity(intent) } -fun StudentTest.routeTo(route: Route, activity: FragmentActivity) { +fun routeTo(route: Route, activity: FragmentActivity) { RouteMatcher.route(activity, route) } -fun StudentTest.seedAssignmentSubmission( - submissionSeeds: List, - assignmentId: Long, - courseId: Long, - studentToken: String, - commentSeeds: List = kotlin.collections.emptyList() +fun seedAssignmentSubmission( + submissionSeeds: List, + assignmentId: Long, + courseId: Long, + studentToken: String, + commentSeeds: List = emptyList() ): List { // Upload one submission file for each submission seed submissionSeeds.forEach { it.attachmentsList.add( when (it.submissionType) { - SubmissionType.ONLINE_UPLOAD -> uploadTextFile(courseId, assignmentId, studentToken, + SubmissionType.ONLINE_UPLOAD -> uploadTextFile( + courseId, assignmentId, studentToken, FileUploadType.ASSIGNMENT_SUBMISSION ) else -> AttachmentApiModel(displayName="", fileName="", id=0L) // Not handled right now } - ); + ) } // Add attachments to comment seeds commentSeeds.forEach { - val fileAttachments: MutableList = kotlin.collections.mutableListOf() + val fileAttachments: MutableList = mutableListOf() for (i in 0..it.amount) { if (it.fileType != FileType.NONE) { fileAttachments.add(when (it.fileType) { - FileType.PDF -> kotlin.TODO() - FileType.TEXT -> uploadTextFile(courseId, assignmentId, studentToken, + FileType.PDF -> TODO() + FileType.TEXT -> uploadTextFile( + courseId, assignmentId, studentToken, FileUploadType.COMMENT_ATTACHMENT ) else -> throw RuntimeException("Unknown file type passed into StudentTest.seedAssignmentSubmission") // Unknown type @@ -278,7 +283,12 @@ fun StudentTest.seedAssignmentSubmission( return SubmissionsApi.seedAssignmentSubmission(submissionRequest) } -fun StudentTest.uploadTextFile(courseId: Long, assignmentId: Long, token: String, fileUploadType: FileUploadType): AttachmentApiModel { +fun uploadTextFile( + courseId: Long, + assignmentId: Long? = null, + token: String, + fileUploadType: FileUploadType +): AttachmentApiModel { // Create the file val file = File( diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt new file mode 100644 index 0000000000..8207e383df --- /dev/null +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt @@ -0,0 +1,63 @@ +// +// Copyright (C) 2024-present Instructure, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +package com.instructure.dataseeding.api + +import com.instructure.dataseeding.model.CourseFolderUploadApiModel +import com.instructure.dataseeding.model.CourseFolderUploadApiRequestModel +import com.instructure.dataseeding.model.CourseRootFolderApiModel +import com.instructure.dataseeding.util.CanvasNetworkAdapter +import retrofit2.Call +import retrofit2.http.* + +object FileFolderApi { + interface FileFolderService { + + @GET("courses/{courseId}/folders/root") + fun getCourseRootFolder( + @Path("courseId") courseId: Long): Call + + @POST("folders/{folderId}/folders") + fun createCourseFolder( + @Path("folderId") folderId: Long, + @Body courseFolderUploadApiRequestModel: CourseFolderUploadApiRequestModel): Call + + } + + private fun fileFolderService(token: String): FileFolderService + = CanvasNetworkAdapter.retrofitWithToken(token).create(FileFolderService::class.java) + + fun getCourseRootFolder(courseId: Long, token: String): CourseRootFolderApiModel { + return fileFolderService(token) + .getCourseRootFolder(courseId = courseId) + .execute() + .body()!! + } + + fun createCourseFolder(folderId: Long, name: String, locked: Boolean, token: String): CourseFolderUploadApiModel { + val courseFolderUploadRequestModel = CourseFolderUploadApiRequestModel( + name = name, + locked = locked + ) + val createFolderUploadApiModel: CourseFolderUploadApiModel = fileFolderService(token) + .createCourseFolder(folderId, courseFolderUploadRequestModel) + .execute() + .body()!! + return createFolderUploadApiModel + } + +} diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt index 7c967d8394..ef7404cfc2 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileUploadsApi.kt @@ -44,6 +44,11 @@ object FileUploadsApi { @Path("assignmentId") assignmentId: Long, @Body startFileUpload: StartFileUpload): Call + @POST("folders/{folderId}/files") + fun courseFolderFileUpload( + @Path("folderId") folderId: Long?, + @Body startFileUpload: StartFileUpload): Call + @Multipart @POST fun uploadFile( @@ -66,14 +71,14 @@ object FileUploadsApi { * Start here to upload a file! */ fun uploadFile(courseId: Long, - assignmentId: Long, + assignmentId: Long? = null, file: ByteArray, fileName: String, token: String, fileUploadType: FileUploadType): AttachmentApiModel { // Get file upload params from Canvas, telling us where to upload the file - val params = getFileUploadParams(courseId, assignmentId, file, fileName, fileUploadType, token) + val params = getFileUploadParams(courseId = courseId, assignmentId= assignmentId, file = file, fileName = fileName, fileUploadType = fileUploadType, token = token) // Upload the file based on the params we got back; return the resulting attachment return uploadFileToCanvas(params, file) @@ -94,7 +99,7 @@ object FileUploadsApi { } private fun getFileUploadParams(courseId: Long, - assignmentId: Long, + assignmentId: Long? = null, file: ByteArray, fileName: String, fileUploadType: FileUploadType, @@ -103,8 +108,9 @@ object FileUploadsApi { val request = StartFileUpload(fileName, file.size.toLong()) return when (fileUploadType) { - FileUploadType.ASSIGNMENT_SUBMISSION -> fileUploadsService(token).assignmentSubmissionFileUpload(courseId, assignmentId, request) - FileUploadType.COMMENT_ATTACHMENT -> fileUploadsService(token).commentAttachmentFileUpload(courseId, assignmentId, request) + FileUploadType.ASSIGNMENT_SUBMISSION -> fileUploadsService(token).assignmentSubmissionFileUpload(courseId, assignmentId!!, request) + FileUploadType.COMMENT_ATTACHMENT -> fileUploadsService(token).commentAttachmentFileUpload(courseId, assignmentId!!, request) + FileUploadType.COURSE_FILE -> fileUploadsService(token).courseFolderFileUpload(courseId, request) else -> TODO() }.execute().body()!! } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseFolderUploadApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseFolderUploadApiModel.kt new file mode 100644 index 0000000000..456a94b3d4 --- /dev/null +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseFolderUploadApiModel.kt @@ -0,0 +1,39 @@ +// +// Copyright (C) 2024-present Instructure, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package com.instructure.dataseeding.model + +import com.google.gson.annotations.SerializedName + +data class CourseFolderUploadApiRequestModel( + @SerializedName("name") + val name: String, + @SerializedName("locked") + val locked: Boolean = false +) + +data class CourseFolderUploadApiModel( + @SerializedName("id") + val id: Long, + @SerializedName("context_type") + val contextType: String, + @SerializedName("parent_folder_id") + val parentFolderId: Long, + @SerializedName("name") + val name: String, + @SerializedName("locked") + val locked: Boolean = false +) \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseRootFolderApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseRootFolderApiModel.kt new file mode 100644 index 0000000000..13aa27a0c6 --- /dev/null +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/CourseRootFolderApiModel.kt @@ -0,0 +1,30 @@ +// +// Copyright (C) 2024-present Instructure, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package com.instructure.dataseeding.model + +import com.google.gson.annotations.SerializedName + +data class CourseRootFolderApiModel( + @SerializedName("id") + val id: Long, + @SerializedName("context_type") + val contextType: String, + @SerializedName("context_id") + val contextId: Long, + @SerializedName("name") + val name: String +) \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt index eecbb26889..2d348251c7 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/FileUploadType.kt @@ -17,5 +17,6 @@ package com.instructure.dataseeding.model enum class FileUploadType { ASSIGNMENT_SUBMISSION, - COMMENT_ATTACHMENT + COMMENT_ATTACHMENT, + COURSE_FILE } From 17e499ce26e946566f05bdec32b6661b68200679 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Wed, 24 Jan 2024 10:24:38 +0100 Subject: [PATCH 03/52] [MBL-17278][Teacher] Bulk module update snackbars (#2310) Test plan: See ticket for design. Test if the correct snackbar shows for every event. The 'Can't publish...' snackbars are blocked by api changes, we'll revisit them when the apis are ready. refs: MBL-17278 affects: Teacher release note: none --- .../modules/list/ModuleListEffectHandler.kt | 35 +-- .../features/modules/list/ModuleListModels.kt | 12 +- .../features/modules/list/ModuleListUpdate.kt | 92 ++++++-- .../modules/list/ui/ModuleListView.kt | 6 + .../list/ModuleListEffectHandlerTest.kt | 22 +- .../unit/modules/list/ModuleListUpdateTest.kt | 202 +++++++++++++++++- libs/pandares/src/main/res/values/strings.xml | 8 + 7 files changed, 335 insertions(+), 42 deletions(-) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt index 9ecba0488d..0b9a7dd293 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt @@ -66,7 +66,8 @@ class ModuleListEffectHandler( effect.canvasContext, effect.moduleIds, effect.action, - effect.skipContentTags + effect.skipContentTags, + allModules = effect.allModules ) is ModuleListEffect.UpdateModuleItem -> updateModuleItem( @@ -75,6 +76,9 @@ class ModuleListEffectHandler( effect.itemId, effect.published ) + is ModuleListEffect.ShowSnackbar -> { + view?.showSnackbar(effect.message) + } }.exhaustive } @@ -187,7 +191,8 @@ class ModuleListEffectHandler( moduleIds: List, action: BulkModuleUpdateAction, skipContentTags: Boolean, - async: Boolean = true + async: Boolean = true, + allModules: Boolean ) { launch { val restParams = RestParams( @@ -204,13 +209,23 @@ class ModuleListEffectHandler( restParams ).dataOrNull?.progress - progress?.progress?.let { - trackUpdateProgress(it) - } ?: consumer.accept(ModuleListEvent.BulkUpdateFailed) + val bulkUpdateProgress = progress?.progress + if (bulkUpdateProgress == null) { + consumer.accept(ModuleListEvent.BulkUpdateFailed(skipContentTags)) + return@launch + } + + val success = trackUpdateProgress(bulkUpdateProgress) + + if (success) { + consumer.accept(ModuleListEvent.BulkUpdateSuccess(skipContentTags, action, allModules)) + } else { + consumer.accept(ModuleListEvent.BulkUpdateFailed(skipContentTags)) + } } } - private suspend fun trackUpdateProgress(progress: Progress) { + private suspend fun trackUpdateProgress(progress: Progress): Boolean { val params = RestParams(isForceReadFromNetwork = true) val result = poll(500, maxAttempts = -1, @@ -225,11 +240,7 @@ class ModuleListEffectHandler( newProgress }) - if (result?.hasRun == true && result.isCompleted) { - consumer.accept(ModuleListEvent.BulkUpdateSuccess) - } else { - consumer.accept(ModuleListEvent.BulkUpdateFailed) - } + return result?.hasRun == true && result.isCompleted } private fun updateModuleItem(canvasContext: CanvasContext, moduleId: Long, itemId: Long, published: Boolean) { @@ -248,7 +259,7 @@ class ModuleListEffectHandler( ).dataOrNull moduleItem?.let { - consumer.accept(ModuleListEvent.ModuleItemUpdateSuccess(it)) + consumer.accept(ModuleListEvent.ModuleItemUpdateSuccess(it, published)) } ?: consumer.accept(ModuleListEvent.ModuleItemUpdateFailed(itemId)) } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt index 89f7bf5dc4..07871e8e88 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt @@ -16,6 +16,7 @@ */ package com.instructure.teacher.features.modules.list +import androidx.annotation.StringRes import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject @@ -25,8 +26,6 @@ import com.instructure.canvasapi2.utils.isValid sealed class ModuleListEvent { object PullToRefresh : ModuleListEvent() object NextPageRequested : ModuleListEvent() - object BulkUpdateSuccess : ModuleListEvent() - object BulkUpdateFailed : 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() @@ -37,8 +36,10 @@ sealed class ModuleListEvent { data class BulkUpdateModule(val moduleId: Long, val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : ModuleListEvent() data class BulkUpdateAllModules(val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : ModuleListEvent() data class UpdateModuleItem(val itemId: Long, val isPublished: Boolean) : ModuleListEvent() - data class ModuleItemUpdateSuccess(val item: ModuleItem): ModuleListEvent() + data class ModuleItemUpdateSuccess(val item: ModuleItem, val published: Boolean): ModuleListEvent() data class ModuleItemUpdateFailed(val itemId: Long): ModuleListEvent() + data class BulkUpdateSuccess(val skipContentTags: Boolean, val action: BulkModuleUpdateAction, val allModules: Boolean) : ModuleListEvent() + data class BulkUpdateFailed(val skipContentTags: Boolean) : ModuleListEvent() } sealed class ModuleListEffect { @@ -65,7 +66,8 @@ sealed class ModuleListEffect { val canvasContext: CanvasContext, val moduleIds: List, val action: BulkModuleUpdateAction, - val skipContentTags: Boolean + val skipContentTags: Boolean, + val allModules: Boolean ) : ModuleListEffect() data class UpdateModuleItem( @@ -74,6 +76,8 @@ sealed class ModuleListEffect { val itemId: Long, val published: Boolean ) : ModuleListEffect() + + data class ShowSnackbar(@StringRes val message: Int) : ModuleListEffect() } data class ModuleListModel( diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt index a2427835e7..9c1689770d 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt @@ -16,8 +16,10 @@ */ package com.instructure.teacher.features.modules.list +import androidx.annotation.StringRes import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.utils.patchedBy +import com.instructure.teacher.R import com.instructure.teacher.mobius.common.ui.UpdateInit import com.spotify.mobius.First import com.spotify.mobius.Next @@ -52,10 +54,12 @@ class ModuleListUpdate : UpdateInit { val item = model.modules.flatMap { it.items }.first { it.id == event.moduleItemId } return Next.dispatch(setOf(ModuleListEffect.ShowModuleItemDetailView(item, model.course))) } + is ModuleListEvent.PageLoaded -> { val effects = mutableSetOf() var newModel = model.copy( @@ -67,7 +71,8 @@ class ModuleListUpdate : UpdateInit module.items.any { it.id == model.scrollToItemId } }) { + && newModules.any { module -> module.items.any { it.id == model.scrollToItemId } } + ) { newModel = newModel.copy(scrollToItemId = null) effects += ModuleListEffect.ScrollToItem(model.scrollToItemId) } @@ -75,6 +80,7 @@ class ModuleListUpdate : UpdateInit { return if (model.isLoading || !model.pageData.hasMorePages) { // Do nothing if we're already loading or all pages have loaded @@ -89,13 +95,19 @@ class ModuleListUpdate : UpdateInit { - return Next.dispatch(setOf(ModuleListEffect.MarkModuleExpanded( - model.course, - event.moduleId, - event.isExpanded - ))) + return Next.dispatch( + setOf( + ModuleListEffect.MarkModuleExpanded( + model.course, + event.moduleId, + event.isExpanded + ) + ) + ) } + is ModuleListEvent.ModuleItemLoadStatusChanged -> { return Next.next( model.copy( @@ -107,6 +119,7 @@ class ModuleListUpdate : UpdateInit { val items = model.modules.flatMap { it.items }.filter { it.type == event.type }.filter(event.predicate) return if (items.isEmpty()) { @@ -116,6 +129,7 @@ class ModuleListUpdate : UpdateInit { val itemGroups = event.items.groupBy { it.moduleId } val newModel = model.copy( @@ -130,6 +144,7 @@ class ModuleListUpdate : UpdateInit { val newModel = model.copy( modules = model.modules.map { module -> @@ -141,6 +156,7 @@ class ModuleListUpdate : UpdateInit { val affectedIds = mutableListOf(event.moduleId) if (!event.skipContentTags) { @@ -156,10 +172,12 @@ class ModuleListUpdate : UpdateInit { val affectedIds = mutableListOf() affectedIds.addAll(model.modules.map { it.id }) @@ -174,10 +192,12 @@ class ModuleListUpdate : UpdateInit { val newModel = model.copy( isLoading = true, @@ -190,14 +210,24 @@ class ModuleListUpdate : UpdateInit { val newModel = model.copy( loadingModuleItemIds = emptySet() ) - return Next.next(newModel) + + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.errorOccurred) + + return Next.next(newModel, setOf(snackbarEffect)) } + is ModuleListEvent.UpdateModuleItem -> { val newModel = model.copy( loadingModuleItemIds = model.loadingModuleItemIds + event.itemId @@ -210,6 +240,7 @@ class ModuleListUpdate : UpdateInit { val newModel = model.copy( modules = model.modules.map { module -> @@ -221,13 +252,50 @@ class ModuleListUpdate : UpdateInit { val newModel = model.copy( loadingModuleItemIds = model.loadingModuleItemIds - event.itemId ) - return Next.next(newModel) + + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.errorOccurred) + + return Next.next(newModel, setOf(snackbarEffect)) + } + } + } + + @StringRes + private fun getBulkUpdateSnackbarMessage( + action: BulkModuleUpdateAction, + skipContentTags: Boolean, + allModules: Boolean + ): Int { + return if (allModules) { + if (action == BulkModuleUpdateAction.PUBLISH) { + if (skipContentTags) { + R.string.onlyModulesPublished + } else { + R.string.allModulesAndAllItemsPublished + } + } else { + R.string.allModulesAndAllItemsUnpublished + } + } else { + if (action == BulkModuleUpdateAction.PUBLISH) { + if (skipContentTags) { + R.string.onlyModulePublished + } else { + R.string.moduleAndAllItemsPublished + } + } else { + R.string.moduleAndAllItemsUnpublished } } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt index 513fe23619..cd86aed154 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt @@ -18,9 +18,11 @@ package com.instructure.teacher.features.modules.list.ui import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleItem import com.instructure.pandarecycler.PaginatedScrollListener @@ -169,4 +171,8 @@ class ModuleListView( .setNegativeButton(negativeButton) { _, _ -> } .showThemed() } + + fun showSnackbar(@StringRes message: Int) { + Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() + } } diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt index 92fa4dac98..eb5db8d2c7 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt @@ -291,12 +291,12 @@ class ModuleListEffectHandlerTest : Assert() { @Test fun `BulkUpdateModules results in correct success event`() { val pageModules = makeModulePage() - val expectedEvent = ModuleListEvent.BulkUpdateSuccess + val expectedEvent = ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.PUBLISH, false) coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Success(mockk(relaxed = true)) coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(Progress(1L, workflowState = "completed")) - connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false)) + connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false, false)) verify(timeout = 1000) { consumer.accept(expectedEvent) } confirmVerified(consumer) @@ -305,11 +305,11 @@ class ModuleListEffectHandlerTest : Assert() { @Test fun `BulkUpdateModules results in correct failed event when call fails`() { val pageModules = makeModulePage() - val expectedEvent = ModuleListEvent.BulkUpdateFailed + val expectedEvent = ModuleListEvent.BulkUpdateFailed(false) coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Fail() - connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false)) + connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false, false)) verify(timeout = 1000) { consumer.accept(expectedEvent) } confirmVerified(consumer) @@ -318,12 +318,12 @@ class ModuleListEffectHandlerTest : Assert() { @Test fun `BulkUpdateModules results in correct failed event when progress fails`() { val pageModules = makeModulePage() - val expectedEvent = ModuleListEvent.BulkUpdateFailed + val expectedEvent = ModuleListEvent.BulkUpdateFailed(false) coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Success(mockk(relaxed = true)) coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(Progress(1L, workflowState = "failed")) - connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false)) + connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false, false)) verify(timeout = 1000) { consumer.accept(expectedEvent) } confirmVerified(consumer) @@ -333,7 +333,7 @@ class ModuleListEffectHandlerTest : Assert() { fun `UpdateModuleItem results in correct success event`() { val moduleId = 1L val itemId = 2L - val expectedEvent = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(id = itemId, moduleId = moduleId, published = true)) + val expectedEvent = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(id = itemId, moduleId = moduleId, published = true), true) coEvery { moduleApi.publishModuleItem(any(), any(), any(), any(), any(), any()) } returns DataResult.Success(ModuleItem(2L, 1L, published = true)) @@ -357,6 +357,14 @@ class ModuleListEffectHandlerTest : Assert() { confirmVerified(consumer) } + @Test + fun `ShowSnackbar calls showSnackbar on view`() { + val message = 123 + connection.accept(ModuleListEffect.ShowSnackbar(message)) + verify(timeout = 100) { view.showSnackbar(message) } + confirmVerified(view) + } + private fun makeModulePage( moduleCount: Int = 3, itemsPerModule: Int = 3, diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt index 5fcfc36137..15cba19b91 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt @@ -42,6 +42,7 @@ import io.mockk.mockkObject import io.mockk.unmockkObject import org.junit.Assert import org.junit.Test +import com.instructure.teacher.R class ModuleListUpdateTest : Assert() { @@ -427,7 +428,8 @@ class ModuleListUpdateTest : Assert() { expectedModel.course, listOf(1L), BulkModuleUpdateAction.PUBLISH, - true + true, + false ) updateSpec @@ -453,6 +455,7 @@ class ModuleListUpdateTest : Assert() { expectedModel.course, listOf(1L), BulkModuleUpdateAction.PUBLISH, + false, false ) @@ -487,6 +490,7 @@ class ModuleListUpdateTest : Assert() { expectedModel.course, listOf(1L, 2L), BulkModuleUpdateAction.UNPUBLISH, + true, true ) @@ -521,7 +525,8 @@ class ModuleListUpdateTest : Assert() { expectedModel.course, listOf(1L, 2L), BulkModuleUpdateAction.UNPUBLISH, - false + false, + true ) updateSpec @@ -553,13 +558,194 @@ class ModuleListUpdateTest : Assert() { expectedModel.pageData, expectedModel.scrollToItemId ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.onlyModulesPublished) updateSpec .given(model) - .whenEvent(ModuleListEvent.BulkUpdateSuccess) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(true, BulkModuleUpdateAction.PUBLISH, true)) .then( assertThatNext( hasModel(expectedModel), - matchesEffects(expectedEffect) + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish all modules and items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + loadingModuleItemIds = setOf(1L, 100L, 2L, 200L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.allModulesAndAllItemsPublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.PUBLISH, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess unpublish all modules and items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + loadingModuleItemIds = setOf(1L, 100L, 2L, 200L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.allModulesAndAllItemsUnpublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.UNPUBLISH, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish all modules displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + loadingModuleItemIds = setOf(1L, 2L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.onlyModulesPublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(true, BulkModuleUpdateAction.PUBLISH, true)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish single module with all items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + loadingModuleItemIds = setOf(1L, 100L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.moduleAndAllItemsPublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.PUBLISH, false)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess unpublish single module with all items displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + loadingModuleItemIds = setOf(1L, 100L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.moduleAndAllItemsUnpublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.UNPUBLISH, false)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) + ) + ) + } + + @Test + fun `BulkUpdateSuccess publish single module displays correct snackbar`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + loadingModuleItemIds = setOf(1L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.onlyModulePublished) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateSuccess(true, BulkModuleUpdateAction.PUBLISH, false)) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, expectedSnackbarEffect) ) ) } @@ -574,13 +760,15 @@ class ModuleListUpdateTest : Assert() { val expectedModel = model.copy( loadingModuleItemIds = emptySet() ) + val expectedSnackbarEffect = ModuleListEffect.ShowSnackbar(R.string.errorOccurred) updateSpec .given(model) - .whenEvent(ModuleListEvent.BulkUpdateFailed) + .whenEvent(ModuleListEvent.BulkUpdateFailed(false)) .then( assertThatNext( - hasModel(expectedModel) + hasModel(expectedModel), + matchesEffects(expectedSnackbarEffect) ) ) } @@ -620,7 +808,7 @@ class ModuleListUpdateTest : Assert() { modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = true)))), loadingModuleItemIds = emptySet() ) - val event = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(100L, 1L, published = true)) + val event = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(100L, 1L, published = true), true) updateSpec .given(model) .whenEvent(event) diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index f85510676d..883668d91e 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1512,6 +1512,14 @@ This will make all modules and items visible to students. This will make only the modules visible to students. This will make all modules and items invisible to students. + Item published + Item unpublished + Only Module published + Module and all Items published + Module and all Items unpublished + Only Modules published + All Modules and all Items published + All Modules and all Items unpublished %.0f pt %.0f pts From b48e6a3f8462d56bdc1e2dec4abfbfb0a4bdfbe7 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 25 Jan 2024 10:58:58 +0100 Subject: [PATCH 04/52] [MBL-16500][Student][Teacher] - Refactor MockCanvas addAssignment method to handle multiple submission types (as a list) (#2311) --- .../AssignmentDetailsInteractionTest.kt | 42 +++++++++- .../AssignmentListInteractionTest.kt | 4 +- .../ui/interaction/BookmarkInteractionTest.kt | 2 +- .../CourseGradesInteractionTest.kt | 2 +- .../interaction/DiscussionsInteractionTest.kt | 6 +- .../ui/interaction/HomeroomInteractionTest.kt | 8 +- .../ImportantDatesInteractionTest.kt | 8 +- .../ui/interaction/ModuleInteractionTest.kt | 14 ++-- .../NotificationInteractionTest.kt | 2 +- .../ui/interaction/PdfInteractionTest.kt | 2 +- .../PickerSubmissionUploadInteractionTest.kt | 2 +- .../ui/interaction/ScheduleInteractionTest.kt | 14 ++-- .../ShareExtensionInteractionTest.kt | 8 +- .../SubmissionDetailsInteractionTest.kt | 8 +- .../ui/interaction/SyllabusInteractionTest.kt | 10 +-- .../ui/interaction/TodoInteractionTest.kt | 10 +-- .../student/ui/pages/AssignmentDetailsPage.kt | 18 +++++ .../teacher/ui/AssigneeListPageTest.kt | 2 +- .../teacher/ui/AssignmentDetailsPageTest.kt | 12 ++- .../teacher/ui/AssignmentDueDatesPageTest.kt | 2 +- .../teacher/ui/AssignmentListPageTest.kt | 2 +- .../ui/AssignmentSubmissionListPageTest.kt | 8 +- .../teacher/ui/CommentLibraryPageTest.kt | 2 +- .../ui/EditAssignmentDetailsPageTest.kt | 4 +- .../teacher/ui/EditSyllabusPageTest.kt | 2 +- .../teacher/ui/SpeedGraderCommentsPageTest.kt | 65 +++++++++------- .../teacher/ui/SpeedGraderFilesPageTest.kt | 8 +- .../teacher/ui/SpeedGraderGradePageTest.kt | 2 +- .../teacher/ui/SpeedGraderPageTest.kt | 67 ++++++++-------- .../teacher/ui/SyllabusPageTest.kt | 9 ++- .../dataseeding/model/SubmissionType.kt | 3 +- .../canvas/espresso/TestMetaData.kt | 2 +- .../canvas/espresso/mockCanvas/MockCanvas.kt | 76 ++++++++++++++----- 33 files changed, 276 insertions(+), 150 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt index 6854424b64..38edfa87c9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt @@ -28,6 +28,7 @@ import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.utils.toApiString +import com.instructure.dataseeding.model.SubmissionType import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.routeTo import com.instructure.student.ui.utils.tokenLogin @@ -53,7 +54,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { val course = data.courses.values.first() val student = data.students[0] val token = data.tokenFor(student)!! - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_URL) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL)) data.addSubmissionForAssignment( assignmentId = assignment.id, userId = data.users.values.first().id, @@ -118,7 +119,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.COMMON, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) - fun testDisplayBookmarMenu() { + fun testDisplayBookmarkMenu() { val data = setUpData() goToAssignmentList() val assignmentList = data.assignments @@ -349,6 +350,41 @@ class AssignmentDetailsInteractionTest : StudentTest() { assignmentDetailsPage.assertScoreNotDisplayed() } + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_MULTIPLE_TYPE) + fun testSubmission_multipleSubmissionType() { + val data = MockCanvas.init( + studentCount = 1, + courseCount = 1 + ) + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY, Assignment.SubmissionType.ONLINE_UPLOAD, Assignment.SubmissionType.MEDIA_RECORDING, Assignment.SubmissionType.DISCUSSION_TOPIC, Assignment.SubmissionType.ONLINE_URL)) + data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = data.users.values.first().id, + type = Assignment.SubmissionType.ONLINE_URL.apiString + ) + tokenLogin(data.domain, token, student) + routeTo("courses/${course.id}/assignments", data.domain) + assignmentListPage.waitForPage() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickSubmit() + + assignmentDetailsPage.assertSubmissionTypeDisplayed("Text Entry") + assignmentDetailsPage.assertSubmissionTypeDisplayed("Website URL") + assignmentDetailsPage.assertSubmissionTypeDisplayed("File Upload") + assignmentDetailsPage.assertSubmissionTypeDisplayed("Media Recording") + + //Try 1 submission to check if it's possible to submit even when there are multiple submission types available. + assignmentDetailsPage.selectSubmissionType(SubmissionType.ONLINE_URL) + urlSubmissionUploadPage.submitText("https://google.com") + assignmentDetailsPage.assertStatusSubmitted() + } + private fun setUpData(restrictQuantitativeData: Boolean = false): MockCanvas { // Test clicking on the Submission and Rubric button to load the Submission Details Page val data = MockCanvas.init( @@ -401,7 +437,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", pointsPossible = maxScore, ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt index 38fd1537a7..fab2876a38 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt @@ -226,7 +226,7 @@ class AssignmentListInteractionTest : StudentTest() { repeat(assignmentCount) { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) assignmentList.add(assignment) } @@ -254,7 +254,7 @@ class AssignmentListInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", pointsPossible = maxScore, ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt index 78c0a552c3..ec8cac9747 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/BookmarkInteractionTest.kt @@ -115,7 +115,7 @@ class BookmarkInteractionTest : StudentTest() { val course = data.courses.values.first() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) val token = data.tokenFor(student)!! diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt index a305f58b66..f989450db0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt @@ -248,7 +248,7 @@ class CourseGradesInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", pointsPossible = maxScore, ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt index dcdf27fe50..25310d5be8 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt @@ -608,7 +608,7 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), name = assignmentName, pointsPossible = 12 ) @@ -649,7 +649,7 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), name = assignmentName, pointsPossible = 12 ) @@ -694,7 +694,7 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), name = assignmentName, pointsPossible = 12 ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt index a139f5d4c9..fa08c724fc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt @@ -234,8 +234,8 @@ class HomeroomInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) goToHomeroomTab(data) @@ -259,8 +259,8 @@ class HomeroomInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) goToHomeroomTab(data) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt index 28a43cb393..53b95676a4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt @@ -64,7 +64,7 @@ class ImportantDatesInteractionTest : StudentTest() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val assignmentScheduleItem = data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, assignment.name!!, assignment.description!!, true, assignment) goToImportantDatesTab(data) @@ -132,7 +132,7 @@ class ImportantDatesInteractionTest : StudentTest() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val assignmentScheduleItem = data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, assignment.name!!, assignment.description!!, true, assignment) goToImportantDatesTab(data) @@ -152,7 +152,7 @@ class ImportantDatesInteractionTest : StudentTest() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, assignment.name!!, assignment.description!!, true, assignment) val calendarEvent = data.addCourseCalendarEvent(course.id, 2.days.fromNow.iso8601, "Important event", "Important event description", true) @@ -176,7 +176,7 @@ class ImportantDatesInteractionTest : StudentTest() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val twoDaysFromNowEvent = data.addAssignmentCalendarEvent(course.id, 2.days.fromNow.iso8601, "Important event two days later", "Important event two days later description", true, assignment) val threeDaysFromNowEvent = data.addCourseCalendarEvent(course.id, diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt index 2114a83fdc..a4af822a17 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt @@ -81,7 +81,7 @@ class ModuleInteractionTest : StudentTest() { // Create an assignment and add it as a module item assignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -324,7 +324,7 @@ class ModuleInteractionTest : StudentTest() { // Create an assignment and add it as a module item assignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -385,7 +385,7 @@ class ModuleInteractionTest : StudentTest() { // Create an assignment and add it as a module item assignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -501,7 +501,7 @@ class ModuleInteractionTest : StudentTest() { // And let's add an assignment to the new module var unavailableAssignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), // Man, this is a bit hokey, but it's what I had to do to get the assignment to show // up as unavailable in the assignment details page lockInfo = LockInfo( @@ -541,7 +541,7 @@ class ModuleInteractionTest : StudentTest() { // And let's create an assignment and add it to the "locked" module. val lockedAssignment = data.addAssignment( courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addItemToModule( course = course1, @@ -568,7 +568,7 @@ class ModuleInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 10 ) @@ -595,7 +595,7 @@ class ModuleInteractionTest : StudentTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 10 ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt index f11a079a02..adde4b4802 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt @@ -255,7 +255,7 @@ class NotificationInteractionTest : StudentTest() { repeat(numSubmissions) { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), gradingType = Assignment.gradingTypeToAPIString(gradingType).orEmpty(), pointsPossible = 20 ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt index 083442ccdd..8c61146e4e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PdfInteractionTest.kt @@ -153,7 +153,7 @@ class PdfInteractionTest : StudentTest() { val pdfUrlElementId = "testLinkElement" val assignmentDescriptionHtml = """pdf baby!!!""" - val assignment = data.addAssignment(courseId = course.id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD, description = assignmentDescriptionHtml) + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD), description = assignmentDescriptionHtml) assignmentListPage.waitForPage() assignmentListPage.refresh() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt index 05d7ca62d1..333abfc1da 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt @@ -155,7 +155,7 @@ class PickerSubmissionUploadInteractionTest : StudentTest() { // Let's set up an assignment that requires an online upload val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_UPLOAD + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD) ) // Sign in diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt index 58abd6a093..2cfe7c7cbb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt @@ -83,7 +83,7 @@ class ScheduleInteractionTest : StudentTest() { courses[0].name = "Course 1" val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate, name = "Assignment 1") + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate, name = "Assignment 1") goToScheduleTab(data) schedulePage.scrollToPosition(10) @@ -101,7 +101,7 @@ class ScheduleInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) goToScheduleTab(data) schedulePage.scrollToPosition(12) @@ -140,8 +140,8 @@ class ScheduleInteractionTest : StudentTest() { schedulePage.assertNoScheduleItemDisplayed() val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) - val assignment2 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) + val assignment2 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) schedulePage.scrollToPosition(0) schedulePage.refresh() @@ -209,7 +209,7 @@ class ScheduleInteractionTest : StudentTest() { courses[0].name = "Course 1" val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate, name = "Assignment 1") + val assignment = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate, name = "Assignment 1") goToScheduleTab(data) schedulePage.scrollToPosition(9) @@ -228,7 +228,7 @@ class ScheduleInteractionTest : StudentTest() { val courses = data.courses.values.filter { !it.homeroomCourse } val currentDate = dateTimeProvider.getCalendar().time.toApiString() - data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate) + data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate) goToScheduleTab(data) schedulePage.scrollToPosition(8) @@ -265,7 +265,7 @@ class ScheduleInteractionTest : StudentTest() { courses[0].name = "Course 1" val currentDate = dateTimeProvider.getCalendar().time.toApiString() - val assignment1 = data.addAssignment(courses[0].id, submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, dueAt = currentDate, name = "Assignment 1") + val assignment1 = data.addAssignment(courses[0].id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = currentDate, name = "Assignment 1") goToScheduleTab(data) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt index f19c657307..13ab1f1da3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt @@ -131,7 +131,7 @@ class ShareExtensionInteractionTest : StudentTest() { val uri = setupFileOnDevice("sample.jpg") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val assignment = data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) + val assignment = data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) login(student) device.pressHome() @@ -182,8 +182,8 @@ class ShareExtensionInteractionTest : StudentTest() { val uri = setupFileOnDevice("sample.jpg") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) - val assignment2 = data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) + data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) + val assignment2 = data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) login(student) device.pressHome() @@ -239,7 +239,7 @@ class ShareExtensionInteractionTest : StudentTest() { val uri = setupFileOnDevice("sample.jpg") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - data.addAssignment(data.courses.values.first().id, submissionType = Assignment.SubmissionType.ONLINE_UPLOAD) + data.addAssignment(data.courses.values.first().id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) login(student) device.pressHome() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt index afedd20532..30203de91a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SubmissionDetailsInteractionTest.kt @@ -57,7 +57,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val data = getToCourse() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 10 ) @@ -102,7 +102,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val data = getToCourse() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL) ) courseBrowserPage.selectAssignments() @@ -124,7 +124,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val data = getToCourse() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), userSubmitted = true ) @@ -169,7 +169,7 @@ class SubmissionDetailsInteractionTest : StudentTest() { val user = data.users.values.first() val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) // Some html for an attachment diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt index 7e8f5e321a..8b9b613563 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/SyllabusInteractionTest.kt @@ -15,6 +15,10 @@ */ package com.instructure.student.ui.interaction +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.addCourseCalendarEvent @@ -26,10 +30,6 @@ import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -87,7 +87,7 @@ class SyllabusInteractionTest : StudentTest() { repeat(assignmentCount) { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 2.days.fromNow.iso8601 ) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt index 5b48a14d07..4e0d5abc4a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt @@ -16,8 +16,12 @@ package com.instructure.student.ui.interaction import androidx.test.espresso.Espresso +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.StubLandscape import com.instructure.canvas.espresso.StubMultiAPILevel +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse @@ -28,10 +32,6 @@ import com.instructure.canvasapi2.models.Quiz import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -104,7 +104,7 @@ class TodoInteractionTest : StudentTest() { for(course in data.courses.values) { assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601 ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt index 5164267d7f..5ac189a0f8 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt @@ -32,6 +32,7 @@ import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.stringContainsTextCaseInsensitive import com.instructure.canvas.espresso.waitForMatcherWithSleeps import com.instructure.canvasapi2.models.Assignment +import com.instructure.dataseeding.model.SubmissionType import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertContainsText import com.instructure.espresso.assertDisplayed @@ -221,6 +222,23 @@ open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { waitForView(allOf(withId(R.id.attemptTitle), withAncestor(withId(R.id.attemptSpinner)))).assertDisplayed() waitForView(allOf(withId(R.id.attemptDate), withAncestor(withId(R.id.attemptSpinner)))).assertDisplayed() } + + fun selectSubmissionType(submissionType: SubmissionType) { + val viewMatcher = when (submissionType) { + SubmissionType.ONLINE_TEXT_ENTRY -> withId(R.id.submissionEntryText) + SubmissionType.ONLINE_UPLOAD -> withId(R.id.submissionEntryFile) + SubmissionType.ONLINE_URL -> withId(R.id.submissionEntryWebsite) + SubmissionType.MEDIA_RECORDING -> withId(R.id.submissionEntryMedia) + + else -> {withId(R.id.submissionEntryText)} + } + + onView(viewMatcher).click() + } + + fun assertSubmissionTypeDisplayed(submissionType: String) { + onView(withText(submissionType) + withAncestor(R.id.customPanel)).assertDisplayed() + } } /** diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt index 2c84903a1b..925dede26e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssigneeListPageTest.kt @@ -87,7 +87,7 @@ class AssigneeListPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) val token = data.tokenFor(teacher)!! tokenLogin(data.domain, token, teacher) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt index fa1f934da9..935ec1fd2b 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt @@ -15,7 +15,11 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvas.espresso.mockCanvas.utils.Randomizer import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Assignment.SubmissionType @@ -41,7 +45,7 @@ class AssignmentDetailsPageTest : TeacherTest() { @TestRail(ID = "C3109579") override fun displaysPageObjects() { getToAssignmentDetailsPage( - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), + submissionTypes = listOf(ONLINE_TEXT_ENTRY), students = 1, withSubmission = true) assignmentDetailsPage.assertPageObjects() @@ -76,7 +80,7 @@ class AssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3134482") + @TestRail(ID = "C313448 2") fun displaysNoFromDate() { val lockAt = 7.days.fromNow.iso8601 getToAssignmentDetailsPage(lockAt = lockAt) @@ -155,7 +159,7 @@ class AssignmentDetailsPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = if(submissionTypes.isEmpty()) SubmissionType.ONLINE_TEXT_ENTRY else submissionTypes.first(), + submissionTypeList = submissionTypes.ifEmpty { listOf(ONLINE_TEXT_ENTRY) }, lockAt = lockAt, unlockAt = unlockAt, description = if(withDescription) Randomizer.randomCourseDescription() else "", diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt index 857ed54e7e..ca15ef0125 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt @@ -77,7 +77,7 @@ class AssignmentDueDatesPageTest : TeacherTest() { dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) val token = data.tokenFor(teacher)!! diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt index e65087514b..545c030706 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt @@ -89,7 +89,7 @@ class AssignmentListPageTest : TeacherTest() { repeat(assignments) { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), assignmentGroupId = assignmentGroup.id) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt index 271ce63154..09f2b53493 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentSubmissionListPageTest.kt @@ -15,7 +15,11 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.dataseeding.util.ago @@ -132,7 +136,7 @@ class AssignmentSubmissionListPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = dueAt ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt index 7832106162..3daf68f585 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CommentLibraryPageTest.kt @@ -255,7 +255,7 @@ class CommentLibraryPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) data.addSubmissionForAssignment( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt index dd07dd63c1..4713c578b8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt @@ -201,14 +201,12 @@ class EditAssignmentDetailsPageTest : TeacherTest() { CanvasContextPermission() // Just need to have some sort of permissions object registered ) - val assignment = data.addAssignment( courseId = course.id, withDescription = withDescription, lockAt = lockAt, unlockAt = unlockAt, - submissionType = submissionTypes.firstOrNull() - ?: Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = submissionTypes.ifEmpty { listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) } ) val token = data.tokenFor(teacher)!! diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt index ba7f29a88c..06aa6ac6d1 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditSyllabusPageTest.kt @@ -90,7 +90,7 @@ class EditSyllabusPageTest : TeacherTest() { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 2.days.fromNow.iso8601, name = "Assignment: 1" ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt index f479a552ab..cfaefe31a2 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderCommentsPageTest.kt @@ -15,8 +15,16 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionsForAssignment +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.models.SubmissionComment import com.instructure.espresso.randomString import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin @@ -38,7 +46,7 @@ class SpeedGraderCommentsPageTest : TeacherTest() { @Test override fun displaysPageObjects() { goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) speedGraderCommentsPage.assertPageObjects() @@ -46,42 +54,42 @@ class SpeedGraderCommentsPageTest : TeacherTest() { @Test fun displaysAuthorName() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), withComment = true ) - val authorName = submission!!.submissionComments[0].authorName!! + val authorName = submissionList?.get(0)!!.submissionComments[0].authorName!! speedGraderCommentsPage.assertDisplaysAuthorName(authorName) } @Test fun displaysCommentText() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), withComment = true ) - val commentText = submission!!.submissionComments[0].comment!! + val commentText = submissionList?.get(0)!!.submissionComments[0].comment!! speedGraderCommentsPage.assertDisplaysCommentText(commentText) } @Test fun displaysCommentAttachment() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), withComment = true, attachment = attachment ) - val attachment = submission!!.submissionComments[0].attachments.get(0) + val attachment = submissionList?.get(0)!!.submissionComments[0].attachments.get(0) speedGraderCommentsPage.assertDisplaysCommentAttachment(attachment) } @Test fun displaysSubmissionHistory() { goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) speedGraderCommentsPage.assertDisplaysSubmission() @@ -89,19 +97,19 @@ class SpeedGraderCommentsPageTest : TeacherTest() { @Test fun displaysSubmissionFile() { - val submission = goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_UPLOAD, + val submissionList = goToSpeedGraderCommentsPage( + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD), attachment = attachment ) - val fileAttachments = submission!!.attachments.get(0) + val fileAttachments = submissionList?.get(0)!!.attachments[0] speedGraderCommentsPage.assertDisplaysSubmissionFile(fileAttachments) } @Test fun addsNewTextComment() { goToSpeedGraderCommentsPage( - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) ) val newComment = randomString(32) @@ -113,7 +121,7 @@ class SpeedGraderCommentsPageTest : TeacherTest() { fun showsNoCommentsMessage() { goToSpeedGraderCommentsPage( submissionCount = 0, - submissionType = Assignment.SubmissionType.ON_PAPER + submissionTypeList = listOf(Assignment.SubmissionType.ON_PAPER) ) speedGraderCommentsPage.assertDisplaysEmptyState() @@ -123,17 +131,17 @@ class SpeedGraderCommentsPageTest : TeacherTest() { * Common setup routine * * [submissionCount] is the number of submissions for the created assignment. Typically 0 or 1. - * [submissionType] is the submission type for the assignment. + * [submissionTypeList] is the submission type for the assignment. * [withComment] if true, include a (student) comment with the submission. * [attachment] if non-null, is either a comment attachment (if withComment is true) or a submission * attachment (if withComment is false). * */ private fun goToSpeedGraderCommentsPage( - submissionCount: Int = 1, - submissionType: Assignment.SubmissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, - withComment: Boolean = false, - attachment: Attachment? = null): Submission? { + submissionCount: Int = 1, + submissionTypeList: List = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), + withComment: Boolean = false, + attachment: Attachment? = null): MutableList? { val data = MockCanvas.init(teacherCount = 1, studentCount = 1, courseCount = 1, favoriteCourseCount = 1) val teacher = data.teachers[0] @@ -147,7 +155,7 @@ class SpeedGraderCommentsPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = submissionType + submissionTypeList = submissionTypeList ) var submissionComment : SubmissionComment? = null @@ -163,12 +171,13 @@ class SpeedGraderCommentsPageTest : TeacherTest() { ) } - var submission: Submission? = null + var submissionList = mutableListOf() repeat(submissionCount) { - submission = data.addSubmissionForAssignment( + val submissionTypesRaw = submissionTypeList.map { it.apiString } + submissionList = data.addSubmissionsForAssignment( assignmentId = assignment.id, userId = student.id, - type = submissionType.apiString, + types = submissionTypesRaw, comment = submissionComment, attachment = if (withComment) null else attachment ) @@ -185,6 +194,6 @@ class SpeedGraderCommentsPageTest : TeacherTest() { speedGraderPage.selectCommentsTab() speedGraderPage.swipeUpCommentsTab() - return submission + return submissionList } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt index 929c21223e..f53301a282 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderFilesPageTest.kt @@ -15,7 +15,11 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Attachment import com.instructure.canvasapi2.models.CanvasContextPermission @@ -76,7 +80,7 @@ class SpeedGraderFilesPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_UPLOAD + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD) ) repeat(submissionCount) { diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt index 5aa322958a..f743f88297 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderGradePageTest.kt @@ -187,7 +187,7 @@ class SpeedGraderGradePageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = pointsPossible, gradingType = gradingType ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt index 35e39d13f7..81a03fd8b6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SpeedGraderPageTest.kt @@ -15,9 +15,16 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addSubmissionsForAssignment +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.Assignment.SubmissionType.* +import com.instructure.canvasapi2.models.Assignment.SubmissionType.EXTERNAL_TOOL +import com.instructure.canvasapi2.models.Assignment.SubmissionType.ONLINE_TEXT_ENTRY +import com.instructure.canvasapi2.models.Assignment.SubmissionType.ONLINE_URL +import com.instructure.canvasapi2.models.Assignment.SubmissionType.ON_PAPER import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TeacherTest @@ -36,13 +43,13 @@ class SpeedGraderPageTest : TeacherTest() { @Test fun displaysSubmissionDropDown() { - goToSpeedGraderPage(submissionType = ONLINE_TEXT_ENTRY, students = 1, submissions = listOf(2)) + goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_TEXT_ENTRY), students = 1, submissions = listOf(2)) speedGraderPage.assertHasSubmissionDropDown() } @Test fun opensToCorrectSubmission() { - val data = goToSpeedGraderPage(students = 4, submissionType = ONLINE_TEXT_ENTRY) + val data = goToSpeedGraderPage(students = 4, submissionTypeList = listOf(ONLINE_TEXT_ENTRY)) speedGraderPage.clickBackButton() val students = data.students for (i in 0 until students.size) { @@ -73,37 +80,37 @@ class SpeedGraderPageTest : TeacherTest() { @Test fun displaysTextSubmission() { - goToSpeedGraderPage(submissionType = ONLINE_TEXT_ENTRY, submissions = listOf(1)) + goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_TEXT_ENTRY), submissions = listOf(1)) speedGraderPage.assertDisplaysTextSubmissionView() } @Test fun displaysUnsubmittedEmptyState() { - goToSpeedGraderPage(submissionType = ONLINE_TEXT_ENTRY) + goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_TEXT_ENTRY)) speedGraderPage.assertDisplaysEmptyState(R.string.noSubmissionTeacher) } @Test fun displaysNoSubmissionsAllowedEmptyState() { - goToSpeedGraderPage(submissionType = Assignment.SubmissionType.NONE) + goToSpeedGraderPage(submissionTypeList = listOf(Assignment.SubmissionType.NONE)) speedGraderPage.assertDisplaysEmptyState(R.string.speedGraderNoneMessage) } @Test fun displaysOnPaperEmptyState() { - goToSpeedGraderPage(submissionType = ON_PAPER) + goToSpeedGraderPage(submissionTypeList = listOf(ON_PAPER)) speedGraderPage.assertDisplaysEmptyState(R.string.speedGraderOnPaperMessage) } @Test fun displaysExternalToolEmptyState() { - goToSpeedGraderPage(submissionType = EXTERNAL_TOOL) + goToSpeedGraderPage(submissionTypeList = listOf(EXTERNAL_TOOL)) speedGraderPage.assertDisplaysEmptyState(R.string.noSubmissionTeacher) } @Test fun displaysUrlSubmission() { - val data = goToSpeedGraderPage(submissionType = ONLINE_URL, submissions = listOf(1)) + val data = goToSpeedGraderPage(submissionTypeList = listOf(ONLINE_URL), submissions = listOf(1)) val assignment = data.assignments.values.first() val submission = data.submissions[assignment.id]!!.first() speedGraderPage.assertDisplaysUrlSubmissionLink(submission) @@ -111,10 +118,10 @@ class SpeedGraderPageTest : TeacherTest() { } private fun goToSpeedGraderPage( - students: Int = 1, - submissionType: Assignment.SubmissionType = Assignment.SubmissionType.NONE, - submissions: List = listOf(0), - selectStudent: Int = 0 + students: Int = 1, + submissionTypeList: List = listOf(Assignment.SubmissionType.NONE), + submissions: List = listOf(0), + selectStudent: Int = 0 ): MockCanvas { val data = MockCanvas.init(teacherCount = 1, studentCount = students, courseCount = 1, favoriteCourseCount = 1) val teacher = data.teachers[0] @@ -127,24 +134,24 @@ class SpeedGraderPageTest : TeacherTest() { val assignment = data.addAssignment( courseId = course.id, - submissionType = submissionType + submissionTypeList = submissionTypeList ) - val assignmentSubmissions = - (0 until submissions.size).map { - if(students < it + 1) throw Exception("student count does not agree with submissions") - val student = data.students[it] - val submissionCount = submissions[it] - repeat(submissionCount) { index -> - data.addSubmissionForAssignment( - assignmentId = assignment.id, - userId = student.id, - type = submissionType.apiString, - body = if(submissionType == Assignment.SubmissionType.ONLINE_URL) null else "AssignmentBody $index", - url = if(submissionType == Assignment.SubmissionType.ONLINE_URL) "www.google.com" else null - ) - } - } + (0 until submissions.size).map { + if(students < it + 1) throw Exception("student count does not agree with submissions") + val student = data.students[it] + val submissionCount = submissions[it] + val submissionTypesRaw = submissionTypeList.map { it.apiString } + repeat(submissionCount) { index -> + data.addSubmissionsForAssignment( + assignmentId = assignment.id, + userId = student.id, + types = submissionTypesRaw, + body = if(submissionTypesRaw.contains(Assignment.SubmissionType.ONLINE_URL.apiString)) null else "AssignmentBody $index", + url = if(submissionTypesRaw.contains(Assignment.SubmissionType.ONLINE_URL.apiString)) "www.google.com" else null + ) + } + } val token = data.tokenFor(teacher)!! tokenLogin(data.domain, token, teacher) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt index 50dc6a2d82..5cf04e871d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/SyllabusPageTest.kt @@ -16,7 +16,12 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCourseCalendarEvent +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addCourseSettings +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.canvasapi2.models.CourseSettings @@ -101,7 +106,7 @@ class SyllabusPageTest : TeacherTest() { repeat(assignmentCount) { data.addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 2.days.fromNow.iso8601, name = "Assignment: $it" ) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt index ed428d9257..dd640e2ece 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionType.kt @@ -23,5 +23,6 @@ enum class SubmissionType { ONLINE_TEXT_ENTRY, ONLINE_URL, EXTERNAL_TOOL, - DISCUSSION_TOPIC + DISCUSSION_TOPIC, + MEDIA_RECORDING } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt index 975a98505f..3fb7dce239 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt @@ -38,7 +38,7 @@ enum class FeatureCategory { enum class SecondaryFeatureCategory { NONE, LOGIN_K5, - SUBMISSIONS_TEXT_ENTRY, SUBMISSIONS_ANNOTATIONS, SUBMISSIONS_ONLINE_URL, + SUBMISSIONS_TEXT_ENTRY, SUBMISSIONS_ANNOTATIONS, SUBMISSIONS_ONLINE_URL, SUBMISSIONS_MULTIPLE_TYPE, ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, GROUPS_DASHBOARD, GROUPS_FILES, GROUPS_ANNOUNCEMENTS, GROUPS_DISCUSSIONS, GROUPS_PAGES, GROUPS_PEOPLE, EVENTS_DISCUSSIONS, EVENTS_QUIZZES, EVENTS_ASSIGNMENTS, EVENTS_NOTIFICATIONS, diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index 9b559020bb..2c81f9e357 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -375,7 +375,7 @@ fun MockCanvas.Companion.init( totalStudents = studentUsers.count() ) } - val course = data.addCourse( + data.addCourse( isFavorite = it < favoriteCourseCount, id = courseId, section = section, @@ -827,24 +827,24 @@ fun MockCanvas.addAssignmentsToGroups(course: Course, assignmentCountPerGroup: I for (i in 0 until assignmentCountPerGroup) { - val overdueAssignment = addAssignment( + addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = pastDueDate, assignmentGroupId = overdueAssignmentGroup.id ) - val upcomingAssignment = addAssignment( + addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = futureDueDate, assignmentGroupId = upcomingAssignmentGroup.id ) - val undatedAssignment = addAssignment( + addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = null, assignmentGroupId = undatedAssignmentGroup.id @@ -852,12 +852,12 @@ fun MockCanvas.addAssignmentsToGroups(course: Course, assignmentCountPerGroup: I val pastAssignment = addAssignment( courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_URL, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_URL), name = Randomizer.randomAssignmentName(), dueAt = pastDueDate, assignmentGroupId = pastAssignmentGroup.id ) - val pastSubmission = addSubmissionForAssignment( + addSubmissionForAssignment( assignmentId = pastAssignment.id, userId = users.values.first().id, type = Assignment.SubmissionType.ONLINE_URL.apiString, @@ -874,7 +874,7 @@ fun MockCanvas.addAssignmentsToGroups(course: Course, assignmentCountPerGroup: I */ fun MockCanvas.addAssignment( courseId: Long, - submissionType: Assignment.SubmissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + submissionTypeList: List = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), assignmentGroupId: Long = newItemId(), isQuizzesNext: Boolean = false, lockInfo : LockInfo? = null, @@ -889,12 +889,13 @@ fun MockCanvas.addAssignment( gradingType: String = "percent" ) : Assignment { val assignmentId = newItemId() + val submissionTypeListRawStrings = submissionTypeList.map { it.apiString } var assignment = Assignment( id = assignmentId, assignmentGroupId = assignmentGroupId, courseId = courseId, name = name, - submissionTypesRaw = listOf(submissionType.apiString), + submissionTypesRaw = submissionTypeListRawStrings, lockInfo = lockInfo, lockedForUser = lockInfo != null, userSubmitted = userSubmitted, @@ -921,7 +922,7 @@ fun MockCanvas.addAssignment( assignmentGroups[courseId] = assignmentGroupList } - var group = assignmentGroupList.find { it.id == assignmentGroupId } + val group = assignmentGroupList.find { it.id == assignmentGroupId } if (group == null) { assignmentGroupList.add(AssignmentGroup(id = assignmentGroupId, assignments = listOf(assignment))) } else { @@ -941,6 +942,45 @@ fun MockCanvas.addAssignment( return assignment } +/** + * Adds multiple (type) submissions to the assignment submission map. + */ +fun MockCanvas.addSubmissionsForAssignment( + assignmentId: Long, + userId: Long, + types: List, + body: String? = null, + url: String? = null, + attachment: Attachment? = null, + comment: SubmissionComment? = null, + state: String = "submitted", + grade: String? = null, + attempt: Long = 1, + score: Double? = null, + excused: Boolean = false): MutableList { + + val submissionList = mutableListOf() + types.forEach { type -> + val submission = addSubmissionForAssignment( + assignmentId, + userId, + type, + body, + url, + attachment, + comment, + state, + grade, + attempt, + score, + excused + ) + submissionList.add(submission) + } + + return submissionList + +} /** * Adds a submission to the assignment submission map. */ @@ -1253,7 +1293,7 @@ fun MockCanvas.addFolderToCourse( groupId: Long? = null ) : Long { // Find your root folder - var rootFolder = getRootFolder(courseId = courseId, groupId = groupId) + val rootFolder = getRootFolder(courseId = courseId, groupId = groupId) // Now create our folder metadata val newFolderId = newItemId() @@ -1303,7 +1343,7 @@ fun MockCanvas.addFileToFolder( if(courseId == null && folderId == null) { throw Exception("Either courseId or folderId must be non-null") } - var rootFolder = getRootFolder(courseId = courseId, folderId = folderId) + val rootFolder = getRootFolder(courseId = courseId, folderId = folderId) // Now create our file metadata val fileMetadataItem = FileFolder( @@ -1345,7 +1385,7 @@ fun MockCanvas.addFileToCourse( url: String = "", fileId: Long = newItemId() ): Long { - var rootFolder = getRootFolder(courseId = courseId, groupId = groupId) + val rootFolder = getRootFolder(courseId = courseId, groupId = groupId) return addFileToFolder( folderId = rootFolder.id, displayName = displayName, @@ -1563,7 +1603,7 @@ fun MockCanvas.addItemToModule( ) // Copy/update/replace the module - var newItemList = module.items.toMutableList() + val newItemList = module.items.toMutableList() newItemList.add(result) val changedModule = module.copy( items = newItemList @@ -1697,7 +1737,7 @@ fun MockCanvas.addQuestionToQuiz( fun MockCanvas.addQuizSubmission(quiz: Quiz, user: User, state: String = "untaken", grade: String? = null) : QuizSubmission { val now = Calendar.getInstance().time.time // ms - var quizSubmission = QuizSubmission( + val quizSubmission = QuizSubmission( id = newItemId(), quizId = quiz.id, userId = user.id, @@ -1710,7 +1750,7 @@ fun MockCanvas.addQuizSubmission(quiz: Quiz, user: User, state: String = "untake // Don't forget to add some sort of submission to the related assignment if(quiz.assignmentId != 0L) { - val assignment = assignments[quiz.assignmentId] + assignments[quiz.assignmentId] this.addSubmissionForAssignment(assignmentId = quiz.assignmentId, userId = user.id, type = "online_quiz", state = state, grade = grade) } From 1f24c06d09543ee4e1b23d97a5166870838c4717 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Mon, 29 Jan 2024 12:55:01 +0100 Subject: [PATCH 05/52] [MBL-17271][Student] Add reminder section refs: MBL-17271 affects: Student release note: none * Reminder basics * Reminder tests * a11y fixes * fixed comments --- .../di/feature/AssignmentDetailsModule.kt | 6 +- .../details/AssignmentDetailsRepository.kt | 18 +- .../details/AssignmentDetailsViewData.kt | 13 +- .../details/AssignmentDetailsViewModel.kt | 40 +- .../itemviewmodels/ReminderItemViewModel.kt | 30 + .../layout/fragment_assignment_details.xml | 90 ++- .../src/main/res/layout/view_reminder.xml | 79 +++ .../AssignmentDetailsViewModelTest.kt | 84 +++ .../AssignmentDetailsRepositoryTest.kt | 35 +- .../src/main/res/drawable/ic_add_lined.xml | 9 + .../res/drawable/ic_notifications_lined.xml | 10 + libs/pandares/src/main/res/values/strings.xml | 4 + .../9.json | 548 ++++++++++++++++++ .../room/appdatabase/daos/ReminderDaoTest.kt | 88 +++ .../pandautils/di/DatabaseModule.kt | 6 + .../room/appdatabase/AppDatabase.kt | 27 +- .../room/appdatabase/AppDatabaseMigrations.kt | 4 + .../room/appdatabase/daos/ReminderDao.kt | 39 ++ .../appdatabase/entities/ReminderEntity.kt | 31 + 19 files changed, 1145 insertions(+), 16 deletions(-) create mode 100644 apps/student/src/main/java/com/instructure/student/features/assignments/details/itemviewmodels/ReminderItemViewModel.kt create mode 100644 apps/student/src/main/res/layout/view_reminder.xml create mode 100644 libs/pandares/src/main/res/drawable/ic_add_lined.xml create mode 100644 libs/pandares/src/main/res/drawable/ic_notifications_lined.xml create mode 100644 libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json create mode 100644 libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt diff --git a/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt b/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt index 05e0815954..36ec1daf56 100644 --- a/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt +++ b/apps/student/src/main/java/com/instructure/student/di/feature/AssignmentDetailsModule.kt @@ -18,6 +18,7 @@ package com.instructure.student.di.feature import com.instructure.canvasapi2.apis.* +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao import com.instructure.pandautils.room.offline.daos.QuizDao import com.instructure.pandautils.room.offline.facade.AssignmentFacade import com.instructure.pandautils.room.offline.facade.CourseFacade @@ -59,8 +60,9 @@ class AssignmentDetailsModule { networkStateProvider: NetworkStateProvider, localDataSource: AssignmentDetailsLocalDataSource, networkDataSource: AssignmentDetailsNetworkDataSource, - featureFlagProvider: FeatureFlagProvider + featureFlagProvider: FeatureFlagProvider, + reminderDao: ReminderDao ): AssignmentDetailsRepository { - return AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) + return AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider, reminderDao) } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt index 0a7d26035d..e01f1b979c 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt @@ -17,11 +17,14 @@ package com.instructure.student.features.assignments.details +import androidx.lifecycle.LiveData import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.LTITool import com.instructure.canvasapi2.models.Quiz import com.instructure.pandautils.repository.Repository +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.student.features.assignments.details.datasource.AssignmentDetailsDataSource @@ -32,7 +35,8 @@ class AssignmentDetailsRepository( localDataSource: AssignmentDetailsLocalDataSource, networkDataSource: AssignmentDetailsNetworkDataSource, networkStateProvider: NetworkStateProvider, - featureFlagProvider: FeatureFlagProvider + featureFlagProvider: FeatureFlagProvider, + private val reminderDao: ReminderDao ) : Repository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) { suspend fun getCourseWithGrade(courseId: Long, forceNetwork: Boolean): Course { @@ -54,4 +58,16 @@ class AssignmentDetailsRepository( suspend fun getLtiFromAuthenticationUrl(url: String, forceNetwork: Boolean): LTITool? { return dataSource().getLtiFromAuthenticationUrl(url, forceNetwork) } + + fun getRemindersByAssignmentIdLiveData(userId: Long, assignmentId: Long): LiveData> { + return reminderDao.findByAssignmentIdLiveData(userId, assignmentId) + } + + suspend fun deleteReminderById(id: Long) { + reminderDao.deleteById(id) + } + + suspend fun addReminder(userId: Long, assignmentId: Long) { + reminderDao.insert(ReminderEntity(userId = userId, assignmentId = assignmentId, text = "Test Reminder")) + } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt index 5058b054a4..662f519dd4 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt @@ -4,10 +4,15 @@ import android.text.Spanned import androidx.annotation.ColorRes import androidx.databinding.BaseObservable import androidx.databinding.Bindable -import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.LTITool +import com.instructure.canvasapi2.models.Quiz +import com.instructure.canvasapi2.models.RemoteFile import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAttemptItemViewModel import com.instructure.pandautils.utils.ThemedColor import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData +import com.instructure.student.features.assignments.details.itemviewmodels.ReminderItemViewModel data class AssignmentDetailsViewData( val courseColor: ThemedColor, @@ -32,7 +37,9 @@ data class AssignmentDetailsViewData( val discussionHeaderViewData: DiscussionHeaderViewData? = null, val quizDetails: QuizViewViewData? = null, val attemptsViewData: AttemptsViewData? = null, - @Bindable var hasDraft: Boolean = false + @Bindable var hasDraft: Boolean = false, + val showReminders: Boolean = false, + @Bindable var reminders: List = emptyList() ) : BaseObservable() { val firstAttemptOrNull = attempts.firstOrNull() val noDescriptionVisible = description.isEmpty() && !fullLocked @@ -51,6 +58,8 @@ data class DiscussionHeaderViewData( val onAttachmentClicked: () -> Unit ) +data class ReminderViewData(val id: Long, val text: String) + sealed class AssignmentDetailAction { data class ShowToast(val message: String) : AssignmentDetailAction() data class NavigateToLtiScreen(val url: String) : AssignmentDetailAction() diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt index 487d856402..f1947b21ee 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt @@ -22,6 +22,7 @@ import android.content.Context import android.content.res.Resources import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,6 +45,7 @@ import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAt import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAttemptViewData import com.instructure.pandautils.mvvm.Event import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.AssignmentUtils2 import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const @@ -52,6 +54,7 @@ import com.instructure.pandautils.utils.orDefault import com.instructure.student.R import com.instructure.student.db.StudentDb import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData +import com.instructure.student.features.assignments.details.itemviewmodels.ReminderItemViewModel import com.instructure.student.mobius.assignmentDetails.getFormattedAttemptDate import com.instructure.student.mobius.assignmentDetails.uploadAudioRecording import com.instructure.student.util.getStudioLTITool @@ -73,7 +76,7 @@ class AssignmentDetailsViewModel @Inject constructor( private val htmlContentFormatter: HtmlContentFormatter, private val colorKeeper: ColorKeeper, private val application: Application, - apiPrefs: ApiPrefs, + private val apiPrefs: ApiPrefs, database: StudentDb ) : ViewModel(), Query.Listener { @@ -112,6 +115,17 @@ class AssignmentDetailsViewModel @Inject constructor( private val submissionQuery = database.submissionQueries.getSubmissionsByAssignmentId(assignmentId, apiPrefs.user?.id.orDefault()) + private val remindersObserver = Observer> { + _data.value?.reminders = mapReminders(it) + _data.value?.notifyPropertyChanged(BR.reminders) + } + + private val remindersLiveData = assignmentDetailsRepository.getRemindersByAssignmentIdLiveData( + apiPrefs.user?.id.orDefault(), assignmentId + ).apply { + observeForever(remindersObserver) + } + init { markSubmissionAsRead() submissionQuery.addListener(this) @@ -119,6 +133,11 @@ class AssignmentDetailsViewModel @Inject constructor( loadData() } + override fun onCleared() { + super.onCleared() + remindersLiveData.removeObserver(remindersObserver) + } + override fun queryResultsChanged() { viewModelScope.launch { val submission = submissionQuery.executeAsList().lastOrNull() @@ -244,7 +263,6 @@ class AssignmentDetailsViewModel @Inject constructor( } } - @Suppress("DEPRECATION") private suspend fun getViewData(assignment: Assignment, hasDraft: Boolean): AssignmentDetailsViewData { val points = if (restrictQuantitativeData) { "" @@ -461,7 +479,9 @@ class AssignmentDetailsViewModel @Inject constructor( discussionHeaderViewData = discussionHeaderViewData, quizDetails = quizViewViewData, attemptsViewData = attemptsViewData, - hasDraft = hasDraft + hasDraft = hasDraft, + showReminders = assignment.dueDate?.after(Date()).orDefault(), + reminders = mapReminders(remindersLiveData.value.orEmpty()) ) } @@ -469,6 +489,14 @@ class AssignmentDetailsViewModel @Inject constructor( _events.postValue(Event(action)) } + private fun mapReminders(reminders: List) = reminders.map { + ReminderItemViewModel(ReminderViewData(it.id, it.text)) { + viewModelScope.launch { + assignmentDetailsRepository.deleteReminderById(it) + } + } + } + fun refresh() { _state.postValue(ViewState.Refresh) loadData(true) @@ -574,4 +602,10 @@ class AssignmentDetailsViewModel @Inject constructor( fun showContent(viewState: ViewState?): Boolean { return (viewState == ViewState.Success || viewState == ViewState.Refresh) && assignment != null } + + fun onAddReminderClicked() { + viewModelScope.launch { + assignmentDetailsRepository.addReminder(apiPrefs.user?.id.orDefault(), assignmentId) + } + } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/itemviewmodels/ReminderItemViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/itemviewmodels/ReminderItemViewModel.kt new file mode 100644 index 0000000000..bbd443a22c --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/itemviewmodels/ReminderItemViewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 - 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 . + * + */ + +package com.instructure.student.features.assignments.details.itemviewmodels + +import com.instructure.pandautils.mvvm.ItemViewModel +import com.instructure.student.R +import com.instructure.student.features.assignments.details.ReminderViewData + +class ReminderItemViewModel( + val data: ReminderViewData, + val onRemoveClick: (Long) -> Unit +) : ItemViewModel { + override val layoutId: Int + get() = R.layout.view_reminder +} \ No newline at end of file diff --git a/apps/student/src/main/res/layout/fragment_assignment_details.xml b/apps/student/src/main/res/layout/fragment_assignment_details.xml index 37953ecadd..c4e135faa7 100644 --- a/apps/student/src/main/res/layout/fragment_assignment_details.xml +++ b/apps/student/src/main/res/layout/fragment_assignment_details.xml @@ -1,4 +1,19 @@ - + @@ -281,6 +296,77 @@ android:visibility="@{viewModel.data.dueDate.empty ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toBottomOf="@id/dueLabel" /> + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/reminderBottomDivider" /> + + + + + + + + + + + + + + + + + + + diff --git a/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt index cd3341086c..133f1a4ca9 100644 --- a/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt @@ -24,6 +24,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext @@ -38,6 +39,7 @@ import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.toApiString import com.instructure.pandautils.R import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter @@ -45,6 +47,7 @@ import com.instructure.student.db.StudentDb import com.instructure.student.features.assignments.details.AssignmentDetailAction import com.instructure.student.features.assignments.details.AssignmentDetailsRepository import com.instructure.student.features.assignments.details.AssignmentDetailsViewModel +import com.instructure.student.features.assignments.details.ReminderViewData import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData import io.mockk.coEvery import io.mockk.every @@ -97,6 +100,8 @@ class AssignmentDetailsViewModelTest { every { savedStateHandle.get(Const.CANVAS_CONTEXT) } returns Course() every { savedStateHandle.get(Const.ASSIGNMENT_ID) } returns 0L + + every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns MutableLiveData() } fun tearDown() { @@ -775,4 +780,83 @@ class AssignmentDetailsViewModelTest { Assert.assertFalse(viewModel.data.value?.submitVisible!!) } + + @Test + fun `Reminder section is not visible if there's no future deadline`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment(name = "Test", submissionTypesRaw = listOf("online_text_entry")) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertFalse(viewModel.data.value?.showReminders!!) + } + + @Test + fun `Reminder section visible if there's a future deadline`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertTrue(viewModel.data.value?.showReminders!!) + } + + @Test + fun `Reminders map correctly`() { + val reminderEntities = listOf( + ReminderEntity(1, 1, 1, "Test 1"), + ReminderEntity(2, 1, 1, "Test 2"), + ReminderEntity(3, 1, 1, "Test 3") + ) + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns MutableLiveData(reminderEntities) + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertEquals( + reminderEntities.map { ReminderViewData(it.id, it.text) }, + viewModel.data.value?.reminders?.map { it.data } + ) + } + + @Test + fun `Reminders update correctly`() { + val remindersLiveData = MutableLiveData>() + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns remindersLiveData + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + Assert.assertEquals(0, viewModel.data.value?.reminders?.size) + + remindersLiveData.value = listOf(ReminderEntity(1, 1, 1, "Test 1")) + + Assert.assertEquals(ReminderViewData(1, "Test 1"), viewModel.data.value?.reminders?.first()?.data) + } } diff --git a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt index f569008123..97f93f73d0 100644 --- a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt @@ -17,11 +17,13 @@ package com.instructure.student.features.offline.assignmentdetails +import androidx.lifecycle.MutableLiveData import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.LTITool import com.instructure.canvasapi2.models.Quiz -import com.instructure.pandautils.utils.FEATURE_FLAG_OFFLINE +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.student.features.assignments.details.AssignmentDetailsRepository @@ -44,8 +46,9 @@ class AssignmentDetailsRepositoryTest { private val localDataSource: AssignmentDetailsLocalDataSource = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + private val reminderDao: ReminderDao = mockk(relaxed = true) - private val repository = AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider) + private val repository = AssignmentDetailsRepository(localDataSource, networkDataSource, networkStateProvider, featureFlagProvider, reminderDao) @Before fun setup() = runTest { @@ -179,4 +182,32 @@ class AssignmentDetailsRepositoryTest { Assert.assertEquals(null, ltiTool) } + + @Test + fun `Get reminders liveData`() = runTest { + val expected = MutableLiveData>() + every { reminderDao.findByAssignmentIdLiveData(any(), any()) } returns expected + + val reminderLiveData = repository.getRemindersByAssignmentIdLiveData(1, 1) + + Assert.assertEquals(expected, reminderLiveData) + } + + @Test + fun `Delete reminder`() = runTest { + repository.deleteReminderById(1) + + coVerify(exactly = 1) { + reminderDao.deleteById(1) + } + } + + @Test + fun `Add reminder`() = runTest { + repository.addReminder(1, 1) + + coVerify(exactly = 1) { + reminderDao.insert(ReminderEntity(userId = 1, assignmentId = 1, text = "Test Reminder")) + } + } } \ No newline at end of file diff --git a/libs/pandares/src/main/res/drawable/ic_add_lined.xml b/libs/pandares/src/main/res/drawable/ic_add_lined.xml new file mode 100644 index 0000000000..2267332ead --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_add_lined.xml @@ -0,0 +1,9 @@ + + + diff --git a/libs/pandares/src/main/res/drawable/ic_notifications_lined.xml b/libs/pandares/src/main/res/drawable/ic_notifications_lined.xml new file mode 100644 index 0000000000..43f5fb5a69 --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_notifications_lined.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 1e2639fb23..dd7e026f24 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -67,6 +67,10 @@ Submission Deleted Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder No preview available for URLs using \'http://\' diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json new file mode 100644 index 0000000000..8d795bfe6d --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json @@ -0,0 +1,548 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "73877a0396a69ef01fc040c995d1a5bd", + "entities": [ + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, `attempt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EnvironmentFeatureFlags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `featureFlags` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "featureFlags", + "columnName": "featureFlags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileUploadInputEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `courseId` INTEGER, `assignmentId` INTEGER, `quizId` INTEGER, `quizQuestionId` INTEGER, `position` INTEGER, `parentFolderId` INTEGER, `action` TEXT NOT NULL, `userId` INTEGER, `attachments` TEXT NOT NULL, `submissionId` INTEGER, `filePaths` TEXT NOT NULL, `attemptId` INTEGER, `notificationId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizQuestionId", + "columnName": "quizQuestionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filePaths", + "columnName": "filePaths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pageId` TEXT NOT NULL, `comment` TEXT, `date` INTEGER NOT NULL, `status` TEXT NOT NULL, `workerId` TEXT, `filePath` TEXT, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardFileUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT, `subtitle` TEXT, `courseId` INTEGER, `assignmentId` INTEGER, `attemptId` INTEGER, `folderId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ReminderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '73877a0396a69ef01fc040c995d1a5bd')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt new file mode 100644 index 0000000000..ed1bade9dd --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2023 - 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 . + * + */ +package com.instructure.pandautils.room.appdatabase.daos + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.room.appdatabase.AppDatabase +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.* + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class ReminderDaoTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var db: AppDatabase + private lateinit var reminderDao: ReminderDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + reminderDao = db.reminderDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testFindItemsByAssignmentId() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "Test 1"), + ReminderEntity(2, 2, 1, "Test 2"), + ReminderEntity(3, 1, 2, "Test 3"), + ) + entities.forEach { reminderDao.insert(it) } + + val result = reminderDao.findByAssignmentIdLiveData(1, 1) + result.observeForever { } + + Assert.assertEquals(entities.take(1), result.value) + } + + @Test + fun testDeleteById() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "Test 1"), + ReminderEntity(2, 1, 1, "Test 2") + ) + entities.forEach { reminderDao.insert(it) } + + reminderDao.deleteById(1) + + val result = reminderDao.findByAssignmentIdLiveData(1, 1) + result.observeForever { } + + Assert.assertEquals(entities.takeLast(1), result.value) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt index c4890d639e..8e9679b912 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt @@ -59,4 +59,10 @@ class DatabaseModule { fun provideEnvironmentFeatureFlagsDao(appDatabase: AppDatabase): EnvironmentFeatureFlagsDao { return appDatabase.environmentFeatureFlagsDao() } + + @Provides + @Singleton + fun provideReminderDao(appDatabase: AppDatabase): ReminderDao { + return appDatabase.reminderDao() + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt index 29902a27b1..2dc92b3770 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt @@ -3,8 +3,24 @@ package com.instructure.pandautils.room.appdatabase import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.instructure.pandautils.room.appdatabase.daos.* -import com.instructure.pandautils.room.appdatabase.entities.* +import com.instructure.pandautils.room.appdatabase.daos.AttachmentDao +import com.instructure.pandautils.room.appdatabase.daos.AuthorDao +import com.instructure.pandautils.room.appdatabase.daos.DashboardFileUploadDao +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao +import com.instructure.pandautils.room.appdatabase.daos.FileUploadInputDao +import com.instructure.pandautils.room.appdatabase.daos.MediaCommentDao +import com.instructure.pandautils.room.appdatabase.daos.PendingSubmissionCommentDao +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.room.appdatabase.daos.SubmissionCommentDao +import com.instructure.pandautils.room.appdatabase.entities.AttachmentEntity +import com.instructure.pandautils.room.appdatabase.entities.AuthorEntity +import com.instructure.pandautils.room.appdatabase.entities.DashboardFileUploadEntity +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags +import com.instructure.pandautils.room.appdatabase.entities.FileUploadInputEntity +import com.instructure.pandautils.room.appdatabase.entities.MediaCommentEntity +import com.instructure.pandautils.room.appdatabase.entities.PendingSubmissionCommentEntity +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity +import com.instructure.pandautils.room.appdatabase.entities.SubmissionCommentEntity import com.instructure.pandautils.room.common.Converters @Database( @@ -16,8 +32,9 @@ import com.instructure.pandautils.room.common.Converters MediaCommentEntity::class, SubmissionCommentEntity::class, PendingSubmissionCommentEntity::class, - DashboardFileUploadEntity::class - ], version = 8 + DashboardFileUploadEntity::class, + ReminderEntity::class + ], version = 9 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -37,4 +54,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun dashboardFileUploadDao(): DashboardFileUploadDao abstract fun environmentFeatureFlagsDao(): EnvironmentFeatureFlagsDao + + abstract fun reminderDao(): ReminderDao } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt index f7cb1e1095..fe3f85a1dd 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt @@ -53,5 +53,9 @@ val appDatabaseMigrations = arrayOf( createMigration(7, 8) { database -> database.execSQL("CREATE TABLE IF NOT EXISTS EnvironmentFeatureFlags (userId INTEGER NOT NULL, featureFlags TEXT NOT NULL, PRIMARY KEY(userId))") + }, + + createMigration(8, 9) { database -> + database.execSQL("CREATE TABLE IF NOT EXISTS ReminderEntity (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, userId INTEGER NOT NULL, assignmentId INTEGER NOT NULL, text TEXT NOT NULL)") } ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt new file mode 100644 index 0000000000..f5738e81ff --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.daos + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity + +@Dao +interface ReminderDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(reminder: ReminderEntity) + + @Query("DELETE FROM ReminderEntity WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("SELECT * FROM ReminderEntity WHERE userId = :userId AND assignmentId = :assignmentId") + fun findByAssignmentIdLiveData(userId: Long, assignmentId: Long): LiveData> +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt new file mode 100644 index 0000000000..904a3ab9ae --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class ReminderEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val userId: Long, + val assignmentId: Long, + val text: String +) \ No newline at end of file From 09c41ab4414bb78a23e8ec7d9f63d3924b81dd92 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Tue, 30 Jan 2024 14:06:42 +0100 Subject: [PATCH 06/52] [MBL-17272][Student] Create reminder dialogs refs: MBL-17272 affects: Student release note: none * Reminder basics * Reminder tests * a11y fixes * Add reminder dialogs * fixed comments * added licence * strings fix * fixed dialog scrolling --- .../details/AssignmentDetailsFragment.kt | 63 ++++++++++++- .../details/AssignmentDetailsRepository.kt | 4 +- .../details/AssignmentDetailsViewData.kt | 20 +++++ .../details/AssignmentDetailsViewModel.kt | 14 ++- .../reminder/CustomReminderDialog.kt | 65 ++++++++++++++ .../res/layout/dialog_custom_reminder.xml | 90 +++++++++++++++++++ .../AssignmentDetailsViewModelTest.kt | 65 ++++++++++++++ .../AssignmentDetailsRepositoryTest.kt | 2 +- libs/pandares/src/main/res/values/strings.xml | 23 +++++ .../room/appdatabase/daos/ReminderDaoTest.kt | 2 +- .../room/appdatabase/daos/ReminderDao.kt | 2 +- .../appdatabase/entities/ReminderEntity.kt | 2 +- 12 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt create mode 100644 apps/student/src/main/res/layout/dialog_custom_reminder.xml diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt index 5dc83c242f..1c39f1ab1d 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt @@ -28,9 +28,15 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels import com.instructure.canvasapi2.CanvasRestAdapter -import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Assignment.SubmissionType -import com.instructure.canvasapi2.utils.* +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.LTITool +import com.instructure.canvasapi2.models.RemoteFile +import com.instructure.canvasapi2.utils.Analytics +import com.instructure.canvasapi2.utils.AnalyticsEventConstants +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.pageview.PageView import com.instructure.canvasapi2.utils.pageview.PageViewUrlParam import com.instructure.interactions.bookmarks.Bookmarkable @@ -41,7 +47,18 @@ import com.instructure.pandautils.analytics.SCREEN_VIEW_ASSIGNMENT_DETAILS import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.features.discussion.router.DiscussionRouterFragment import com.instructure.pandautils.features.shareextension.ShareFileSubmissionTarget -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.LongArg +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.PermissionUtils +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.orDefault +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.showThemed +import com.instructure.pandautils.utils.toast +import com.instructure.pandautils.utils.withArgs import com.instructure.pandautils.views.CanvasWebView import com.instructure.pandautils.views.RecordingMediaType import com.instructure.student.R @@ -49,7 +66,12 @@ import com.instructure.student.activity.BaseRouterActivity import com.instructure.student.databinding.DialogSubmissionPickerBinding import com.instructure.student.databinding.DialogSubmissionPickerMediaBinding import com.instructure.student.databinding.FragmentAssignmentDetailsBinding -import com.instructure.student.fragment.* +import com.instructure.student.features.assignments.reminder.CustomReminderDialog +import com.instructure.student.fragment.BasicQuizViewFragment +import com.instructure.student.fragment.InternalWebviewFragment +import com.instructure.student.fragment.LtiLaunchFragment +import com.instructure.student.fragment.ParentFragment +import com.instructure.student.fragment.StudioWebViewFragment import com.instructure.student.mobius.assignmentDetails.getVideoUri import com.instructure.student.mobius.assignmentDetails.launchAudio import com.instructure.student.mobius.assignmentDetails.needsPermissions @@ -194,6 +216,12 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { is AssignmentDetailAction.OnDiscussionHeaderAttachmentClicked -> { showDiscussionAttachments(action.attachments) } + is AssignmentDetailAction.ShowReminderDialog -> { + showCreateReminderDialog() + } + is AssignmentDetailAction.ShowCustomReminderDialog -> { + showCustomReminderDialog() + } } } @@ -387,6 +415,33 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { ) } + private fun showCreateReminderDialog() { + val choices = listOf( + ReminderChoice.Minute(5), + ReminderChoice.Minute(15), + ReminderChoice.Minute(30), + ReminderChoice.Hour(1), + ReminderChoice.Day(1), + ReminderChoice.Week(1), + ReminderChoice.Custom, + ) + + AlertDialog.Builder(requireContext()) + .setTitle(R.string.reminderTitle) + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() } + .setSingleChoiceItems( + choices.map { it.getText(resources) }.toTypedArray(), -1 + ) { dialog, which -> + viewModel.onReminderSelected(choices[which]) + dialog.dismiss() + } + .showThemed() + } + + private fun showCustomReminderDialog() { + CustomReminderDialog.newInstance().show(childFragmentManager, null) + } + companion object { fun makeRoute(course: CanvasContext, assignmentId: Long): Route { val bundle = course.makeBundle { putLong(Const.ASSIGNMENT_ID, assignmentId) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt index e01f1b979c..c9741b23d0 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt @@ -67,7 +67,7 @@ class AssignmentDetailsRepository( reminderDao.deleteById(id) } - suspend fun addReminder(userId: Long, assignmentId: Long) { - reminderDao.insert(ReminderEntity(userId = userId, assignmentId = assignmentId, text = "Test Reminder")) + suspend fun addReminder(userId: Long, assignmentId: Long, text: String) { + reminderDao.insert(ReminderEntity(userId = userId, assignmentId = assignmentId, text = text)) } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt index 662f519dd4..9aa101beb5 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt @@ -1,5 +1,6 @@ package com.instructure.student.features.assignments.details +import android.content.res.Resources import android.text.Spanned import androidx.annotation.ColorRes import androidx.databinding.BaseObservable @@ -11,6 +12,7 @@ import com.instructure.canvasapi2.models.Quiz import com.instructure.canvasapi2.models.RemoteFile import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAttemptItemViewModel import com.instructure.pandautils.utils.ThemedColor +import com.instructure.student.R import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData import com.instructure.student.features.assignments.details.itemviewmodels.ReminderItemViewModel @@ -60,6 +62,22 @@ data class DiscussionHeaderViewData( data class ReminderViewData(val id: Long, val text: String) +sealed class ReminderChoice { + data class Minute(val quantity: Int) : ReminderChoice() + data class Hour(val quantity: Int) : ReminderChoice() + data class Day(val quantity: Int) : ReminderChoice() + data class Week(val quantity: Int) : ReminderChoice() + data object Custom : ReminderChoice() + + fun getText(resources: Resources) = when (this) { + is Minute -> resources.getQuantityString(R.plurals.reminderMinute, quantity, quantity) + is Hour -> resources.getQuantityString(R.plurals.reminderHour, quantity, quantity) + is Day -> resources.getQuantityString(R.plurals.reminderDay, quantity, quantity) + is Week -> resources.getQuantityString(R.plurals.reminderWeek, quantity, quantity) + is Custom -> resources.getString(R.string.reminderCustom) + } +} + sealed class AssignmentDetailAction { data class ShowToast(val message: String) : AssignmentDetailAction() data class NavigateToLtiScreen(val url: String) : AssignmentDetailAction() @@ -85,4 +103,6 @@ sealed class AssignmentDetailAction { data class ShowSubmitDialog(val assignment: Assignment, val studioLTITool: LTITool?) : AssignmentDetailAction() data class NavigateToUploadStatusScreen(val submissionId: Long) : AssignmentDetailAction() data class OnDiscussionHeaderAttachmentClicked(val attachments: List) : AssignmentDetailAction() + data object ShowReminderDialog : AssignmentDetailAction() + data object ShowCustomReminderDialog : AssignmentDetailAction() } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt index f1947b21ee..577ae2aa9d 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt @@ -604,8 +604,20 @@ class AssignmentDetailsViewModel @Inject constructor( } fun onAddReminderClicked() { + postAction(AssignmentDetailAction.ShowReminderDialog) + } + + fun onReminderSelected(reminderChoice: ReminderChoice) { + if (reminderChoice == ReminderChoice.Custom) { + postAction(AssignmentDetailAction.ShowCustomReminderDialog) + } else { + setReminder(reminderChoice) + } + } + + private fun setReminder(reminderChoice: ReminderChoice) { viewModelScope.launch { - assignmentDetailsRepository.addReminder(apiPrefs.user?.id.orDefault(), assignmentId) + assignmentDetailsRepository.addReminder(apiPrefs.user?.id.orDefault(), assignmentId, reminderChoice.getText(resources)) } } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt new file mode 100644 index 0000000000..f8ba222f0f --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 - 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 . + * + */ + +package com.instructure.student.features.assignments.reminder + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.student.R +import com.instructure.student.databinding.DialogCustomReminderBinding +import com.instructure.student.features.assignments.details.AssignmentDetailsViewModel +import com.instructure.student.features.assignments.details.ReminderChoice +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CustomReminderDialog : DialogFragment() { + + private lateinit var binding: DialogCustomReminderBinding + private val parentViewModel: AssignmentDetailsViewModel by viewModels(ownerProducer = { + requireParentFragment() + }) + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogCustomReminderBinding.inflate(layoutInflater, null, false) + + return AlertDialog.Builder(requireContext()) + .setView(binding.root) + .setTitle(R.string.customReminderTitle) + .setPositiveButton(R.string.done) { _, _ -> + val quantity = binding.quantity.text.toString().toIntOrNull() ?: return@setPositiveButton + when (binding.choices.checkedRadioButtonId) { + R.id.minutes -> parentViewModel.onReminderSelected(ReminderChoice.Minute(quantity)) + R.id.hours -> parentViewModel.onReminderSelected(ReminderChoice.Hour(quantity)) + R.id.days -> parentViewModel.onReminderSelected(ReminderChoice.Day(quantity)) + R.id.weeks -> parentViewModel.onReminderSelected(ReminderChoice.Week(quantity)) + } + } + .create().apply { + setOnShowListener { + getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ThemePrefs.textButtonColor) + } + } + } + + companion object { + fun newInstance() = CustomReminderDialog() + } +} \ No newline at end of file diff --git a/apps/student/src/main/res/layout/dialog_custom_reminder.xml b/apps/student/src/main/res/layout/dialog_custom_reminder.xml new file mode 100644 index 0000000000..dd3ec74c7e --- /dev/null +++ b/apps/student/src/main/res/layout/dialog_custom_reminder.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt index 133f1a4ca9..92d116bf61 100644 --- a/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModelTest.kt @@ -34,6 +34,7 @@ import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.LockInfo import com.instructure.canvasapi2.models.Quiz import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.toApiString @@ -47,9 +48,11 @@ import com.instructure.student.db.StudentDb import com.instructure.student.features.assignments.details.AssignmentDetailAction import com.instructure.student.features.assignments.details.AssignmentDetailsRepository import com.instructure.student.features.assignments.details.AssignmentDetailsViewModel +import com.instructure.student.features.assignments.details.ReminderChoice import com.instructure.student.features.assignments.details.ReminderViewData import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -102,6 +105,7 @@ class AssignmentDetailsViewModelTest { every { savedStateHandle.get(Const.ASSIGNMENT_ID) } returns 0L every { assignmentDetailsRepository.getRemindersByAssignmentIdLiveData(any(), any()) } returns MutableLiveData() + every { apiPrefs.user } returns User(id = 1) } fun tearDown() { @@ -859,4 +863,65 @@ class AssignmentDetailsViewModelTest { Assert.assertEquals(ReminderViewData(1, "Test 1"), viewModel.data.value?.reminders?.first()?.data) } + + @Test + fun `Add reminder posts action`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + viewModel.onAddReminderClicked() + + Assert.assertEquals(AssignmentDetailAction.ShowReminderDialog, viewModel.events.value?.peekContent()) + } + + @Test + fun `Selected reminder choice`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + every { savedStateHandle.get(Const.ASSIGNMENT_ID) } returns 1 + every { resources.getQuantityString(R.plurals.reminderDay, 3, 3) } returns "3 days" + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + viewModel.onReminderSelected(ReminderChoice.Day(3)) + + coVerify(exactly = 1) { + assignmentDetailsRepository.addReminder(1, 1, "3 days") + } + } + + @Test + fun `Selected reminder choice custom`() { + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment( + name = "Test", + submissionTypesRaw = listOf("online_text_entry"), + dueAt = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, 1) }.time.toApiString() + ) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + + val viewModel = getViewModel() + + viewModel.onReminderSelected(ReminderChoice.Custom) + + Assert.assertEquals(AssignmentDetailAction.ShowCustomReminderDialog, viewModel.events.value?.peekContent()) + } } diff --git a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt index 97f93f73d0..cc214053cd 100644 --- a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsRepositoryTest.kt @@ -204,7 +204,7 @@ class AssignmentDetailsRepositoryTest { @Test fun `Add reminder`() = runTest { - repository.addReminder(1, 1) + repository.addReminder(1, 1, "Test Reminder") coVerify(exactly = 1) { reminderDao.insert(ReminderEntity(userId = 1, assignmentId = 1, text = "Test Reminder")) diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index dd7e026f24..4d3727e8a7 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -71,6 +71,29 @@ Add due date reminder notifications about this assignment on this device. Add reminder Remove reminder + + 1 Minute Before + %d Minutes Before + + + 1 Hour Before + %d Hours Before + + + 1 Day Before + %d Days Before + + + 1 Week Before + %d Weeks Before + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before No preview available for URLs using \'http://\' diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt index ed1bade9dd..66dab6fce7 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 - present Instructure, Inc. + * Copyright (C) 2024 - 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 diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt index f5738e81ff..2931230047 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 - present Instructure, Inc. + * Copyright (C) 2024 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt index 904a3ab9ae..0eac0f89b0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 - present Instructure, Inc. + * Copyright (C) 2024 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 75b245a43c980646cf78369d9096e5db7521fdf6 Mon Sep 17 00:00:00 2001 From: inst-danger Date: Tue, 30 Jan 2024 16:07:38 +0100 Subject: [PATCH 07/52] Update translations (#2316) --- libs/pandares/src/main/res/values-ar/strings.xml | 1 + libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml | 1 + libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml | 1 + libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml | 1 + libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml | 1 + libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml | 1 + libs/pandares/src/main/res/values-b+zh+HK/strings.xml | 1 + libs/pandares/src/main/res/values-b+zh+Hans/strings.xml | 1 + libs/pandares/src/main/res/values-b+zh+Hant/strings.xml | 1 + libs/pandares/src/main/res/values-ca/strings.xml | 1 + libs/pandares/src/main/res/values-cy/strings.xml | 1 + libs/pandares/src/main/res/values-da/strings.xml | 1 + libs/pandares/src/main/res/values-de/strings.xml | 1 + libs/pandares/src/main/res/values-en-rAU/strings.xml | 1 + libs/pandares/src/main/res/values-en-rCY/strings.xml | 1 + libs/pandares/src/main/res/values-en-rGB/strings.xml | 1 + libs/pandares/src/main/res/values-es-rES/strings.xml | 1 + libs/pandares/src/main/res/values-es/strings.xml | 1 + libs/pandares/src/main/res/values-fi/strings.xml | 1 + libs/pandares/src/main/res/values-fr-rCA/strings.xml | 1 + libs/pandares/src/main/res/values-fr/strings.xml | 1 + libs/pandares/src/main/res/values-ht/strings.xml | 1 + libs/pandares/src/main/res/values-id/strings.xml | 1 + libs/pandares/src/main/res/values-is/strings.xml | 1 + libs/pandares/src/main/res/values-it/strings.xml | 1 + libs/pandares/src/main/res/values-ja/strings.xml | 1 + libs/pandares/src/main/res/values-mi/strings.xml | 1 + libs/pandares/src/main/res/values-ms/strings.xml | 1 + libs/pandares/src/main/res/values-nb/strings.xml | 1 + libs/pandares/src/main/res/values-nl/strings.xml | 1 + libs/pandares/src/main/res/values-pl/strings.xml | 1 + libs/pandares/src/main/res/values-pt-rBR/strings.xml | 1 + libs/pandares/src/main/res/values-pt-rPT/strings.xml | 1 + libs/pandares/src/main/res/values-ru/strings.xml | 1 + libs/pandares/src/main/res/values-sl/strings.xml | 1 + libs/pandares/src/main/res/values-sv/strings.xml | 1 + libs/pandares/src/main/res/values-th/strings.xml | 1 + libs/pandares/src/main/res/values-vi/strings.xml | 1 + libs/pandares/src/main/res/values-zh/strings.xml | 1 + 39 files changed, 39 insertions(+) diff --git a/libs/pandares/src/main/res/values-ar/strings.xml b/libs/pandares/src/main/res/values-ar/strings.xml index 7b9f7c7478..105d68b18b 100644 --- a/libs/pandares/src/main/res/values-ar/strings.xml +++ b/libs/pandares/src/main/res/values-ar/strings.xml @@ -1560,4 +1560,5 @@ تمت مزامنة %d من المساقات. محتوى مساق إضافي + فشل تحديث ترتيب بطاقات لوحة المعلومات diff --git a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml index e7558e09d5..98baa01d90 100644 --- a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml @@ -1481,4 +1481,5 @@ %d fagene er blevet synkroniseret. Yderligere fagindhold + Kunne ikke opdatere oversigtens kortordren diff --git a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml index 608efeeba3..eb6c0ce191 100644 --- a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -1481,4 +1481,5 @@ %d subjects have been synced. Additional subject content + Failed to update Dashboard cards order diff --git a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml index 97fe4127f9..323ef754a6 100644 --- a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -1481,4 +1481,5 @@ %d modules have been synced. Additional module content + Failed to update Dashboard cards order diff --git a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml index e02f78f1c4..4bb4d4c529 100644 --- a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -1482,4 +1482,5 @@ %d fag er synkronisert. Ytterligere faginnhold + Kunne ikke oppdatere rekkefølge på oversiktskort diff --git a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml index 46d0d717b5..fb1b08c211 100644 --- a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -1481,4 +1481,5 @@ %d-kurser har synkroniserats. Extra kursinnehåll + Det gick inte att uppdatera översiktens kortordning diff --git a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml index f4dafa9d93..44c1579f5c 100644 --- a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml @@ -1461,4 +1461,5 @@ %d 課程已同步。 額外課程內容 + 無法更新控制面板卡片順序 diff --git a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml index 7149d2279e..e8294a5955 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml @@ -1461,4 +1461,5 @@ %d 门课程已同步。 更多课程内容 + 无法更新控制面板卡片顺序 diff --git a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml index f4dafa9d93..44c1579f5c 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml @@ -1461,4 +1461,5 @@ %d 課程已同步。 額外課程內容 + 無法更新控制面板卡片順序 diff --git a/libs/pandares/src/main/res/values-ca/strings.xml b/libs/pandares/src/main/res/values-ca/strings.xml index 890fee6e6f..3d627d7740 100644 --- a/libs/pandares/src/main/res/values-ca/strings.xml +++ b/libs/pandares/src/main/res/values-ca/strings.xml @@ -1482,4 +1482,5 @@ S’han sincronitzat %d assignatures. Contingut de l’assignatura addicional + No s’ha pogut actualitzar la comanda de targetes del panell de control diff --git a/libs/pandares/src/main/res/values-cy/strings.xml b/libs/pandares/src/main/res/values-cy/strings.xml index e970abdb83..52a16fbd8b 100644 --- a/libs/pandares/src/main/res/values-cy/strings.xml +++ b/libs/pandares/src/main/res/values-cy/strings.xml @@ -1481,4 +1481,5 @@ %d cwrs wedi cael eu cysoni. Cynnwys cwrs ychwanegol + Wedi methu diweddaru trefn cardiau’r Dangosfwrdd diff --git a/libs/pandares/src/main/res/values-da/strings.xml b/libs/pandares/src/main/res/values-da/strings.xml index 3b2a5a3be3..db4a169daf 100644 --- a/libs/pandares/src/main/res/values-da/strings.xml +++ b/libs/pandares/src/main/res/values-da/strings.xml @@ -1481,4 +1481,5 @@ %d fagene er blevet synkroniseret. Yderligere fagindhold + Kunne ikke opdatere oversigtens kortordren diff --git a/libs/pandares/src/main/res/values-de/strings.xml b/libs/pandares/src/main/res/values-de/strings.xml index 582f85287c..442768c588 100644 --- a/libs/pandares/src/main/res/values-de/strings.xml +++ b/libs/pandares/src/main/res/values-de/strings.xml @@ -1481,4 +1481,5 @@ %d Kurse wurden synchronisiert. Zusätzliche Kursinhalte + Reihenfolge der Dashboard-Karten konnte nicht aktualisiert werden diff --git a/libs/pandares/src/main/res/values-en-rAU/strings.xml b/libs/pandares/src/main/res/values-en-rAU/strings.xml index d980349add..8c83327465 100644 --- a/libs/pandares/src/main/res/values-en-rAU/strings.xml +++ b/libs/pandares/src/main/res/values-en-rAU/strings.xml @@ -1481,4 +1481,5 @@ %d courses have been synced. Additional course content + Failed to update Dashboard cards order diff --git a/libs/pandares/src/main/res/values-en-rCY/strings.xml b/libs/pandares/src/main/res/values-en-rCY/strings.xml index 97fe4127f9..323ef754a6 100644 --- a/libs/pandares/src/main/res/values-en-rCY/strings.xml +++ b/libs/pandares/src/main/res/values-en-rCY/strings.xml @@ -1481,4 +1481,5 @@ %d modules have been synced. Additional module content + Failed to update Dashboard cards order diff --git a/libs/pandares/src/main/res/values-en-rGB/strings.xml b/libs/pandares/src/main/res/values-en-rGB/strings.xml index 24c584678f..853018a743 100644 --- a/libs/pandares/src/main/res/values-en-rGB/strings.xml +++ b/libs/pandares/src/main/res/values-en-rGB/strings.xml @@ -1481,4 +1481,5 @@ %d courses have been synced. Additional course content + Failed to update Dashboard cards order diff --git a/libs/pandares/src/main/res/values-es-rES/strings.xml b/libs/pandares/src/main/res/values-es-rES/strings.xml index 2df0e3f03b..fbc52175a1 100644 --- a/libs/pandares/src/main/res/values-es-rES/strings.xml +++ b/libs/pandares/src/main/res/values-es-rES/strings.xml @@ -1483,4 +1483,5 @@ %d cursos se han sincronizado. Contenido del curso adicional + Error al actualizar el orden de las tarjetas en el cuadro de mando diff --git a/libs/pandares/src/main/res/values-es/strings.xml b/libs/pandares/src/main/res/values-es/strings.xml index 5f78299787..0a826ef847 100644 --- a/libs/pandares/src/main/res/values-es/strings.xml +++ b/libs/pandares/src/main/res/values-es/strings.xml @@ -1481,4 +1481,5 @@ Se han sincronizado los cursos %d. Contenido adicional del curso + No se pudo actualizar el orden de las tarjetas del tablero diff --git a/libs/pandares/src/main/res/values-fi/strings.xml b/libs/pandares/src/main/res/values-fi/strings.xml index be20b9c54e..fce7fece1f 100644 --- a/libs/pandares/src/main/res/values-fi/strings.xml +++ b/libs/pandares/src/main/res/values-fi/strings.xml @@ -1481,4 +1481,5 @@ %d kurssia on synkronoitu. LIsäkurssin sisältö. + Hallintapaneelin koontinäytön korttien päivittäminen epäonnistui diff --git a/libs/pandares/src/main/res/values-fr-rCA/strings.xml b/libs/pandares/src/main/res/values-fr-rCA/strings.xml index 900b9b79b2..b019c0d273 100644 --- a/libs/pandares/src/main/res/values-fr-rCA/strings.xml +++ b/libs/pandares/src/main/res/values-fr-rCA/strings.xml @@ -1481,4 +1481,5 @@ %d cours ont été synchronisés. Contenu supplémentaire de cours + Échec de la mise à jour de la commande des cartes du tableau de bord diff --git a/libs/pandares/src/main/res/values-fr/strings.xml b/libs/pandares/src/main/res/values-fr/strings.xml index a3e56bdf2a..7ef75407d1 100644 --- a/libs/pandares/src/main/res/values-fr/strings.xml +++ b/libs/pandares/src/main/res/values-fr/strings.xml @@ -1481,4 +1481,5 @@ %d cours ont été synchronisés. Contenu de cours supplémentaire + Impossible de mettre à jour l’ordre des cartes du tableau de bord diff --git a/libs/pandares/src/main/res/values-ht/strings.xml b/libs/pandares/src/main/res/values-ht/strings.xml index 6d8b1151c8..e550e51637 100644 --- a/libs/pandares/src/main/res/values-ht/strings.xml +++ b/libs/pandares/src/main/res/values-ht/strings.xml @@ -1481,4 +1481,5 @@ %d kou yo senkwonize. Plis kontni kou + Echèk mizajou kòmann kat tablodbò a diff --git a/libs/pandares/src/main/res/values-id/strings.xml b/libs/pandares/src/main/res/values-id/strings.xml index 073cc18e07..0de16706ad 100644 --- a/libs/pandares/src/main/res/values-id/strings.xml +++ b/libs/pandares/src/main/res/values-id/strings.xml @@ -1484,4 +1484,5 @@ Kursus %d telah disinkronkan. Konten kursus tambahan + Gagal memperbarui urutan kartu Dashboard diff --git a/libs/pandares/src/main/res/values-is/strings.xml b/libs/pandares/src/main/res/values-is/strings.xml index 2de8421654..876ecd299b 100644 --- a/libs/pandares/src/main/res/values-is/strings.xml +++ b/libs/pandares/src/main/res/values-is/strings.xml @@ -1481,4 +1481,5 @@ %d námskeið hafa verið samstillt. Viðbótar námskeiðsefni + Ekki tókst að uppfæra röð Skjáborðsspjalda diff --git a/libs/pandares/src/main/res/values-it/strings.xml b/libs/pandares/src/main/res/values-it/strings.xml index b170295b0d..e74f2d030c 100644 --- a/libs/pandares/src/main/res/values-it/strings.xml +++ b/libs/pandares/src/main/res/values-it/strings.xml @@ -1481,4 +1481,5 @@ %d corsi sono stati sincronizzati. Altro contenuto del corso + Impossibile aggiornare l’ordine delle schede dashboard diff --git a/libs/pandares/src/main/res/values-ja/strings.xml b/libs/pandares/src/main/res/values-ja/strings.xml index a426dd9484..5c3311d69d 100644 --- a/libs/pandares/src/main/res/values-ja/strings.xml +++ b/libs/pandares/src/main/res/values-ja/strings.xml @@ -1461,4 +1461,5 @@ %dコースが同期されました。 追加のコースコンテンツ + ダッシュボードのカードの順番を更新できませんでした diff --git a/libs/pandares/src/main/res/values-mi/strings.xml b/libs/pandares/src/main/res/values-mi/strings.xml index 181bde810e..b7f0aa7e84 100644 --- a/libs/pandares/src/main/res/values-mi/strings.xml +++ b/libs/pandares/src/main/res/values-mi/strings.xml @@ -1481,4 +1481,5 @@ %d kua tukutahia nga akoranga. Ihirangi akoranga taapiri + I rahua te whakahōu i te raupapa kāri Papatohu diff --git a/libs/pandares/src/main/res/values-ms/strings.xml b/libs/pandares/src/main/res/values-ms/strings.xml index 78ae096d79..8383951961 100644 --- a/libs/pandares/src/main/res/values-ms/strings.xml +++ b/libs/pandares/src/main/res/values-ms/strings.xml @@ -1487,4 +1487,5 @@ %d Kursus telah disegerakkan. Kandungan kursus tambahan + Gagal untuk mengemas kini susunan kad Papan Pemuka diff --git a/libs/pandares/src/main/res/values-nb/strings.xml b/libs/pandares/src/main/res/values-nb/strings.xml index 9d67eff9a5..a4018ab8fe 100644 --- a/libs/pandares/src/main/res/values-nb/strings.xml +++ b/libs/pandares/src/main/res/values-nb/strings.xml @@ -1482,4 +1482,5 @@ %d emner er synkronisert. Ytterligere emneinnhold + Kunne ikke oppdatere rekkefølge på Dashbord-kort diff --git a/libs/pandares/src/main/res/values-nl/strings.xml b/libs/pandares/src/main/res/values-nl/strings.xml index 7002b343d5..a9688a48d6 100644 --- a/libs/pandares/src/main/res/values-nl/strings.xml +++ b/libs/pandares/src/main/res/values-nl/strings.xml @@ -1481,4 +1481,5 @@ %d-cursussen zijn gesynchroniseerd. Aanvullende cursusinhoud + Kan de volgorde van Dashboard-kaarten niet wijzigen diff --git a/libs/pandares/src/main/res/values-pl/strings.xml b/libs/pandares/src/main/res/values-pl/strings.xml index df8ca14aae..baf62b0691 100644 --- a/libs/pandares/src/main/res/values-pl/strings.xml +++ b/libs/pandares/src/main/res/values-pl/strings.xml @@ -1521,4 +1521,5 @@ Kursy %d zostały zsynchronizowane. Dodatkowa zawartość kursu + Nie udało się zaktualizować kolejności kart pulpitu nawigacyjnego diff --git a/libs/pandares/src/main/res/values-pt-rBR/strings.xml b/libs/pandares/src/main/res/values-pt-rBR/strings.xml index ed775bca10..b9828af155 100644 --- a/libs/pandares/src/main/res/values-pt-rBR/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rBR/strings.xml @@ -1481,4 +1481,5 @@ %d cursos foram sincronizados. Conteúdo adicional do curso + Falha ao atualizar a ordem dos cartões do Painel diff --git a/libs/pandares/src/main/res/values-pt-rPT/strings.xml b/libs/pandares/src/main/res/values-pt-rPT/strings.xml index b03b6d9f3d..426b1051aa 100644 --- a/libs/pandares/src/main/res/values-pt-rPT/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rPT/strings.xml @@ -1481,4 +1481,5 @@ %d as disciplinas foram sincronizadas. Conteúdo adicional da disciplina + Falha ao atualizar a ordem dos cartões do Painel diff --git a/libs/pandares/src/main/res/values-ru/strings.xml b/libs/pandares/src/main/res/values-ru/strings.xml index b0df61bd35..7fb0f883b7 100644 --- a/libs/pandares/src/main/res/values-ru/strings.xml +++ b/libs/pandares/src/main/res/values-ru/strings.xml @@ -1521,4 +1521,5 @@ %d курса(-ов) были синхронизированы. Дополнительный контент курса + Не удалось обновить порядок карт на информационной панели diff --git a/libs/pandares/src/main/res/values-sl/strings.xml b/libs/pandares/src/main/res/values-sl/strings.xml index 634e388210..ce64f76177 100644 --- a/libs/pandares/src/main/res/values-sl/strings.xml +++ b/libs/pandares/src/main/res/values-sl/strings.xml @@ -1481,4 +1481,5 @@ %d predmetov je bilo sinhroniziranih. Dodatna vsebina predmeta + Vrstnega reda kartic nadzorne plošče ni bilo mogoče posodobiti diff --git a/libs/pandares/src/main/res/values-sv/strings.xml b/libs/pandares/src/main/res/values-sv/strings.xml index 57b0530642..def34c123a 100644 --- a/libs/pandares/src/main/res/values-sv/strings.xml +++ b/libs/pandares/src/main/res/values-sv/strings.xml @@ -1481,4 +1481,5 @@ %d-kurser har synkroniserats. Extra kursinnehåll + Det gick inte att uppdatera översiktens kortordning diff --git a/libs/pandares/src/main/res/values-th/strings.xml b/libs/pandares/src/main/res/values-th/strings.xml index fa9be66bc5..df2e36e324 100644 --- a/libs/pandares/src/main/res/values-th/strings.xml +++ b/libs/pandares/src/main/res/values-th/strings.xml @@ -1481,4 +1481,5 @@ %d บทเรียนได้รับการซิงค์แล้ว เนื้อหาบทเรียนเพิ่มเติม + ไม่สามารถอัพเดตลำดับการ์ดแผงข้อมูล diff --git a/libs/pandares/src/main/res/values-vi/strings.xml b/libs/pandares/src/main/res/values-vi/strings.xml index ed23161453..f7cdda048f 100644 --- a/libs/pandares/src/main/res/values-vi/strings.xml +++ b/libs/pandares/src/main/res/values-vi/strings.xml @@ -1482,4 +1482,5 @@ %d khóa học đã được đồng bộ. Nội dung khóa học bổ sung + Không thể cập nhật thứ tự thẻ Bảng Điều Khiển diff --git a/libs/pandares/src/main/res/values-zh/strings.xml b/libs/pandares/src/main/res/values-zh/strings.xml index 7149d2279e..e8294a5955 100644 --- a/libs/pandares/src/main/res/values-zh/strings.xml +++ b/libs/pandares/src/main/res/values-zh/strings.xml @@ -1461,4 +1461,5 @@ %d 门课程已同步。 更多课程内容 + 无法更新控制面板卡片顺序 From 68198a5e55280f0e04d41615006512ba5377ac46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Tue, 30 Jan 2024 16:08:41 +0100 Subject: [PATCH 08/52] Version bump --- apps/teacher/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index 8972440a88..b277eb65da 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -39,8 +39,8 @@ android { defaultConfig { minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 61 - versionName = '1.27.0' + versionCode = 62 + versionName = '1.28.0' vectorDrawables.useSupportLibrary = true multiDexEnabled true testInstrumentationRunner 'com.instructure.teacher.ui.espresso.TeacherHiltTestRunner' From fc5201d32accdb0cc99fd1faf3b60b013c8163a4 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Wed, 31 Jan 2024 15:22:13 +0100 Subject: [PATCH 09/52] Extract common parts of the Offline E2E tests. (#2315) --- .../offline/ManageOfflineContentE2ETest.kt | 15 ++++--------- .../e2e/offline/OfflineAllCoursesE2ETest.kt | 22 ++++--------------- .../offline/OfflineCourseBrowserE2ETest.kt | 21 +++++------------- .../ui/e2e/offline/OfflineDashboardE2ETest.kt | 18 ++++----------- .../ui/e2e/offline/OfflineFilesE2ETest.kt | 15 ++++--------- .../e2e/offline/OfflineLeftSideMenuE2ETest.kt | 7 +++--- .../ui/e2e/offline/OfflineLoginE2ETest.kt | 6 +++-- .../ui/e2e/offline/OfflinePeopleE2ETest.kt | 19 +++------------- .../e2e/offline/OfflineSyncProgressE2ETest.kt | 10 ++------- .../ui/e2e/offline/utils/OfflineTestUtils.kt | 6 +++++ .../e2e/usergroups/UserGroupFilesE2ETest.kt | 4 ++-- .../student/ui/pages/DashboardPage.kt | 13 +++++++++-- 12 files changed, 53 insertions(+), 103 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index 9aa20f8b6a..f66fa17e87 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -24,6 +24,7 @@ import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin @@ -192,20 +193,12 @@ class ManageOfflineContentE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on the 'Sync' button and confirm sync.") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt index b45e9395bb..b9868b8487 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt @@ -19,8 +19,6 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E @@ -28,13 +26,13 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.After import org.junit.Test -import java.lang.Thread.sleep @HiltAndroidTest class OfflineAllCoursesE2ETest : StudentTest() { @@ -54,9 +52,6 @@ class OfflineAllCoursesE2ETest : StudentTest() { val course2 = data.coursesList[1] val course3 = data.coursesList[2] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() @@ -83,20 +78,12 @@ class OfflineAllCoursesE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course2.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered, and assert that '${course1.name}' is the only course which is displayed on the offline mode Dashboard Page.") dashboardPage.waitForRender() @@ -136,7 +123,6 @@ class OfflineAllCoursesE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on '${course1.name}' course and assert if it will navigate the user to the CourseBrowser Page.") allCoursesPage.openCourse(course1.name) courseBrowserPage.assertTitleCorrect(course1) - } @After diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt index 5ea2aeb906..c31bb58e50 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -18,13 +18,12 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.pages.CourseBrowserPage import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData @@ -49,7 +48,6 @@ class OfflineCourseBrowserE2ETest : StudentTest() { val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) val student = data.studentsList[0] val course1 = data.coursesList[0] - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -64,26 +62,17 @@ class OfflineCourseBrowserE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState("Announcements") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + OfflineTestUtils.waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") - sleep(5000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. dashboardPage.selectCourse(course1) Log.d(STEP_TAG, "Assert that only the 'Announcements' tab is enabled because it is the only one which has been synced, and assert that all the other, previously synced tabs are disabled, because they weren't synced now.") @@ -95,7 +84,7 @@ class OfflineCourseBrowserE2ETest : StudentTest() { Log.d(STEP_TAG, "Navigate back to Dashboard Page.Turn back on the Wi-Fi and Mobile Data on the device, and wait for it to come online.") Espresso.pressBack() turnOnConnectionViaADB() - dashboardPage.waitForNetworkComeBack() + dashboardPage.waitForOfflineIndicatorNotDisplayed() Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.openGlobalManageOfflineContentPage() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt index 4be5df3180..21bd315781 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt @@ -27,6 +27,7 @@ import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin @@ -52,9 +53,6 @@ class OfflineDashboardE2ETest : StudentTest() { val course2 = data.coursesList[1] val testAnnouncement = data.announcementsList[0] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() @@ -69,20 +67,12 @@ class OfflineDashboardE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course1.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt index a96cba7482..4a2a2009c7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt @@ -26,6 +26,7 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.FileFolderApi import com.instructure.dataseeding.model.FileUploadType +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin @@ -77,20 +78,12 @@ class OfflineFilesE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState("Files") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + OfflineTestUtils.waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt index 3b185a5d14..6deba129d3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt @@ -22,6 +22,7 @@ import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertNoInternetConnectionDialog import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertOfflineIndicator import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.dismissNoInternetConnectionDialog @@ -53,14 +54,15 @@ class OfflineLeftSideMenuE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. + OfflineTestUtils.waitForNetworkToGoOffline(device) + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") assertOfflineIndicator() - Log.d(STEP_TAG, "Open Left Side Menu by clicking on the 'hamburger icon' on the Dashboard Page.") + Log.d(STEP_TAG, "Open Left Side Menu by clicking on the 'hamburger/kebab icon' on the Dashboard Page.") dashboardPage.openLeftSideMenu() Log.d(STEP_TAG, "Assert that the offline indicator is displayed below the user info within the header.") @@ -91,7 +93,6 @@ class OfflineLeftSideMenuE2ETest : StudentTest() { leftSideNavigationDrawerPage.clickHelpMenu() assertNoInternetConnectionDialog() dismissNoInternetConnectionDialog() - } @After diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt index a4718ab009..f8eede4b34 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt @@ -26,6 +26,7 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertNoInternetConnectionDialog import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.dismissNoInternetConnectionDialog +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import dagger.hilt.android.testing.HiltAndroidTest @@ -61,6 +62,8 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Login with user: ${student2.name}, login id: ${student2.loginId}.") loginWithUser(student2, true) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline indicator is not displayed because we are in online mode yet.") @@ -68,7 +71,7 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - device.waitForWindowUpdate(null, 10000) + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() @@ -104,7 +107,6 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Assert that the offline indicator is displayed to ensure we are in offline mode, and change user function is supported.") dashboardPage.waitForRender() dashboardPage.assertOfflineIndicatorDisplayed() - } private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt index 707757c566..2ebcdfd833 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt @@ -18,8 +18,6 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority @@ -50,9 +48,6 @@ class OfflinePeopleE2ETest : StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() @@ -65,20 +60,12 @@ class OfflinePeopleE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState("People") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + OfflineTestUtils.waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt index a424546711..275575ba0e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt @@ -18,8 +18,6 @@ package com.instructure.student.ui.e2e.offline import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E @@ -52,9 +50,6 @@ class OfflineSyncProgressE2ETest : StudentTest() { val course2 = data.coursesList[1] val testAnnouncement = data.announcementsList[0] - Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() @@ -90,10 +85,9 @@ class OfflineSyncProgressE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) + OfflineTestUtils.waitForNetworkToGoOffline(device) + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt index 0696eec9b3..c7dbd9c204 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt @@ -78,4 +78,10 @@ object OfflineTestUtils { hasSibling(withId(R.id.topPanel) + hasDescendant(withText(R.string.noInternetConnectionTitle))))).click() } + + fun waitForNetworkToGoOffline(device: UiDevice) { + Thread.sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. + device.waitForIdle() + device.waitForWindowUpdate(null, 10000) + } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt index be17d15451..1c40730be3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * Copyright (C) 2023 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,12 +20,12 @@ import android.util.Log import androidx.test.espresso.Espresso import androidx.test.espresso.intent.Intents import com.instructure.canvas.espresso.E2E -import com.instructure.dataseeding.api.GroupsApi import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.GroupsApi import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index 18971f74a5..91e3e38702 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -359,7 +359,7 @@ class DashboardPage : BasePage(R.id.dashboardPage) { } //OfflineMethod - fun waitForNetworkComeBack() { + fun waitForOfflineIndicatorNotDisplayed() { assertDisplaysCourses() retry(times = 5, delay = 2000) { assertOfflineIndicatorNotDisplayed() @@ -367,13 +367,22 @@ class DashboardPage : BasePage(R.id.dashboardPage) { } //OfflineMethod - fun waitForNetworkOff() { + fun waitForOfflineIndicatorDisplayed() { assertDisplaysCourses() retry(times = 5, delay = 2000) { assertOfflineIndicatorDisplayed() } } + //OfflineMethod + fun waitForOfflineSyncDashboardNotifications() { + waitForSyncProgressDownloadStartedNotification() + waitForSyncProgressDownloadStartedNotificationToDisappear() + + waitForSyncProgressStartingNotification() + waitForSyncProgressStartingNotificationToDisappear() + } + //OfflineMethod fun assertCourseOfflineSyncIconVisible(courseName: String) { waitForView(withId(R.id.offlineSyncIcon) + hasSibling(withId(R.id.titleTextView) + withText(courseName))).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) From 90ae79ae693be50ccbcd6865429c9e57031e2680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Thu, 1 Feb 2024 18:26:09 +0100 Subject: [PATCH 10/52] add migration --- .../2.json | 5515 +++++++++++++++++ .../room/offline/OfflineDatabase.kt | 2 +- .../room/offline/OfflineDatabaseMigrations.kt | 60 +- 3 files changed, 5575 insertions(+), 2 deletions(-) create mode 100644 libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/2.json diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/2.json b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/2.json new file mode 100644 index 0000000000..732f0ee4b0 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/2.json @@ -0,0 +1,5515 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "1182046fc85ec1b16d7693e1a593081b", + "entities": [ + { + "tableName": "AssignmentDueDateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `assignmentOverrideId` INTEGER, `dueAt` TEXT, `title` TEXT, `unlockAt` TEXT, `lockAt` TEXT, `isBase` INTEGER NOT NULL, PRIMARY KEY(`assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentOverrideId", + "columnName": "assignmentOverrideId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isBase", + "columnName": "isBase", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `description` TEXT, `submissionTypesRaw` TEXT NOT NULL, `dueAt` TEXT, `pointsPossible` REAL NOT NULL, `courseId` INTEGER NOT NULL, `isGradeGroupsIndividually` INTEGER NOT NULL, `gradingType` TEXT, `needsGradingCount` INTEGER NOT NULL, `htmlUrl` TEXT, `url` TEXT, `quizId` INTEGER NOT NULL, `isUseRubricForGrading` INTEGER NOT NULL, `rubricSettingsId` INTEGER, `allowedExtensions` TEXT NOT NULL, `submissionId` INTEGER, `assignmentGroupId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `isPeerReviews` INTEGER NOT NULL, `lockedForUser` INTEGER NOT NULL, `lockAt` TEXT, `unlockAt` TEXT, `lockExplanation` TEXT, `discussionTopicHeaderId` INTEGER, `freeFormCriterionComments` INTEGER NOT NULL, `published` INTEGER NOT NULL, `groupCategoryId` INTEGER NOT NULL, `userSubmitted` INTEGER NOT NULL, `unpublishable` INTEGER NOT NULL, `onlyVisibleToOverrides` INTEGER NOT NULL, `anonymousPeerReviews` INTEGER NOT NULL, `moderatedGrading` INTEGER NOT NULL, `anonymousGrading` INTEGER NOT NULL, `allowedAttempts` INTEGER NOT NULL, `plannerOverrideId` INTEGER, `isStudioEnabled` INTEGER NOT NULL, `inClosedGradingPeriod` INTEGER NOT NULL, `annotatableAttachmentId` INTEGER NOT NULL, `anonymousSubmissions` INTEGER NOT NULL, `omitFromFinalGrade` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentGroupId`) REFERENCES `AssignmentGroupEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionTypesRaw", + "columnName": "submissionTypesRaw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGradeGroupsIndividually", + "columnName": "isGradeGroupsIndividually", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gradingType", + "columnName": "gradingType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "needsGradingCount", + "columnName": "needsGradingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUseRubricForGrading", + "columnName": "isUseRubricForGrading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rubricSettingsId", + "columnName": "rubricSettingsId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "allowedExtensions", + "columnName": "allowedExtensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentGroupId", + "columnName": "assignmentGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPeerReviews", + "columnName": "isPeerReviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "discussionTopicHeaderId", + "columnName": "discussionTopicHeaderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "freeFormCriterionComments", + "columnName": "freeFormCriterionComments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupCategoryId", + "columnName": "groupCategoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userSubmitted", + "columnName": "userSubmitted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unpublishable", + "columnName": "unpublishable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlyVisibleToOverrides", + "columnName": "onlyVisibleToOverrides", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anonymousPeerReviews", + "columnName": "anonymousPeerReviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "moderatedGrading", + "columnName": "moderatedGrading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anonymousGrading", + "columnName": "anonymousGrading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowedAttempts", + "columnName": "allowedAttempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "plannerOverrideId", + "columnName": "plannerOverrideId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isStudioEnabled", + "columnName": "isStudioEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inClosedGradingPeriod", + "columnName": "inClosedGradingPeriod", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "annotatableAttachmentId", + "columnName": "annotatableAttachmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "anonymousSubmissions", + "columnName": "anonymousSubmissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "omitFromFinalGrade", + "columnName": "omitFromFinalGrade", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentGroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentGroupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentGroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `position` INTEGER NOT NULL, `groupWeight` REAL NOT NULL, `rules` TEXT, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupWeight", + "columnName": "groupWeight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rules", + "columnName": "rules", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentOverrideEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `title` TEXT, `dueAt` INTEGER, `isAllDay` INTEGER NOT NULL, `allDayDate` TEXT, `unlockAt` INTEGER, `lockAt` INTEGER, `courseSectionId` INTEGER NOT NULL, `groupId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isAllDay", + "columnName": "isAllDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allDayDate", + "columnName": "allDayDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseSectionId", + "columnName": "courseSectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentRubricCriterionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `rubricId` TEXT NOT NULL, PRIMARY KEY(`assignmentId`, `rubricId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rubricId", + "columnName": "rubricId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId", + "rubricId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentScoreStatisticsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `mean` REAL NOT NULL, `min` REAL NOT NULL, `max` REAL NOT NULL, PRIMARY KEY(`assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mean", + "columnName": "mean", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "min", + "columnName": "min", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "max", + "columnName": "max", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AssignmentSetEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `scoringRangeId` INTEGER NOT NULL, `createdAt` TEXT, `updatedAt` TEXT, `position` INTEGER NOT NULL, `masteryPathId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`masteryPathId`) REFERENCES `MasteryPathEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoringRangeId", + "columnName": "scoringRangeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "masteryPathId", + "columnName": "masteryPathId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "MasteryPathEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "masteryPathId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `originalName` TEXT, `courseCode` TEXT, `startAt` TEXT, `endAt` TEXT, `syllabusBody` TEXT, `hideFinalGrades` INTEGER NOT NULL, `isPublic` INTEGER NOT NULL, `license` TEXT NOT NULL, `termId` INTEGER, `needsGradingCount` INTEGER NOT NULL, `isApplyAssignmentGroupWeights` INTEGER NOT NULL, `currentScore` REAL, `finalScore` REAL, `currentGrade` TEXT, `finalGrade` TEXT, `isFavorite` INTEGER NOT NULL, `accessRestrictedByDate` INTEGER NOT NULL, `imageUrl` TEXT, `bannerImageUrl` TEXT, `isWeightedGradingPeriods` INTEGER NOT NULL, `hasGradingPeriods` INTEGER NOT NULL, `homePage` TEXT, `restrictEnrollmentsToCourseDate` INTEGER NOT NULL, `workflowState` TEXT, `homeroomCourse` INTEGER NOT NULL, `courseColor` TEXT, `gradingScheme` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`termId`) REFERENCES `TermEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalName", + "columnName": "originalName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseCode", + "columnName": "courseCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "syllabusBody", + "columnName": "syllabusBody", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideFinalGrades", + "columnName": "hideFinalGrades", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "termId", + "columnName": "termId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "needsGradingCount", + "columnName": "needsGradingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isApplyAssignmentGroupWeights", + "columnName": "isApplyAssignmentGroupWeights", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentScore", + "columnName": "currentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "finalScore", + "columnName": "finalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentGrade", + "columnName": "currentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "finalGrade", + "columnName": "finalGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessRestrictedByDate", + "columnName": "accessRestrictedByDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerImageUrl", + "columnName": "bannerImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isWeightedGradingPeriods", + "columnName": "isWeightedGradingPeriods", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasGradingPeriods", + "columnName": "hasGradingPeriods", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "homePage", + "columnName": "homePage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "restrictEnrollmentsToCourseDate", + "columnName": "restrictEnrollmentsToCourseDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workflowState", + "columnName": "workflowState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "homeroomCourse", + "columnName": "homeroomCourse", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseColor", + "columnName": "courseColor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingScheme", + "columnName": "gradingScheme", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "TermEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "termId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseFilesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`courseId`, `url`), FOREIGN KEY(`courseId`) REFERENCES `CourseSyncSettingsEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId", + "url" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseSyncSettingsEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "courseId" + ] + } + ] + }, + { + "tableName": "CourseGradingPeriodEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `gradingPeriodId` INTEGER NOT NULL, PRIMARY KEY(`courseId`, `gradingPeriodId`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`gradingPeriodId`) REFERENCES `GradingPeriodEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gradingPeriodId", + "columnName": "gradingPeriodId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId", + "gradingPeriodId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GradingPeriodEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "gradingPeriodId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `courseSummary` INTEGER, `restrictQuantitativeData` INTEGER NOT NULL, PRIMARY KEY(`courseId`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseSummary", + "columnName": "courseSummary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "restrictQuantitativeData", + "columnName": "restrictQuantitativeData", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseSyncSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `courseName` TEXT NOT NULL, `fullContentSync` INTEGER NOT NULL, `tabs` TEXT NOT NULL, `fullFileSync` INTEGER NOT NULL, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseName", + "columnName": "courseName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullContentSync", + "columnName": "fullContentSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabs", + "columnName": "tabs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullFileSync", + "columnName": "fullFileSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isK5Subject` INTEGER NOT NULL, `shortName` TEXT, `originalName` TEXT, `courseCode` TEXT, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isK5Subject", + "columnName": "isK5Subject", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "originalName", + "columnName": "originalName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseCode", + "columnName": "courseCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DiscussionEntryAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`discussionEntryId` INTEGER NOT NULL, `remoteFileId` INTEGER NOT NULL, PRIMARY KEY(`discussionEntryId`, `remoteFileId`), FOREIGN KEY(`discussionEntryId`) REFERENCES `DiscussionEntryEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`remoteFileId`) REFERENCES `RemoteFileEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "discussionEntryId", + "columnName": "discussionEntryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteFileId", + "columnName": "remoteFileId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "discussionEntryId", + "remoteFileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionEntryId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "RemoteFileEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "remoteFileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` TEXT, `createdAt` TEXT, `authorId` INTEGER, `description` TEXT, `userId` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `message` TEXT, `deleted` INTEGER NOT NULL, `totalChildren` INTEGER NOT NULL, `unreadChildren` INTEGER NOT NULL, `ratingCount` INTEGER NOT NULL, `ratingSum` INTEGER NOT NULL, `editorId` INTEGER NOT NULL, `_hasRated` INTEGER NOT NULL, `replyIds` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalChildren", + "columnName": "totalChildren", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadChildren", + "columnName": "unreadChildren", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingCount", + "columnName": "ratingCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingSum", + "columnName": "ratingSum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editorId", + "columnName": "editorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_hasRated", + "columnName": "_hasRated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyIds", + "columnName": "replyIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DiscussionParticipantEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `pronouns` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DiscussionTopicHeaderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `discussionType` TEXT, `title` TEXT, `message` TEXT, `htmlUrl` TEXT, `postedDate` INTEGER, `delayedPostDate` INTEGER, `lastReplyDate` INTEGER, `requireInitialPost` INTEGER NOT NULL, `discussionSubentryCount` INTEGER NOT NULL, `readState` TEXT, `unreadCount` INTEGER NOT NULL, `position` INTEGER NOT NULL, `assignmentId` INTEGER, `locked` INTEGER NOT NULL, `lockedForUser` INTEGER NOT NULL, `lockExplanation` TEXT, `pinned` INTEGER NOT NULL, `authorId` INTEGER, `podcastUrl` TEXT, `groupCategoryId` TEXT, `announcement` INTEGER NOT NULL, `permissionId` INTEGER, `published` INTEGER NOT NULL, `allowRating` INTEGER NOT NULL, `onlyGradersCanRate` INTEGER NOT NULL, `sortByRating` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `lockAt` INTEGER, `userCanSeePosts` INTEGER NOT NULL, `specificSections` TEXT, `anonymousState` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`authorId`) REFERENCES `DiscussionParticipantEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`permissionId`) REFERENCES `DiscussionTopicPermissionEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionType", + "columnName": "discussionType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "postedDate", + "columnName": "postedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "delayedPostDate", + "columnName": "delayedPostDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastReplyDate", + "columnName": "lastReplyDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "requireInitialPost", + "columnName": "requireInitialPost", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionSubentryCount", + "columnName": "discussionSubentryCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readState", + "columnName": "readState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "podcastUrl", + "columnName": "podcastUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupCategoryId", + "columnName": "groupCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "announcement", + "columnName": "announcement", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionId", + "columnName": "permissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowRating", + "columnName": "allowRating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlyGradersCanRate", + "columnName": "onlyGradersCanRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortByRating", + "columnName": "sortByRating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "userCanSeePosts", + "columnName": "userCanSeePosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specificSections", + "columnName": "specificSections", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "anonymousState", + "columnName": "anonymousState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionParticipantEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "authorId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "DiscussionTopicPermissionEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "permissionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionTopicPermissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `discussionTopicHeaderId` INTEGER NOT NULL, `attach` INTEGER NOT NULL, `update` INTEGER NOT NULL, `delete` INTEGER NOT NULL, `reply` INTEGER NOT NULL, FOREIGN KEY(`discussionTopicHeaderId`) REFERENCES `DiscussionTopicHeaderEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionTopicHeaderId", + "columnName": "discussionTopicHeaderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attach", + "columnName": "attach", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delete", + "columnName": "delete", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reply", + "columnName": "reply", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionTopicHeaderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionTopicHeaderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionTopicRemoteFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`discussionId` INTEGER NOT NULL, `remoteFileId` INTEGER NOT NULL, PRIMARY KEY(`discussionId`, `remoteFileId`), FOREIGN KEY(`discussionId`) REFERENCES `DiscussionTopicHeaderEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`remoteFileId`) REFERENCES `RemoteFileEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "discussionId", + "columnName": "discussionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteFileId", + "columnName": "remoteFileId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "discussionId", + "remoteFileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionTopicHeaderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "RemoteFileEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "remoteFileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DiscussionTopicSectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`discussionTopicId` INTEGER NOT NULL, `sectionId` INTEGER NOT NULL, PRIMARY KEY(`discussionTopicId`, `sectionId`), FOREIGN KEY(`discussionTopicId`) REFERENCES `DiscussionTopicHeaderEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sectionId`) REFERENCES `SectionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "discussionTopicId", + "columnName": "discussionTopicId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionId", + "columnName": "sectionId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "discussionTopicId", + "sectionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionTopicHeaderEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionTopicId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "SectionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "EnrollmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `role` TEXT NOT NULL, `type` TEXT NOT NULL, `courseId` INTEGER, `courseSectionId` INTEGER, `enrollmentState` TEXT, `userId` INTEGER NOT NULL, `computedCurrentScore` REAL, `computedFinalScore` REAL, `computedCurrentGrade` TEXT, `computedFinalGrade` TEXT, `multipleGradingPeriodsEnabled` INTEGER NOT NULL, `totalsForAllGradingPeriodsOption` INTEGER NOT NULL, `currentPeriodComputedCurrentScore` REAL, `currentPeriodComputedFinalScore` REAL, `currentPeriodComputedCurrentGrade` TEXT, `currentPeriodComputedFinalGrade` TEXT, `currentGradingPeriodId` INTEGER NOT NULL, `currentGradingPeriodTitle` TEXT, `associatedUserId` INTEGER NOT NULL, `lastActivityAt` INTEGER, `limitPrivilegesToCourseSection` INTEGER NOT NULL, `observedUserId` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`observedUserId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`courseSectionId`) REFERENCES `SectionEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseSectionId", + "columnName": "courseSectionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "enrollmentState", + "columnName": "enrollmentState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "computedCurrentScore", + "columnName": "computedCurrentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "computedFinalScore", + "columnName": "computedFinalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "computedCurrentGrade", + "columnName": "computedCurrentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "computedFinalGrade", + "columnName": "computedFinalGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "multipleGradingPeriodsEnabled", + "columnName": "multipleGradingPeriodsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalsForAllGradingPeriodsOption", + "columnName": "totalsForAllGradingPeriodsOption", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPeriodComputedCurrentScore", + "columnName": "currentPeriodComputedCurrentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentPeriodComputedFinalScore", + "columnName": "currentPeriodComputedFinalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentPeriodComputedCurrentGrade", + "columnName": "currentPeriodComputedCurrentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currentPeriodComputedFinalGrade", + "columnName": "currentPeriodComputedFinalGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currentGradingPeriodId", + "columnName": "currentGradingPeriodId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentGradingPeriodTitle", + "columnName": "currentGradingPeriodTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "associatedUserId", + "columnName": "associatedUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivityAt", + "columnName": "lastActivityAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "limitPrivilegesToCourseSection", + "columnName": "limitPrivilegesToCourseSection", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "observedUserId", + "columnName": "observedUserId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UserEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "observedUserId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "SectionEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "courseSectionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FileFolderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `createdDate` INTEGER, `updatedDate` INTEGER, `unlockDate` INTEGER, `lockDate` INTEGER, `isLocked` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `isLockedForUser` INTEGER NOT NULL, `isHiddenForUser` INTEGER NOT NULL, `folderId` INTEGER NOT NULL, `size` INTEGER NOT NULL, `contentType` TEXT, `url` TEXT, `displayName` TEXT, `thumbnailUrl` TEXT, `parentFolderId` INTEGER NOT NULL, `contextId` INTEGER NOT NULL, `filesCount` INTEGER NOT NULL, `position` INTEGER NOT NULL, `foldersCount` INTEGER NOT NULL, `contextType` TEXT, `name` TEXT, `foldersUrl` TEXT, `filesUrl` TEXT, `fullName` TEXT, `forSubmissions` INTEGER NOT NULL, `canUpload` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdDate", + "columnName": "createdDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedDate", + "columnName": "updatedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "unlockDate", + "columnName": "unlockDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lockDate", + "columnName": "lockDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLockedForUser", + "columnName": "isLockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHiddenForUser", + "columnName": "isHiddenForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filesCount", + "columnName": "filesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "foldersCount", + "columnName": "foldersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "foldersUrl", + "columnName": "foldersUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filesUrl", + "columnName": "filesUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "forSubmissions", + "columnName": "forSubmissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUpload", + "columnName": "canUpload", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EditDashboardItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `name` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, `enrollmentState` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentState", + "columnName": "enrollmentState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ExternalToolAttributesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentId` INTEGER NOT NULL, `url` TEXT, `newTab` INTEGER NOT NULL, `resourceLinkid` TEXT, `contentId` INTEGER, PRIMARY KEY(`assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "newTab", + "columnName": "newTab", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceLinkid", + "columnName": "resourceLinkid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GradesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`enrollmentId` INTEGER NOT NULL, `htmlUrl` TEXT NOT NULL, `currentScore` REAL, `finalScore` REAL, `currentGrade` TEXT, `finalGrade` TEXT, PRIMARY KEY(`enrollmentId`), FOREIGN KEY(`enrollmentId`) REFERENCES `EnrollmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "enrollmentId", + "columnName": "enrollmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentScore", + "columnName": "currentScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "finalScore", + "columnName": "finalScore", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "currentGrade", + "columnName": "currentGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "finalGrade", + "columnName": "finalGrade", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "enrollmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "EnrollmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "enrollmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GradingPeriodEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT, `startDate` TEXT, `endDate` TEXT, `weight` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `description` TEXT, `avatarUrl` TEXT, `isPublic` INTEGER NOT NULL, `membersCount` INTEGER NOT NULL, `joinLevel` TEXT, `courseId` INTEGER NOT NULL, `accountId` INTEGER NOT NULL, `role` TEXT, `groupCategoryId` INTEGER NOT NULL, `storageQuotaMb` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `concluded` INTEGER NOT NULL, `canAccess` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "membersCount", + "columnName": "membersCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "joinLevel", + "columnName": "joinLevel", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "groupCategoryId", + "columnName": "groupCategoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storageQuotaMb", + "columnName": "storageQuotaMb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "concluded", + "columnName": "concluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canAccess", + "columnName": "canAccess", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "GroupUserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `userId` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocalFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `createdDate` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdDate", + "columnName": "createdDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MasteryPathAssignmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `createdAt` TEXT, `updatedAt` TEXT, `overrideId` INTEGER NOT NULL, `assignmentSetId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentSetId`) REFERENCES `AssignmentSetEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "overrideId", + "columnName": "overrideId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentSetId", + "columnName": "assignmentSetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentSetEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentSetId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MasteryPathEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `selectedSetId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `ModuleItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "selectedSetId", + "columnName": "selectedSetId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleItemEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleContentDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pointsPossible` TEXT, `dueAt` TEXT, `unlockAt` TEXT, `lockAt` TEXT, `lockedForUser` INTEGER NOT NULL, `lockExplanation` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `ModuleItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleItemEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `moduleId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT, `indent` INTEGER NOT NULL, `type` TEXT, `htmlUrl` TEXT, `url` TEXT, `published` INTEGER, `contentId` INTEGER NOT NULL, `externalUrl` TEXT, `pageUrl` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`moduleId`) REFERENCES `ModuleObjectEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "moduleId", + "columnName": "moduleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "indent", + "columnName": "indent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalUrl", + "columnName": "externalUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pageUrl", + "columnName": "pageUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleObjectEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "moduleId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleObjectEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `position` INTEGER NOT NULL, `name` TEXT, `unlockAt` TEXT, `sequentialProgress` INTEGER NOT NULL, `prerequisiteIds` TEXT, `state` TEXT, `completedAt` TEXT, `published` INTEGER, `itemCount` INTEGER NOT NULL, `itemsUrl` TEXT NOT NULL, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sequentialProgress", + "columnName": "sequentialProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prerequisiteIds", + "columnName": "prerequisiteIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "itemCount", + "columnName": "itemCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemsUrl", + "columnName": "itemsUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NeedsGradingCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sectionId` INTEGER NOT NULL, `needsGradingCount` INTEGER NOT NULL, PRIMARY KEY(`sectionId`), FOREIGN KEY(`sectionId`) REFERENCES `SectionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sectionId", + "columnName": "sectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsGradingCount", + "columnName": "needsGradingCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sectionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "SectionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, `createdAt` INTEGER, `updatedAt` INTEGER, `hideFromStudents` INTEGER NOT NULL, `status` TEXT, `body` TEXT, `frontPage` INTEGER NOT NULL, `published` INTEGER NOT NULL, `editingRoles` TEXT, `htmlUrl` TEXT, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hideFromStudents", + "columnName": "hideFromStudents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "frontPage", + "columnName": "frontPage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editingRoles", + "columnName": "editingRoles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PlannerOverrideEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `plannableType` TEXT NOT NULL, `plannableId` INTEGER NOT NULL, `dismissed` INTEGER NOT NULL, `markedComplete` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "plannableType", + "columnName": "plannableType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plannableId", + "columnName": "plannableId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dismissed", + "columnName": "dismissed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "markedComplete", + "columnName": "markedComplete", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `folderId` INTEGER NOT NULL, `displayName` TEXT, `fileName` TEXT, `contentType` TEXT, `url` TEXT, `size` INTEGER NOT NULL, `createdAt` TEXT, `updatedAt` TEXT, `unlockAt` TEXT, `locked` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, `lockAt` TEXT, `hiddenForUser` INTEGER NOT NULL, `thumbnailUrl` TEXT, `modifiedAt` TEXT, `lockedForUser` INTEGER NOT NULL, `previewUrl` TEXT, `lockExplanation` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hiddenForUser", + "columnName": "hiddenForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "modifiedAt", + "columnName": "modifiedAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RubricCriterionAssessmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `assignmentId` INTEGER NOT NULL, `ratingId` TEXT, `points` REAL, `comments` TEXT, PRIMARY KEY(`id`, `assignmentId`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingId", + "columnName": "ratingId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "comments", + "columnName": "comments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "assignmentId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RubricCriterionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT, `longDescription` TEXT, `points` REAL NOT NULL, `criterionUseRange` INTEGER NOT NULL, `ignoreForScoring` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "criterionUseRange", + "columnName": "criterionUseRange", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ignoreForScoring", + "columnName": "ignoreForScoring", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RubricCriterionRatingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT, `longDescription` TEXT, `points` REAL NOT NULL, `rubricCriterionId` TEXT NOT NULL, PRIMARY KEY(`id`, `rubricCriterionId`), FOREIGN KEY(`rubricCriterionId`) REFERENCES `RubricCriterionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "points", + "columnName": "points", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rubricCriterionId", + "columnName": "rubricCriterionId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "rubricCriterionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "RubricCriterionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "rubricCriterionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RubricSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `contextId` INTEGER NOT NULL, `contextType` TEXT, `pointsPossible` REAL NOT NULL, `title` TEXT NOT NULL, `isReusable` INTEGER NOT NULL, `isPublic` INTEGER NOT NULL, `isReadOnly` INTEGER NOT NULL, `freeFormCriterionComments` INTEGER NOT NULL, `hideScoreTotal` INTEGER NOT NULL, `hidePoints` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isReusable", + "columnName": "isReusable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReadOnly", + "columnName": "isReadOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeFormCriterionComments", + "columnName": "freeFormCriterionComments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hideScoreTotal", + "columnName": "hideScoreTotal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hidePoints", + "columnName": "hidePoints", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ScheduleItemAssignmentOverrideEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assignmentOverrideId` INTEGER NOT NULL, `scheduleItemId` TEXT NOT NULL, PRIMARY KEY(`assignmentOverrideId`, `scheduleItemId`), FOREIGN KEY(`assignmentOverrideId`) REFERENCES `AssignmentOverrideEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`scheduleItemId`) REFERENCES `ScheduleItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assignmentOverrideId", + "columnName": "assignmentOverrideId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleItemId", + "columnName": "scheduleItemId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "assignmentOverrideId", + "scheduleItemId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AssignmentOverrideEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentOverrideId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ScheduleItemEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scheduleItemId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ScheduleItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `startAt` TEXT, `endAt` TEXT, `isAllDay` INTEGER NOT NULL, `allDayAt` TEXT, `locationAddress` TEXT, `locationName` TEXT, `htmlUrl` TEXT, `contextCode` TEXT, `effectiveContextCode` TEXT, `isHidden` INTEGER NOT NULL, `importantDates` INTEGER NOT NULL, `assignmentId` INTEGER, `type` TEXT NOT NULL, `itemType` TEXT, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isAllDay", + "columnName": "isAllDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allDayAt", + "columnName": "allDayAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationAddress", + "columnName": "locationAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationName", + "columnName": "locationName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contextCode", + "columnName": "contextCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "effectiveContextCode", + "columnName": "effectiveContextCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importantDates", + "columnName": "importantDates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemType", + "columnName": "itemType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SectionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `courseId` INTEGER, `startAt` TEXT, `endAt` TEXT, `totalStudents` INTEGER NOT NULL, `restrictEnrollmentsToSectionDates` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalStudents", + "columnName": "totalStudents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictEnrollmentsToSectionDates", + "columnName": "restrictEnrollmentsToSectionDates", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SubmissionDiscussionEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`submissionId` INTEGER NOT NULL, `discussionEntryId` INTEGER NOT NULL, PRIMARY KEY(`submissionId`, `discussionEntryId`), FOREIGN KEY(`discussionEntryId`) REFERENCES `DiscussionEntryEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionEntryId", + "columnName": "discussionEntryId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "submissionId", + "discussionEntryId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "DiscussionEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "discussionEntryId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SubmissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `grade` TEXT, `score` REAL NOT NULL, `attempt` INTEGER NOT NULL, `submittedAt` INTEGER, `commentCreated` INTEGER, `mediaContentType` TEXT, `mediaCommentUrl` TEXT, `mediaCommentDisplay` TEXT, `body` TEXT, `isGradeMatchesCurrentSubmission` INTEGER NOT NULL, `workflowState` TEXT, `submissionType` TEXT, `previewUrl` TEXT, `url` TEXT, `late` INTEGER NOT NULL, `excused` INTEGER NOT NULL, `missing` INTEGER NOT NULL, `mediaCommentId` TEXT, `assignmentId` INTEGER NOT NULL, `userId` INTEGER, `graderId` INTEGER, `groupId` INTEGER, `pointsDeducted` REAL, `enteredScore` REAL NOT NULL, `enteredGrade` TEXT, `postedAt` INTEGER, `gradingPeriodId` INTEGER, PRIMARY KEY(`id`, `attempt`), FOREIGN KEY(`groupId`) REFERENCES `GroupEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "grade", + "columnName": "grade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submittedAt", + "columnName": "submittedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "commentCreated", + "columnName": "commentCreated", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaContentType", + "columnName": "mediaContentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaCommentUrl", + "columnName": "mediaCommentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaCommentDisplay", + "columnName": "mediaCommentDisplay", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isGradeMatchesCurrentSubmission", + "columnName": "isGradeMatchesCurrentSubmission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workflowState", + "columnName": "workflowState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionType", + "columnName": "submissionType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "late", + "columnName": "late", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excused", + "columnName": "excused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "missing", + "columnName": "missing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "graderId", + "columnName": "graderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pointsDeducted", + "columnName": "pointsDeducted", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "enteredScore", + "columnName": "enteredScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "enteredGrade", + "columnName": "enteredGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "postedAt", + "columnName": "postedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPeriodId", + "columnName": "gradingPeriodId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "attempt" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "GroupEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UserEntity", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SyncSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `autoSyncEnabled` INTEGER NOT NULL, `syncFrequency` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoSyncEnabled", + "columnName": "autoSyncEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncFrequency", + "columnName": "syncFrequency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TabEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `label` TEXT, `type` TEXT NOT NULL, `htmlUrl` TEXT, `externalUrl` TEXT, `visibility` TEXT NOT NULL, `isHidden` INTEGER NOT NULL, `position` INTEGER NOT NULL, `ltiUrl` TEXT NOT NULL, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`, `courseId`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalUrl", + "columnName": "externalUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ltiUrl", + "columnName": "ltiUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "courseId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TermEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `startAt` TEXT, `endAt` TEXT, `isGroupTerm` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startAt", + "columnName": "startAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endAt", + "columnName": "endAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isGroupTerm", + "columnName": "isGroupTerm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UserCalendarEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ics` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ics", + "columnName": "ics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortName` TEXT, `loginId` TEXT, `avatarUrl` TEXT, `primaryEmail` TEXT, `email` TEXT, `sortableName` TEXT, `bio` TEXT, `enrollmentIndex` INTEGER NOT NULL, `lastLogin` TEXT, `locale` TEXT, `effective_locale` TEXT, `pronouns` TEXT, `k5User` INTEGER NOT NULL, `rootAccount` TEXT, `isFakeStudent` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "primaryEmail", + "columnName": "primaryEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sortableName", + "columnName": "sortableName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentIndex", + "columnName": "enrollmentIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastLogin", + "columnName": "lastLogin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "effective_locale", + "columnName": "effective_locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "k5User", + "columnName": "k5User", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rootAccount", + "columnName": "rootAccount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFakeStudent", + "columnName": "isFakeStudent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "QuizEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT, `mobileUrl` TEXT, `htmlUrl` TEXT, `description` TEXT, `quizType` TEXT, `assignmentGroupId` INTEGER NOT NULL, `allowedAttempts` INTEGER NOT NULL, `questionCount` INTEGER NOT NULL, `pointsPossible` TEXT, `isLockQuestionsAfterAnswering` INTEGER NOT NULL, `dueAt` TEXT, `timeLimit` INTEGER NOT NULL, `shuffleAnswers` INTEGER NOT NULL, `showCorrectAnswers` INTEGER NOT NULL, `scoringPolicy` TEXT, `accessCode` TEXT, `ipFilter` TEXT, `lockedForUser` INTEGER NOT NULL, `lockExplanation` TEXT, `hideResults` TEXT, `showCorrectAnswersAt` TEXT, `hideCorrectAnswersAt` TEXT, `unlockAt` TEXT, `oneTimeResults` INTEGER NOT NULL, `lockAt` TEXT, `questionTypes` TEXT NOT NULL, `hasAccessCode` INTEGER NOT NULL, `oneQuestionAtATime` INTEGER NOT NULL, `requireLockdownBrowser` INTEGER NOT NULL, `requireLockdownBrowserForResults` INTEGER NOT NULL, `allowAnonymousSubmissions` INTEGER NOT NULL, `published` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `isOnlyVisibleToOverrides` INTEGER NOT NULL, `unpublishable` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mobileUrl", + "columnName": "mobileUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quizType", + "columnName": "quizType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "assignmentGroupId", + "columnName": "assignmentGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowedAttempts", + "columnName": "allowedAttempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionCount", + "columnName": "questionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pointsPossible", + "columnName": "pointsPossible", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isLockQuestionsAfterAnswering", + "columnName": "isLockQuestionsAfterAnswering", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timeLimit", + "columnName": "timeLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shuffleAnswers", + "columnName": "shuffleAnswers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showCorrectAnswers", + "columnName": "showCorrectAnswers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scoringPolicy", + "columnName": "scoringPolicy", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accessCode", + "columnName": "accessCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ipFilter", + "columnName": "ipFilter", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedForUser", + "columnName": "lockedForUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockExplanation", + "columnName": "lockExplanation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideResults", + "columnName": "hideResults", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showCorrectAnswersAt", + "columnName": "showCorrectAnswersAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hideCorrectAnswersAt", + "columnName": "hideCorrectAnswersAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTimeResults", + "columnName": "oneTimeResults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockAt", + "columnName": "lockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "questionTypes", + "columnName": "questionTypes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasAccessCode", + "columnName": "hasAccessCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "oneQuestionAtATime", + "columnName": "oneQuestionAtATime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requireLockdownBrowser", + "columnName": "requireLockdownBrowser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requireLockdownBrowserForResults", + "columnName": "requireLockdownBrowserForResults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowAnonymousSubmissions", + "columnName": "allowAnonymousSubmissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "published", + "columnName": "published", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOnlyVisibleToOverrides", + "columnName": "isOnlyVisibleToOverrides", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unpublishable", + "columnName": "unpublishable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LockInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `modulePrerequisiteNames` TEXT, `unlockAt` TEXT, `lockedModuleId` INTEGER, `assignmentId` INTEGER, `moduleId` INTEGER, `pageId` INTEGER, FOREIGN KEY(`moduleId`) REFERENCES `ModuleContentDetailsEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`pageId`) REFERENCES `PageEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modulePrerequisiteNames", + "columnName": "modulePrerequisiteNames", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedModuleId", + "columnName": "lockedModuleId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "moduleId", + "columnName": "moduleId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ModuleContentDetailsEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "moduleId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "AssignmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assignmentId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "PageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LockedModuleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contextId` INTEGER NOT NULL, `contextType` TEXT, `name` TEXT, `unlockAt` TEXT, `isRequireSequentialProgress` INTEGER NOT NULL, `lockInfoId` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`lockInfoId`) REFERENCES `LockInfoEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unlockAt", + "columnName": "unlockAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRequireSequentialProgress", + "columnName": "isRequireSequentialProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lockInfoId", + "columnName": "lockInfoId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LockInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "lockInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleNameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `lockedModuleId` INTEGER NOT NULL, FOREIGN KEY(`lockedModuleId`) REFERENCES `LockedModuleEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lockedModuleId", + "columnName": "lockedModuleId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "LockedModuleEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "lockedModuleId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ModuleCompletionRequirementEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT, `minScore` REAL NOT NULL, `maxScore` REAL NOT NULL, `completed` INTEGER, `moduleId` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "minScore", + "columnName": "minScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "maxScore", + "columnName": "maxScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "completed", + "columnName": "completed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "moduleId", + "columnName": "moduleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FileSyncSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fileName` TEXT, `courseId` INTEGER NOT NULL, `url` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseSyncSettingsEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseSyncSettingsEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "courseId" + ] + } + ] + }, + { + "tableName": "ConferenceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `conferenceKey` TEXT, `conferenceType` TEXT, `description` TEXT, `duration` INTEGER NOT NULL, `endedAt` INTEGER, `hasAdvancedSettings` INTEGER NOT NULL, `joinUrl` TEXT, `longRunning` INTEGER NOT NULL, `startedAt` INTEGER, `title` TEXT, `url` TEXT, `contextType` TEXT NOT NULL, `contextId` INTEGER NOT NULL, `record` INTEGER, `users` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`courseId`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conferenceKey", + "columnName": "conferenceKey", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "conferenceType", + "columnName": "conferenceType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasAdvancedSettings", + "columnName": "hasAdvancedSettings", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "joinUrl", + "columnName": "joinUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "longRunning", + "columnName": "longRunning", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contextType", + "columnName": "contextType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record", + "columnName": "record", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "users", + "columnName": "users", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ConferenceRecordingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordingId` TEXT NOT NULL, `conferenceId` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `durationMinutes` INTEGER NOT NULL, `playbackUrl` TEXT, `title` TEXT NOT NULL, PRIMARY KEY(`recordingId`), FOREIGN KEY(`conferenceId`) REFERENCES `ConferenceEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordingId", + "columnName": "recordingId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conferenceId", + "columnName": "conferenceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationMinutes", + "columnName": "durationMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playbackUrl", + "columnName": "playbackUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordingId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ConferenceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "conferenceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CourseFeaturesEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `features` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `CourseEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, `attempt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`submissionCommentId`) REFERENCES `SubmissionCommentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "SubmissionCommentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionCommentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `submissionId` INTEGER NOT NULL, `attemptId` INTEGER NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`), FOREIGN KEY(`submissionId`, `attemptId`) REFERENCES `SubmissionEntity`(`id`, `attempt`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "SubmissionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId", + "attemptId" + ], + "referencedColumns": [ + "id", + "attempt" + ] + } + ] + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`submissionId`, `attemptId`) REFERENCES `SubmissionEntity`(`id`, `attempt`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "SubmissionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submissionId", + "attemptId" + ], + "referencedColumns": [ + "id", + "attempt" + ] + } + ] + }, + { + "tableName": "DiscussionTopicEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `unreadEntries` TEXT NOT NULL, `participantIds` TEXT NOT NULL, `viewIds` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadEntries", + "columnName": "unreadEntries", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantIds", + "columnName": "participantIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "viewIds", + "columnName": "viewIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "CourseSyncProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `courseName` TEXT NOT NULL, `tabs` TEXT NOT NULL, `additionalFilesStarted` INTEGER NOT NULL, `progressState` TEXT NOT NULL, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseName", + "columnName": "courseName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabs", + "columnName": "tabs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "additionalFilesStarted", + "columnName": "additionalFilesStarted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressState", + "columnName": "progressState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileSyncProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `progress` INTEGER NOT NULL, `fileSize` INTEGER NOT NULL, `additionalFile` INTEGER NOT NULL, `progressState` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`courseId`) REFERENCES `CourseSyncProgressEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "additionalFile", + "columnName": "additionalFile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressState", + "columnName": "progressState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CourseSyncProgressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "courseId" + ], + "referencedColumns": [ + "courseId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1182046fc85ec1b16d7693e1a593081b')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt index 0dd9d648e3..b455e6f507 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabase.kt @@ -219,7 +219,7 @@ import com.instructure.pandautils.room.offline.entities.UserEntity DiscussionTopicEntity::class, CourseSyncProgressEntity::class, FileSyncProgressEntity::class, - ], version = 1 + ], version = 2 ) @TypeConverters(value = [Converters::class, OfflineConverters::class]) abstract class OfflineDatabase : RoomDatabase() { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt index 066f17bf82..470d1fdafe 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt @@ -20,4 +20,62 @@ package com.instructure.pandautils.room.offline import androidx.room.migration.Migration import com.instructure.pandautils.room.createMigration -val offlineDatabaseMigrations = arrayOf() \ No newline at end of file +val offlineDatabaseMigrations = arrayOf( + + createMigration(1, 2) { database -> + //CourseSyncProgressEntity + database.execSQL("ALTER TABLE CourseSyncProgressEntity DROP COLUMN workerId") + + //FileSyncProgressEntity + database.execSQL( + "CREATE TABLE FileSyncProgressEntity_temp (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + "fileId INTEGER NOT NULL," + + "courseId INTEGER NOT NULL," + + "fileName TEXT NOT NULL," + + "progress INTEGER NOT NULL," + + "fileSize INTEGER NOT NULL," + + "additionalFile INTEGER NOT NULL DEFAULT 0," + + "progressState TEXT NOT NULL," + + "FOREIGN KEY(courseId) REFERENCES CourseSyncProgressEntity(courseId) ON DELETE CASCADE" + + ")" + ) + database.execSQL( + "INSERT INTO FileSyncProgressEntity_temp (fileId, courseId, fileName, progress, fileSize, additionalFile, progressState) " + + "SELECT fileId, courseId, fileName, progress, fileSize, additionalFile, progressState FROM FileSyncProgressEntity" + ) + database.execSQL("DROP TABLE FileSyncProgressEntity") + database.execSQL("ALTER TABLE FileSyncProgressEntity_temp RENAME TO FileSyncProgressEntity") + + //LockInfoEntity + database.execSQL("CREATE TABLE IF NOT EXISTS `LockInfoEntity_temp` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + "`modulePrerequisiteNames` TEXT," + + "`unlockAt` TEXT," + + "`lockedModuleId` INTEGER," + + "`assignmentId` INTEGER," + + "`moduleId` INTEGER," + + "`pageId` INTEGER," + + "FOREIGN KEY(`moduleId`) REFERENCES `ModuleContentDetailsEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + + "FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + + "FOREIGN KEY(`pageId`) REFERENCES `PageEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + database.execSQL("INSERT INTO LockInfoEntity_temp (id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId) " + + "SELECT id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId FROM LockInfoEntity") + database.execSQL("DROP TABLE LockInfoEntity") + database.execSQL("ALTER TABLE LockInfoEntity_temp RENAME TO LockInfoEntity") + + //LockedModuleEntity + database.execSQL("CREATE TABLE IF NOT EXISTS `LockedModuleEntity_temp` (" + + "`id` INTEGER NOT NULL," + + "`contextId` INTEGER NOT NULL," + + "`contextType` TEXT," + + "`name` TEXT," + + "`unlockAt` TEXT," + + "`isRequireSequentialProgress` INTEGER NOT NULL," + + "`lockInfoId` INTEGER, PRIMARY KEY(`id`)," + + "FOREIGN KEY(`lockInfoId`) REFERENCES `LockInfoEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("INSERT INTO LockedModuleEntity_temp (id, contextId, contextType, name, unlockAt, isRequireSequentialProgress) " + + "SELECT id, contextId, contextType, name, unlockAt, isRequireSequentialProgress FROM LockedModuleEntity") + database.execSQL("DROP TABLE LockedModuleEntity") + database.execSQL("ALTER TABLE LockedModuleEntity_temp RENAME TO LockedModuleEntity") + }, +) \ No newline at end of file From 7c18fab75bdd3dd1f1ecdea5485d30c6cd3135b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Thu, 1 Feb 2024 18:56:49 +0100 Subject: [PATCH 11/52] version bump --- apps/teacher/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index b277eb65da..734a9e30ab 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -39,8 +39,8 @@ android { defaultConfig { minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 62 - versionName = '1.28.0' + versionCode = 63 + versionName = '1.28.1' vectorDrawables.useSupportLibrary = true multiDexEnabled true testInstrumentationRunner 'com.instructure.teacher.ui.espresso.TeacherHiltTestRunner' From d70dd39f2732f455445a348fef7913ec020d95dc Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Fri, 2 Feb 2024 11:24:09 +0100 Subject: [PATCH 12/52] [MBL-17356][Student] - Fix front page title bug when navigating to front page from CourseBrowser 'Home' (#2318) --- .../coursebrowser/CourseBrowserFragment.kt | 16 +++++++++++----- .../pages/details/PageDetailsFragment.kt | 12 +++++++----- .../features/pages/list/PageListFragment.kt | 5 ++--- .../com/instructure/student/util/TabHelper.kt | 9 ++++++--- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt b/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt index 68c048d916..79ca2d53e8 100644 --- a/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt @@ -213,14 +213,20 @@ class CourseBrowserFragment : Fragment(), FragmentInteractions, AppBarLayout.OnO // Load Pages List if (tabs.any { it.tabId == Tab.PAGES_ID }) { // Do not load the pages list if the tab is hidden or locked. - RouteMatcher.route(requireActivity(), TabHelper.getRouteByTabId(tab, canvasContext)) + val route = TabHelper.getRouteByTabId(tab, canvasContext) + route?.arguments = route?.arguments?.apply { + putString(PageDetailsFragment.PAGE_NAME, homePageTitle) + } ?: Bundle() + RouteMatcher.route(requireActivity(), route) } // If the home tab is a Page and we clicked it lets route directly there. - RouteMatcher.route( - requireActivity(), - PageDetailsFragment.makeRoute(canvasContext, Page.FRONT_PAGE_NAME) - .apply { ignoreDebounce = true }) + val route = PageDetailsFragment.makeFrontPageRoute(canvasContext) + .apply { ignoreDebounce = true } + route.arguments = route.arguments.apply { + putString(PageDetailsFragment.PAGE_NAME, homePageTitle) + } + RouteMatcher.route(requireActivity(), route) } else { val route = TabHelper.getRouteByTabId(tab, canvasContext)?.apply { ignoreDebounce = true } RouteMatcher.route(requireActivity(), route) diff --git a/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt index ae55c2f3ef..fba5a92eac 100644 --- a/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt @@ -51,7 +51,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import org.greenrobot.eventbus.Subscribe import java.util.* -import java.util.regex.Pattern +import java.util.regex.* import javax.inject.Inject @ScreenView(SCREEN_VIEW_PAGE_DETAILS) @@ -67,6 +67,7 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { private var page: Page by ParcelableArg(default = Page(), key = PAGE) private var pageUrl: String? by NullableStringArg(key = PAGE_URL) private var navigatedFromModules: Boolean by BooleanArg(key = NAVIGATED_FROM_MODULES) + private var frontPage: Boolean by BooleanArg(key = FRONT_PAGE) // Flag for the webview client to know whether or not we should clear the history private var isUpdated = false @@ -152,11 +153,11 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { } else { loadFailedPageInfo(null) } - } else if (pageName == null || pageName == Page.FRONT_PAGE_NAME) fetchFontPage() + } else if (frontPage) fetchFrontPage() else fetchPageDetails() } - private fun fetchFontPage() { + private fun fetchFrontPage() { lifecycleScope.tryLaunch { val result = repository.getFrontPage(canvasContext, true) result.onSuccess { @@ -331,6 +332,7 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { const val PAGE = "pageDetails" const val PAGE_URL = "pageUrl" const val NAVIGATED_FROM_MODULES = "navigated_from_modules" + private const val FRONT_PAGE = "frontPage" fun newInstance(route: Route): PageDetailsFragment? { return if (validRoute(route)) PageDetailsFragment().apply { @@ -349,9 +351,9 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { route.paramsHash.containsKey(RouterParams.PAGE_ID)) } - fun makeRoute(canvasContext: CanvasContext, pageName: String?): Route { + fun makeFrontPageRoute(canvasContext: CanvasContext): Route { return Route(null, PageDetailsFragment::class.java, canvasContext, canvasContext.makeBundle(Bundle().apply { - if (pageName != null) putString(PAGE_NAME, pageName) + putBoolean(FRONT_PAGE, true) })) } diff --git a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt index 740d0c4816..cb47a42ce2 100644 --- a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt @@ -129,9 +129,8 @@ class PageListFragment : ParentFragment(), Bookmarkable { super.onActivityCreated(savedInstanceState) if (isShowFrontPage) { - val route = PageDetailsFragment.makeRoute( - canvasContext, - Page.FRONT_PAGE_NAME + val route = PageDetailsFragment.makeFrontPageRoute( + canvasContext ).apply { ignoreDebounce = true} RouteMatcher.route(requireActivity(), route) } diff --git a/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt b/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt index 4b77b5f92d..b3fd8de5ee 100644 --- a/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt +++ b/apps/student/src/main/java/com/instructure/student/util/TabHelper.kt @@ -19,7 +19,6 @@ package com.instructure.student.util import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.Page import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ContextKeeper @@ -36,7 +35,11 @@ import com.instructure.student.features.pages.details.PageDetailsFragment import com.instructure.student.features.pages.list.PageListFragment import com.instructure.student.features.people.list.PeopleListFragment import com.instructure.student.features.quiz.list.QuizListFragment -import com.instructure.student.fragment.* +import com.instructure.student.fragment.AnnouncementListFragment +import com.instructure.student.fragment.CourseSettingsFragment +import com.instructure.student.fragment.LtiLaunchFragment +import com.instructure.student.fragment.NotificationListFragment +import com.instructure.student.fragment.UnsupportedTabFragment import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.syllabus.ui.SyllabusRepositoryFragment import java.util.* @@ -97,7 +100,7 @@ object TabHelper { Tab.ASSIGNMENTS_ID -> AssignmentListFragment.makeRoute(canvasContext) Tab.MODULES_ID -> ModuleListFragment.makeRoute(canvasContext) Tab.PAGES_ID -> PageListFragment.makeRoute(canvasContext, false) - Tab.FRONT_PAGE_ID -> PageDetailsFragment.makeRoute(canvasContext, Page.FRONT_PAGE_NAME) + Tab.FRONT_PAGE_ID -> PageDetailsFragment.makeFrontPageRoute(canvasContext) Tab.DISCUSSIONS_ID -> DiscussionListFragment.makeRoute(canvasContext) Tab.PEOPLE_ID -> PeopleListFragment.makeRoute(canvasContext) Tab.FILES_ID -> FileListFragment.makeRoute(canvasContext) From 902027f313f54b6d99230c8e517fa34ca955f60b Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 2 Feb 2024 12:54:57 +0100 Subject: [PATCH 13/52] [MBL-17273][Student] Schedule reminder notification refs: MBL-17273 affects: Student release note: none * Reminder basics * Reminder tests * a11y fixes * Add reminder dialogs * Add reminder notifications * Add reminder logic improvements * Unit tests * Fixed cancellation * Fixed alarm permission on A14 * Fixed alarm permission on A14 * Minor fixes * Minor fixes --- apps/student/src/main/AndroidManifest.xml | 6 ++ .../activity/InterwebsToApplication.kt | 12 ++- .../student/activity/NavigationActivity.kt | 93 +++++++++++++--- .../student/di/AlarmSchedulerModule.kt | 38 +++++++ .../com/instructure/student/di/LoginModule.kt | 13 ++- .../details/AssignmentDetailsFragment.kt | 59 ++++++++++- .../details/AssignmentDetailsRepository.kt | 13 ++- .../details/AssignmentDetailsViewData.kt | 9 ++ .../details/AssignmentDetailsViewModel.kt | 54 +++++++++- .../assignments/reminder/AlarmScheduler.kt | 78 ++++++++++++++ .../reminder/CustomReminderDialog.kt | 23 +++- .../login/StudentAcceptableUsePolicyRouter.kt | 6 +- .../features/login/StudentLoginNavigation.kt | 6 +- .../student/receivers/AlarmReceiver.kt | 100 ++++++++++++++++++ .../student/receivers/InitializeReceiver.kt | 13 ++- .../student/tasks/StudentLogoutTask.kt | 8 +- .../instructure/student/util/AppManager.kt | 18 +++- .../instructure/student/util/Extensions.kt | 24 ++++- .../res/layout/dialog_custom_reminder.xml | 2 +- .../AssignmentDetailsViewModelTest.kt | 71 +++++++++++-- .../reminder/AlarmSchedulerTest.kt | 91 ++++++++++++++++ .../AssignmentDetailsRepositoryTest.kt | 13 ++- .../loginapi/login/tasks/LogoutTask.kt | 8 +- libs/pandares/src/main/res/values/strings.xml | 26 +++-- .../9.json | 24 ++++- .../room/appdatabase/daos/ReminderDaoTest.kt | 41 ++++++- .../room/appdatabase/AppDatabaseMigrations.kt | 2 +- .../room/appdatabase/daos/ReminderDao.kt | 8 +- .../appdatabase/entities/ReminderEntity.kt | 5 +- .../pandautils/utils/NetworkStateProvider.kt | 7 +- .../pandautils/utils/StorageUtilsTest.kt | 2 + 31 files changed, 801 insertions(+), 72 deletions(-) create mode 100644 apps/student/src/main/java/com/instructure/student/di/AlarmSchedulerModule.kt create mode 100644 apps/student/src/main/java/com/instructure/student/features/assignments/reminder/AlarmScheduler.kt create mode 100644 apps/student/src/main/java/com/instructure/student/receivers/AlarmReceiver.kt create mode 100644 apps/student/src/test/java/com/instructure/student/features/assignmentdetails/reminder/AlarmSchedulerTest.kt diff --git a/apps/student/src/main/AndroidManifest.xml b/apps/student/src/main/AndroidManifest.xml index 064bc4dabb..ec13a71e53 100644 --- a/apps/student/src/main/AndroidManifest.xml +++ b/apps/student/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ + + + + { @@ -214,7 +269,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. StudentLogoutTask( LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, - databaseProvider = databaseProvider + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler ).execute() } .setNegativeButton(android.R.string.cancel, null) @@ -325,6 +381,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } catch { firebaseCrashlytics.recordException(it) } + + scheduleAlarms() } private fun handleTokenCheck(online: Boolean?) { @@ -334,7 +392,12 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. lifecycleScope.launch { val isTokenValid = repository.isTokenValid() if (!isTokenValid) { - StudentLogoutTask(LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, databaseProvider = databaseProvider).execute() + StudentLogoutTask( + LogoutTask.Type.LOGOUT, + typefaceBehavior = typefaceBehavior, + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler + ).execute() } } } @@ -387,7 +450,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. if (ApiPrefs.user == null ) { // Hard case to repro but it's possible for a user to force exit the app before we finish saving the user but they will still launch into the app // If that happens, log out - StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider).execute() + StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider, alarmScheduler = alarmScheduler).execute() } setupBottomNavigation() @@ -1215,6 +1278,12 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } } + private fun scheduleAlarms() { + lifecycleScope.launch { + alarmScheduler.scheduleAllAlarmsForCurrentUser() + } + } + companion object { fun createIntent(context: Context, route: Route): Intent { return Intent(context, NavigationActivity::class.java).apply { putExtra(Route.ROUTE, route) } diff --git a/apps/student/src/main/java/com/instructure/student/di/AlarmSchedulerModule.kt b/apps/student/src/main/java/com/instructure/student/di/AlarmSchedulerModule.kt new file mode 100644 index 0000000000..ee3ad2f563 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/di/AlarmSchedulerModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.instructure.student.di + +import android.content.Context +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.student.features.assignments.reminder.AlarmScheduler +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class AlarmSchedulerModule { + + @Provides + fun provideAlarmScheduler(@ApplicationContext context: Context, reminderDao: ReminderDao, apiPrefs: ApiPrefs): AlarmScheduler { + return AlarmScheduler(context, reminderDao, apiPrefs) + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt b/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt index 24914b2cd9..5672e1660e 100644 --- a/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt +++ b/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt @@ -20,6 +20,7 @@ import androidx.fragment.app.FragmentActivity import com.instructure.loginapi.login.LoginNavigation import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter import com.instructure.pandautils.room.offline.DatabaseProvider +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.features.login.StudentAcceptableUsePolicyRouter import com.instructure.student.features.login.StudentLoginNavigation import dagger.Module @@ -32,12 +33,16 @@ import dagger.hilt.android.components.ActivityComponent class LoginModule { @Provides - fun provideAcceptabelUsePolicyRouter(activity: FragmentActivity, databaseProvider: DatabaseProvider): AcceptableUsePolicyRouter { - return StudentAcceptableUsePolicyRouter(activity, databaseProvider) + fun provideAcceptabelUsePolicyRouter( + activity: FragmentActivity, + databaseProvider: DatabaseProvider, + alarmScheduler: AlarmScheduler + ): AcceptableUsePolicyRouter { + return StudentAcceptableUsePolicyRouter(activity, databaseProvider, alarmScheduler) } @Provides - fun provideLoginNavigation(activity: FragmentActivity, databaseProvider: DatabaseProvider): LoginNavigation { - return StudentLoginNavigation(activity, databaseProvider) + fun provideLoginNavigation(activity: FragmentActivity, databaseProvider: DatabaseProvider, alarmScheduler: AlarmScheduler): LoginNavigation { + return StudentLoginNavigation(activity, databaseProvider, alarmScheduler) } } \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt index 1c39f1ab1d..029a9551af 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt @@ -17,9 +17,14 @@ package com.instructure.student.features.assignments.details +import android.app.AlarmManager import android.app.Dialog +import android.content.Context +import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -27,6 +32,7 @@ import android.webkit.WebView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels +import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Assignment.SubmissionType @@ -156,6 +162,11 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { } } + override fun onResume() { + super.onResume() + checkAlarmPermissionResult() + } + private fun handleAction(action: AssignmentDetailAction) { val canvasContext = canvasContext as? CanvasContext ?: run { toast(R.string.generalUnexpectedError) @@ -217,11 +228,14 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { showDiscussionAttachments(action.attachments) } is AssignmentDetailAction.ShowReminderDialog -> { - showCreateReminderDialog() + checkAlarmPermission() } is AssignmentDetailAction.ShowCustomReminderDialog -> { showCustomReminderDialog() } + is AssignmentDetailAction.ShowDeleteReminderConfirmationDialog -> { + showDeleteReminderConfirmationDialog(action.onConfirmed) + } } } @@ -415,6 +429,35 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { ) } + private fun checkAlarmPermission() { + val alarmManager = context?.getSystemService(Context.ALARM_SERVICE) as AlarmManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + showCreateReminderDialog() + } else { + viewModel.checkingReminderPermission = true + startActivity( + Intent( + Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, + Uri.parse("package:" + requireContext().packageName) + ) + ) + } + } else { + showCreateReminderDialog() + } + } + + private fun checkAlarmPermissionResult() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && viewModel.checkingReminderPermission) { + if ((context?.getSystemService(Context.ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()) { + showCreateReminderDialog() + } else { + Snackbar.make(requireView(), getString(R.string.reminderPermissionNotGrantedError), Snackbar.LENGTH_LONG).show() + } + } + } + private fun showCreateReminderDialog() { val choices = listOf( ReminderChoice.Minute(5), @@ -428,7 +471,7 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { AlertDialog.Builder(requireContext()) .setTitle(R.string.reminderTitle) - .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() } + .setNegativeButton(R.string.cancel, null) .setSingleChoiceItems( choices.map { it.getText(resources) }.toTypedArray(), -1 ) { dialog, which -> @@ -442,6 +485,18 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { CustomReminderDialog.newInstance().show(childFragmentManager, null) } + private fun showDeleteReminderConfirmationDialog(onConfirmed: () -> Unit) { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.deleteReminderTitle) + .setMessage(R.string.deleteReminderMessage) + .setNegativeButton(R.string.no, null) + .setPositiveButton(R.string.yes) { dialog, _ -> + onConfirmed() + dialog.dismiss() + } + .showThemed() + } + companion object { fun makeRoute(course: CanvasContext, assignmentId: Long): Route { val bundle = course.makeBundle { putLong(Const.ASSIGNMENT_ID, assignmentId) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt index c9741b23d0..475df251da 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsRepository.kt @@ -67,7 +67,14 @@ class AssignmentDetailsRepository( reminderDao.deleteById(id) } - suspend fun addReminder(userId: Long, assignmentId: Long, text: String) { - reminderDao.insert(ReminderEntity(userId = userId, assignmentId = assignmentId, text = text)) - } + suspend fun addReminder(userId: Long, assignment: Assignment, text: String, time: Long) = reminderDao.insert( + ReminderEntity( + userId = userId, + assignmentId = assignment.id, + htmlUrl = assignment.htmlUrl.orEmpty(), + name = assignment.name.orEmpty(), + text = text, + time = time + ) + ) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt index 9aa101beb5..9327ac087a 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewData.kt @@ -76,6 +76,14 @@ sealed class ReminderChoice { is Week -> resources.getQuantityString(R.plurals.reminderWeek, quantity, quantity) is Custom -> resources.getString(R.string.reminderCustom) } + + fun getTimeInMillis() = when (this) { + is Minute -> quantity * 60 * 1000L + is Hour -> quantity * 60 * 60 * 1000L + is Day -> quantity * 24 * 60 * 60 * 1000L + is Week -> quantity * 7 * 24 * 60 * 60 * 1000L + else -> 0 + } } sealed class AssignmentDetailAction { @@ -105,4 +113,5 @@ sealed class AssignmentDetailAction { data class OnDiscussionHeaderAttachmentClicked(val attachments: List) : AssignmentDetailAction() data object ShowReminderDialog : AssignmentDetailAction() data object ShowCustomReminderDialog : AssignmentDetailAction() + data class ShowDeleteReminderConfirmationDialog(val onConfirmed: () -> Unit) : AssignmentDetailAction() } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt index 577ae2aa9d..00ea9846cc 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsViewModel.kt @@ -55,6 +55,7 @@ import com.instructure.student.R import com.instructure.student.db.StudentDb import com.instructure.student.features.assignments.details.gradecellview.GradeCellViewData import com.instructure.student.features.assignments.details.itemviewmodels.ReminderItemViewModel +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.mobius.assignmentDetails.getFormattedAttemptDate import com.instructure.student.mobius.assignmentDetails.uploadAudioRecording import com.instructure.student.util.getStudioLTITool @@ -77,6 +78,7 @@ class AssignmentDetailsViewModel @Inject constructor( private val colorKeeper: ColorKeeper, private val application: Application, private val apiPrefs: ApiPrefs, + private val alarmScheduler: AlarmScheduler, database: StudentDb ) : ViewModel(), Query.Listener { @@ -126,6 +128,8 @@ class AssignmentDetailsViewModel @Inject constructor( observeForever(remindersObserver) } + var checkingReminderPermission = false + init { markSubmissionAsRead() submissionQuery.addListener(this) @@ -490,10 +494,17 @@ class AssignmentDetailsViewModel @Inject constructor( } private fun mapReminders(reminders: List) = reminders.map { - ReminderItemViewModel(ReminderViewData(it.id, it.text)) { - viewModelScope.launch { - assignmentDetailsRepository.deleteReminderById(it) - } + ReminderItemViewModel(ReminderViewData(it.id, resources.getString(R.string.reminderBefore, it.text))) { + postAction(AssignmentDetailAction.ShowDeleteReminderConfirmationDialog { + deleteReminderById(it) + }) + } + } + + private fun deleteReminderById(id: Long) { + alarmScheduler.cancelAlarm(id) + viewModelScope.launch { + assignmentDetailsRepository.deleteReminderById(id) } } @@ -616,8 +627,41 @@ class AssignmentDetailsViewModel @Inject constructor( } private fun setReminder(reminderChoice: ReminderChoice) { + val assignment = assignment ?: return + val alarmTimeInMillis = getAlarmTimeInMillis(reminderChoice) ?: return + val reminderText = reminderChoice.getText(resources) + + if (alarmTimeInMillis < System.currentTimeMillis()) { + postAction(AssignmentDetailAction.ShowToast(resources.getString(R.string.reminderInPast))) + return + } + + if (remindersLiveData.value?.any { it.time == alarmTimeInMillis }.orDefault()) { + postAction(AssignmentDetailAction.ShowToast(resources.getString(R.string.reminderAlreadySet))) + return + } + viewModelScope.launch { - assignmentDetailsRepository.addReminder(apiPrefs.user?.id.orDefault(), assignmentId, reminderChoice.getText(resources)) + val reminderId = assignmentDetailsRepository.addReminder( + apiPrefs.user?.id.orDefault(), + assignment, + reminderText, + alarmTimeInMillis + ) + + alarmScheduler.scheduleAlarm( + assignment.id, + assignment.htmlUrl.orEmpty(), + assignment.name.orEmpty(), + reminderText, + alarmTimeInMillis, + reminderId + ) } } + + private fun getAlarmTimeInMillis(reminderChoice: ReminderChoice): Long? { + val dueDate = assignment?.dueDate?.time ?: return null + return dueDate - reminderChoice.getTimeInMillis() + } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/AlarmScheduler.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/AlarmScheduler.kt new file mode 100644 index 0000000000..1243b77cc5 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/AlarmScheduler.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 - 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 . + * + */ + +package com.instructure.student.features.assignments.reminder + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.student.receivers.AlarmReceiver + +class AlarmScheduler(private val context: Context, private val reminderDao: ReminderDao, private val apiPrefs: ApiPrefs) { + + private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + fun scheduleAlarm(assignmentId: Long, assignmentPath: String, assignmentName: String, dueIn: String, timeInMillis: Long, reminderId: Long) { + val intent = Intent(context, AlarmReceiver::class.java) + intent.putExtra(AlarmReceiver.ASSIGNMENT_ID, assignmentId) + intent.putExtra(AlarmReceiver.ASSIGNMENT_PATH, assignmentPath) + intent.putExtra(AlarmReceiver.ASSIGNMENT_NAME, assignmentName) + intent.putExtra(AlarmReceiver.DUE_IN, dueIn) + + val pendingIntent = PendingIntent.getBroadcast( + context, + reminderId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) return + + alarmManager.setExact(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent) + } + + suspend fun scheduleAllAlarmsForCurrentUser() { + val reminders = reminderDao.findByUserId(apiPrefs.user?.id ?: return) + reminders.forEach { + scheduleAlarm(it.assignmentId, it.htmlUrl, it.name, it.text, it.time, it.id) + } + } + + fun cancelAlarm(reminderId: Long) { + val intent = Intent(context, AlarmReceiver::class.java) + + val pendingIntent = PendingIntent.getBroadcast( + context, + reminderId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + alarmManager.cancel(pendingIntent) + } + + suspend fun cancelAllAlarmsForCurrentUser() { + val reminders = reminderDao.findByUserId(apiPrefs.user?.id ?: return) + reminders.forEach { + cancelAlarm(it.id) + } + } +} diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt index f8ba222f0f..311829ba1f 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/reminder/CustomReminderDialog.kt @@ -18,8 +18,11 @@ package com.instructure.student.features.assignments.reminder import android.app.Dialog +import android.content.res.ColorStateList import android.os.Bundle +import android.widget.Button import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels import com.instructure.pandautils.utils.ThemePrefs @@ -52,13 +55,31 @@ class CustomReminderDialog : DialogFragment() { R.id.weeks -> parentViewModel.onReminderSelected(ReminderChoice.Week(quantity)) } } + .setNegativeButton(R.string.cancel, null) .create().apply { setOnShowListener { - getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ThemePrefs.textButtonColor) + getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ThemePrefs.textButtonColor) + setupPositiveButton(getButton(AlertDialog.BUTTON_POSITIVE)) } } } + private fun setupPositiveButton(button: Button) { + button.isEnabled = false + button.setTextColor( + ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), + intArrayOf(requireContext().getColor(R.color.textDark), ThemePrefs.textButtonColor) + ) + ) + binding.choices.setOnCheckedChangeListener { _, _ -> updateButtonState(button) } + binding.quantity.doAfterTextChanged { updateButtonState(button) } + } + + private fun updateButtonState(button: Button) { + button.isEnabled = binding.choices.checkedRadioButtonId != -1 && binding.quantity.text.isNotEmpty() + } + companion object { fun newInstance() = CustomReminderDialog() } diff --git a/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt b/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt index ef195aaa84..1884f27bfd 100644 --- a/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt @@ -27,11 +27,13 @@ import com.instructure.pandautils.services.PushNotificationRegistrationWorker import com.instructure.student.R import com.instructure.student.activity.InternalWebViewActivity import com.instructure.student.activity.NavigationActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.tasks.StudentLogoutTask class StudentAcceptableUsePolicyRouter( private val activity: FragmentActivity, - private val databaseProvider: DatabaseProvider + private val databaseProvider: DatabaseProvider, + private val alarmScheduler: AlarmScheduler ) : AcceptableUsePolicyRouter { override fun openPolicy(content: String) { @@ -54,6 +56,6 @@ class StudentAcceptableUsePolicyRouter( } override fun logout() { - StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider).execute() + StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider, alarmScheduler = alarmScheduler).execute() } } \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt b/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt index 8c562f1760..a11e91a18a 100644 --- a/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt +++ b/apps/student/src/main/java/com/instructure/student/features/login/StudentLoginNavigation.kt @@ -25,16 +25,18 @@ import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.services.PushNotificationRegistrationWorker import com.instructure.student.activity.NavigationActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.tasks.StudentLogoutTask class StudentLoginNavigation( private val activity: FragmentActivity, - private val databaseProvider: DatabaseProvider + private val databaseProvider: DatabaseProvider, + private val alarmScheduler: AlarmScheduler ) : LoginNavigation(activity) { override val checkElementary: Boolean = true override fun logout() { - StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider).execute() + StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider, alarmScheduler = alarmScheduler).execute() } override fun initMainActivityIntent(): Intent { diff --git a/apps/student/src/main/java/com/instructure/student/receivers/AlarmReceiver.kt b/apps/student/src/main/java/com/instructure/student/receivers/AlarmReceiver.kt new file mode 100644 index 0000000000..0f02fe27f8 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/receivers/AlarmReceiver.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2024 - 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 . + * + */ + +package com.instructure.student.receivers + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.instructure.pandautils.models.PushNotification +import com.instructure.pandautils.room.appdatabase.daos.ReminderDao +import com.instructure.pandautils.utils.Const +import com.instructure.student.R +import com.instructure.student.activity.NavigationActivity +import com.instructure.student.util.goAsync +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class AlarmReceiver : BroadcastReceiver() { + + @Inject + lateinit var reminderDao: ReminderDao + + override fun onReceive(context: Context?, intent: Intent?) { + if (context != null && intent != null) { + val assignmentId = intent.getLongExtra(ASSIGNMENT_ID, 0L) + val assignmentPath = intent.getStringExtra(ASSIGNMENT_PATH) ?: return + val assignmentName = intent.getStringExtra(ASSIGNMENT_NAME) ?: return + val dueIn = intent.getStringExtra(DUE_IN) ?: return + + createNotificationChannel(context) + showNotification(context, assignmentId, assignmentPath, assignmentName, dueIn) + goAsync { + reminderDao.deletePastReminders(System.currentTimeMillis()) + } + } + } + + private fun showNotification(context: Context, assignmentId: Long, assignmentPath: String, assignmentName: String, dueIn: String) { + val intent = Intent(context, NavigationActivity.startActivityClass).apply { + putExtra(Const.LOCAL_NOTIFICATION, true) + putExtra(PushNotification.HTML_URL, assignmentPath) + } + + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setContentTitle(context.getString(R.string.reminderNotificationTitle)) + .setContentText(context.getString(R.string.reminderNotificationDescription, dueIn, assignmentName)) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(assignmentId.toInt(), builder.build()) + } + + private fun createNotificationChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.reminderNotificationChannelName), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = context.getString(R.string.reminderNotificationChannelDescription) + } + + val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + companion object { + private const val CHANNEL_ID = "REMINDERS_CHANNEL_ID" + const val ASSIGNMENT_ID = "ASSIGNMENT_ID" + const val ASSIGNMENT_PATH = "ASSIGNMENT_PATH" + const val ASSIGNMENT_NAME = "ASSIGNMENT_NAME" + const val DUE_IN = "DUE_IN" + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt b/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt index 090bf55a72..90560f5f20 100644 --- a/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt +++ b/apps/student/src/main/java/com/instructure/student/receivers/InitializeReceiver.kt @@ -20,18 +20,29 @@ package com.instructure.student.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import com.instructure.pandautils.receivers.PushExternalReceiver import com.instructure.student.R import com.instructure.student.activity.NavigationActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler +import com.instructure.student.util.goAsync import com.instructure.student.widget.WidgetUpdater -import com.instructure.pandautils.receivers.PushExternalReceiver +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@AndroidEntryPoint class InitializeReceiver : BroadcastReceiver() { + @Inject + lateinit var alarmScheduler: AlarmScheduler + override fun onReceive(context: Context, intent: Intent) { if(Intent.ACTION_BOOT_COMPLETED == intent.action || Intent.ACTION_MY_PACKAGE_REPLACED == intent.action) { //Restores stored push notifications upon boot PushExternalReceiver.postStoredNotifications(context, context.getString(R.string.student_app_name), NavigationActivity.startActivityClass, R.color.login_studentAppTheme) WidgetUpdater.updateWidgets() + goAsync { + alarmScheduler.scheduleAllAlarmsForCurrentUser() + } } } } diff --git a/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt b/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt index 59360f590b..e695af1e4f 100644 --- a/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt +++ b/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt @@ -29,6 +29,7 @@ import com.instructure.pandautils.features.offline.sync.OfflineSyncWorker import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.typeface.TypefaceBehavior import com.instructure.student.activity.LoginActivity +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.flutterChannels.FlutterComm import com.instructure.student.util.StudentPrefs import com.instructure.student.widget.WidgetUpdater @@ -39,7 +40,8 @@ class StudentLogoutTask( uri: Uri? = null, canvasForElementaryFeatureFlag: Boolean = false, typefaceBehavior: TypefaceBehavior? = null, - private val databaseProvider: DatabaseProvider? = null + private val databaseProvider: DatabaseProvider? = null, + private val alarmScheduler: AlarmScheduler? = null ) : LogoutTask(type, uri, canvasForElementaryFeatureFlag, typefaceBehavior) { override fun onCleanup() { @@ -82,4 +84,8 @@ class StudentLogoutTask( cancelAllWorkByTag(OfflineSyncWorker.ONE_TIME_TAG) } } + + override suspend fun cancelAlarms() { + alarmScheduler?.cancelAllAlarmsForCurrentUser() + } } diff --git a/apps/student/src/main/java/com/instructure/student/util/AppManager.kt b/apps/student/src/main/java/com/instructure/student/util/AppManager.kt index 20a7c1fa77..c43f7fb665 100644 --- a/apps/student/src/main/java/com/instructure/student/util/AppManager.kt +++ b/apps/student/src/main/java/com/instructure/student/util/AppManager.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.utils.MasqueradeHelper import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.typeface.TypefaceBehavior +import com.instructure.student.features.assignments.reminder.AlarmScheduler import com.instructure.student.tasks.StudentLogoutTask import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -39,15 +40,28 @@ class AppManager : BaseAppManager() { @Inject lateinit var databaseProvider: DatabaseProvider + @Inject + lateinit var alarmScheduler: AlarmScheduler + override fun onCreate() { super.onCreate() MasqueradeHelper.masqueradeLogoutTask = Runnable { - StudentLogoutTask(LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, databaseProvider = databaseProvider).execute() + StudentLogoutTask( + LogoutTask.Type.LOGOUT, + typefaceBehavior = typefaceBehavior, + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler + ).execute() } } override fun performLogoutOnAuthError() { - StudentLogoutTask(LogoutTask.Type.LOGOUT, typefaceBehavior = typefaceBehavior, databaseProvider = databaseProvider).execute() + StudentLogoutTask( + LogoutTask.Type.LOGOUT, + typefaceBehavior = typefaceBehavior, + databaseProvider = databaseProvider, + alarmScheduler = alarmScheduler + ).execute() } override fun getWorkManagerFactory(): WorkerFactory = workerFactory diff --git a/apps/student/src/main/java/com/instructure/student/util/Extensions.kt b/apps/student/src/main/java/com/instructure/student/util/Extensions.kt index 4190da75d2..110f6fd2c9 100644 --- a/apps/student/src/main/java/com/instructure/student/util/Extensions.kt +++ b/apps/student/src/main/java/com/instructure/student/util/Extensions.kt @@ -15,6 +15,7 @@ */ package com.instructure.student.util +import android.content.BroadcastReceiver import android.content.Context import com.instructure.canvasapi2.managers.ExternalToolManager import com.instructure.canvasapi2.models.Assignment @@ -24,8 +25,14 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.utils.getShortMonthAndDay import com.instructure.pandautils.utils.getTime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.threeten.bp.OffsetDateTime -import java.util.* +import java.util.Locale +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext suspend fun Long.isStudioEnabled(): Boolean { val context = CanvasContext.getGenericContext(CanvasContext.Type.COURSE, this) @@ -52,3 +59,18 @@ fun String.toDueAtString(context: Context): String { val dueDateTime = OffsetDateTime.parse(this).withOffsetSameInstant(OffsetDateTime.now().offset) return context.getString(com.instructure.pandares.R.string.submissionDetailsDueAt, dueDateTime.getShortMonthAndDay(), dueDateTime.getTime()) } + +fun BroadcastReceiver.goAsync( + context: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> Unit +) { + val pendingResult = goAsync() + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(context) { + try { + block() + } finally { + pendingResult.finish() + } + } +} \ No newline at end of file diff --git a/apps/student/src/main/res/layout/dialog_custom_reminder.xml b/apps/student/src/main/res/layout/dialog_custom_reminder.xml index dd3ec74c7e..3f5ad32e2c 100644 --- a/apps/student/src/main/res/layout/dialog_custom_reminder.xml +++ b/apps/student/src/main/res/layout/dialog_custom_reminder.xml @@ -1,5 +1,5 @@ No preview available for URLs using \'http://\' diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json index 8d795bfe6d..d1a943762c 100644 --- a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/9.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 9, - "identityHash": "73877a0396a69ef01fc040c995d1a5bd", + "identityHash": "d1a4b121027a0855fb86782dbdc1d7cd", "entities": [ { "tableName": "AttachmentEntity", @@ -502,7 +502,7 @@ }, { "tableName": "ReminderEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `text` TEXT NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `htmlUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `text` TEXT NOT NULL, `time` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -522,11 +522,29 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -542,7 +560,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '73877a0396a69ef01fc040c995d1a5bd')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd1a4b121027a0855fb86782dbdc1d7cd')" ] } } \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt index 66dab6fce7..9b39bfbe6b 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDaoTest.kt @@ -58,9 +58,9 @@ class ReminderDaoTest { @Test fun testFindItemsByAssignmentId() = runTest { val entities = listOf( - ReminderEntity(1, 1, 1, "Test 1"), - ReminderEntity(2, 2, 1, "Test 2"), - ReminderEntity(3, 1, 2, "Test 3"), + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 2, 1, "htmlUrl2", "Assignment 2", "2 days", 2000), + ReminderEntity(3, 1, 2, "htmlUrl3", "Assignment 3", "3 days", 3000) ) entities.forEach { reminderDao.insert(it) } @@ -73,8 +73,8 @@ class ReminderDaoTest { @Test fun testDeleteById() = runTest { val entities = listOf( - ReminderEntity(1, 1, 1, "Test 1"), - ReminderEntity(2, 1, 1, "Test 2") + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 1, 1, "htmlUrl2", "Assignment 2", "2 days", 2000), ) entities.forEach { reminderDao.insert(it) } @@ -85,4 +85,35 @@ class ReminderDaoTest { Assert.assertEquals(entities.takeLast(1), result.value) } + + @Test + fun testDeletePastReminders() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 1, 1, "htmlUrl2", "Assignment 2", "2 days", 2000), + ReminderEntity(2, 1, 1, "htmlUrl2", "Assignment 2", "2 days", 3000) + ) + entities.forEach { reminderDao.insert(it) } + + reminderDao.deletePastReminders(2000) + + val result = reminderDao.findByAssignmentIdLiveData(1, 1) + result.observeForever { } + + Assert.assertEquals(entities.takeLast(1), result.value) + } + + @Test + fun testFindItemsByUserId() = runTest { + val entities = listOf( + ReminderEntity(1, 1, 1, "htmlUrl1", "Assignment 1", "1 day", 1000), + ReminderEntity(2, 1, 3, "htmlUrl2", "Assignment 2", "2 days", 2000), + ReminderEntity(3, 2, 3, "htmlUrl3", "Assignment 3", "3 days", 3000) + ) + entities.forEach { reminderDao.insert(it) } + + val result = reminderDao.findByUserId(1) + + Assert.assertEquals(entities.take(2), result) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt index fe3f85a1dd..707e4f0a0e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt @@ -56,6 +56,6 @@ val appDatabaseMigrations = arrayOf( }, createMigration(8, 9) { database -> - database.execSQL("CREATE TABLE IF NOT EXISTS ReminderEntity (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, userId INTEGER NOT NULL, assignmentId INTEGER NOT NULL, text TEXT NOT NULL)") + database.execSQL("CREATE TABLE IF NOT EXISTS ReminderEntity (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, userId INTEGER NOT NULL, assignmentId INTEGER NOT NULL, htmlUrl TEXT NOT NULL, name TEXT NOT NULL, text TEXT NOT NULL, time INTEGER NOT NULL)") } ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt index 2931230047..0f3a32495d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ReminderDao.kt @@ -29,11 +29,17 @@ import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity interface ReminderDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(reminder: ReminderEntity) + suspend fun insert(reminder: ReminderEntity): Long @Query("DELETE FROM ReminderEntity WHERE id = :id") suspend fun deleteById(id: Long) + @Query("DELETE FROM ReminderEntity WHERE time < :time") + suspend fun deletePastReminders(time: Long) + @Query("SELECT * FROM ReminderEntity WHERE userId = :userId AND assignmentId = :assignmentId") fun findByAssignmentIdLiveData(userId: Long, assignmentId: Long): LiveData> + + @Query("SELECT * FROM ReminderEntity WHERE userId = :userId") + suspend fun findByUserId(userId: Long): List } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt index 0eac0f89b0..e98f494de8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ReminderEntity.kt @@ -27,5 +27,8 @@ data class ReminderEntity( val id: Long = 0, val userId: Long, val assignmentId: Long, - val text: String + val htmlUrl: String, + val name: String, + val text: String, + val time: Long ) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt index d5f7016a6e..c12384121b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/NetworkStateProvider.kt @@ -33,6 +33,8 @@ interface NetworkStateProvider { class NetworkStateProviderImpl(context: Context) : NetworkStateProvider { private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + private val hasActiveNetwork = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).orDefault() private val _isOnlineLiveData = MutableLiveData() @@ -40,10 +42,7 @@ class NetworkStateProviderImpl(context: Context) : NetworkStateProvider { get() = _isOnlineLiveData init { - val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - val hasActiveNetwork = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).orDefault() _isOnlineLiveData.postValue(hasActiveNetwork) - connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) @@ -58,6 +57,6 @@ class NetworkStateProviderImpl(context: Context) : NetworkStateProvider { } override fun isOnline(): Boolean { - return _isOnlineLiveData.value.orDefault() + return _isOnlineLiveData.value ?: hasActiveNetwork } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt index ac6d3b5bc9..c18c91745b 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/StorageUtilsTest.kt @@ -23,6 +23,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -37,6 +38,7 @@ class StorageUtilsTest { mockkStatic(Environment::class) } + @After fun tearDown() { unmockkAll() } From 869f8f01fe6c8f4ef9de53838cf9245eb60c7b76 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Fri, 2 Feb 2024 13:09:08 +0100 Subject: [PATCH 14/52] [MBL-17313][Student] - Create E2E Test for Offline Pages (#2321) --- .../ui/e2e/offline/OfflinePagesE2ETest.kt | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt new file mode 100644 index 0000000000..e4e90fb2f7 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.student.ui.e2e.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import androidx.test.espresso.web.webdriver.Locator +import com.google.android.material.checkbox.MaterialCheckBox +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.PagesApi +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.util.Randomizer +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertOfflineIndicator +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline +import com.instructure.student.ui.pages.WebViewTextCheck +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Test + +@HiltAndroidTest +class OfflinePagesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.PAGES, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflinePagesE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for '${course.name}' course.") + val pageUnpublished = createCoursePage(course, teacher, published = false, frontPage = false) + + Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for '${course.name}' course.") + val pagePublished = createCoursePage(course, teacher, published = true, frontPage = false, editingRoles = "teachers,students", body = "

Regular Page Text

") + + Log.d(PREPARATION_TAG,"Seed a PUBLISHED, but NOT editable page for '${course.name}' course.") + val pageNotEditable = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") + + Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for '${course.name}' course.") + val pagePublishedFront = createCoursePage(course, teacher, published = true, frontPage = true, editingRoles = "public", body = "

Front Page Text

") + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Assert that the '${course.name}' course's checkbox state is 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Expand the course. Select the 'Pages' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.expandCollapseItem(course.name) + manageOfflineContentPage.changeItemSelectionState("Pages") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course.name}' course and click on 'Pages' tab to navigate to the Page List Page.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectPages() + + Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Page List Page.") + assertOfflineIndicator() + + Log.d(STEP_TAG,"Assert that '${pagePublishedFront.title}' published front page is displayed.") + pageListPage.assertFrontPageDisplayed(pagePublishedFront) + + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is displayed.") + pageListPage.assertRegularPageDisplayed(pagePublished) + + Log.d(STEP_TAG,"Assert that '${pageUnpublished.title}' unpublished page is NOT displayed.") + pageListPage.assertPageNotDisplayed(pageUnpublished) + + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${pagePublishedFront.title}', the page's name to the search input field.") + pageListPage.searchable.clickOnSearchButton() + pageListPage.searchable.typeToSearchBar(pagePublishedFront.title) + + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is NOT displayed and there is only one page (the front page) is displayed.") + pageListPage.assertPageNotDisplayed(pagePublished) + pageListPage.assertPageListItemCount(1) + + Log.d(STEP_TAG, "Click on clear search icon (X).") + pageListPage.searchable.clickOnClearSearchButton() + + Log.d(STEP_TAG,"Assert that '${pagePublishedFront.title}' published front page is displayed.") + pageListPage.assertFrontPageDisplayed(pagePublishedFront) + + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is displayed.") + pageListPage.assertRegularPageDisplayed(pagePublished) + + Log.d(STEP_TAG,"Assert that '${pageUnpublished.title}' unpublished page is NOT displayed.") + pageListPage.assertPageNotDisplayed(pageUnpublished) + + Log.d(STEP_TAG,"Open '${pagePublishedFront.title}' page. Assert that it is really a front (published) page via web view assertions.") + pageListPage.selectFrontPage(pagePublishedFront) + canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text")) + + Log.d(STEP_TAG,"Navigate back to Pages page.") + Espresso.pressBack() + + Log.d(STEP_TAG, "Select '${pageNotEditable.title}' page. Assert that it is not editable as a student, then navigate back to Page List page.") + pageListPage.selectRegularPage(pageNotEditable) + canvasWebViewPage.assertDoesNotEditable() + Espresso.pressBack() + + Log.d(STEP_TAG,"Open '${pagePublished.title}' page. Assert that it is really a regular published page via web view assertions.") + pageListPage.selectRegularPage(pagePublished) + canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Regular Page Text")) + + Log.d(STEP_TAG, "Click on the 'Pencil' icon. Assert that the 'No Internet Connection' dialog has displayed. Dismiss the dialog by accepting it.") + canvasWebViewPage.clickEditPencilIcon() + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + + Log.d(STEP_TAG, "Navigate back to Page List page. Select '${pagePublishedFront.title}' front page.") + Espresso.pressBack() + pageListPage.selectFrontPage(pagePublishedFront) + + Log.d(STEP_TAG, "Click on the 'Pencil' icon. Assert that the 'No Internet Connection' dialog has displayed. Dismiss the dialog by accepting it.") + canvasWebViewPage.clickEditPencilIcon() + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + turnOnConnectionViaADB() + } + + private fun createCoursePage( + course: CourseApiModel, + teacher: CanvasUserApiModel, + published: Boolean, + frontPage: Boolean, + editingRoles: String? = null, + body: String = Randomizer.randomPageBody() + ) = PagesApi.createCoursePage( + courseId = course.id, + published = published, + frontPage = frontPage, + editingRoles = editingRoles, + token = teacher.token, + body = body + ) + +} \ No newline at end of file From fec71d1adac0df813c58ac09d06fbb1e152cfe77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Fri, 2 Feb 2024 13:40:15 +0100 Subject: [PATCH 15/52] pre 14 migration --- .../room/offline/OfflineDatabaseMigrations.kt | 67 ++++++++++++------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt index 470d1fdafe..c80837d6b6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseMigrations.kt @@ -17,14 +17,23 @@ package com.instructure.pandautils.room.offline -import androidx.room.migration.Migration import com.instructure.pandautils.room.createMigration val offlineDatabaseMigrations = arrayOf( createMigration(1, 2) { database -> //CourseSyncProgressEntity - database.execSQL("ALTER TABLE CourseSyncProgressEntity DROP COLUMN workerId") + database.execSQL("CREATE TABLE IF NOT EXISTS `CourseSyncProgressEntity_temp` (`courseId` INTEGER NOT NULL," + + "`courseName` TEXT NOT NULL," + + "`tabs` TEXT NOT NULL," + + "`additionalFilesStarted` INTEGER NOT NULL," + + "`progressState` TEXT NOT NULL, PRIMARY KEY(`courseId`))") + database.execSQL( + "INSERT INTO CourseSyncProgressEntity_temp (courseId, courseName, tabs, additionalFilesStarted, progressState) " + + "SELECT courseId, courseName, tabs, additionalFilesStarted, progressState FROM CourseSyncProgressEntity" + ) + database.execSQL("DROP TABLE CourseSyncProgressEntity") + database.execSQL("ALTER TABLE CourseSyncProgressEntity_temp RENAME TO CourseSyncProgressEntity") //FileSyncProgressEntity database.execSQL( @@ -48,33 +57,41 @@ val offlineDatabaseMigrations = arrayOf( database.execSQL("ALTER TABLE FileSyncProgressEntity_temp RENAME TO FileSyncProgressEntity") //LockInfoEntity - database.execSQL("CREATE TABLE IF NOT EXISTS `LockInfoEntity_temp` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + - "`modulePrerequisiteNames` TEXT," + - "`unlockAt` TEXT," + - "`lockedModuleId` INTEGER," + - "`assignmentId` INTEGER," + - "`moduleId` INTEGER," + - "`pageId` INTEGER," + - "FOREIGN KEY(`moduleId`) REFERENCES `ModuleContentDetailsEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + - "FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + - "FOREIGN KEY(`pageId`) REFERENCES `PageEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") - database.execSQL("INSERT INTO LockInfoEntity_temp (id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId) " + - "SELECT id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId FROM LockInfoEntity") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `LockInfoEntity_temp` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + "`modulePrerequisiteNames` TEXT," + + "`unlockAt` TEXT," + + "`lockedModuleId` INTEGER," + + "`assignmentId` INTEGER," + + "`moduleId` INTEGER," + + "`pageId` INTEGER," + + "FOREIGN KEY(`moduleId`) REFERENCES `ModuleContentDetailsEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + + "FOREIGN KEY(`assignmentId`) REFERENCES `AssignmentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED," + + "FOREIGN KEY(`pageId`) REFERENCES `PageEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" + ) + database.execSQL( + "INSERT INTO LockInfoEntity_temp (id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId) " + + "SELECT id, modulePrerequisiteNames, unlockAt, lockedModuleId, assignmentId, moduleId, pageId FROM LockInfoEntity" + ) database.execSQL("DROP TABLE LockInfoEntity") database.execSQL("ALTER TABLE LockInfoEntity_temp RENAME TO LockInfoEntity") //LockedModuleEntity - database.execSQL("CREATE TABLE IF NOT EXISTS `LockedModuleEntity_temp` (" + - "`id` INTEGER NOT NULL," + - "`contextId` INTEGER NOT NULL," + - "`contextType` TEXT," + - "`name` TEXT," + - "`unlockAt` TEXT," + - "`isRequireSequentialProgress` INTEGER NOT NULL," + - "`lockInfoId` INTEGER, PRIMARY KEY(`id`)," + - "FOREIGN KEY(`lockInfoId`) REFERENCES `LockInfoEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") - database.execSQL("INSERT INTO LockedModuleEntity_temp (id, contextId, contextType, name, unlockAt, isRequireSequentialProgress) " + - "SELECT id, contextId, contextType, name, unlockAt, isRequireSequentialProgress FROM LockedModuleEntity") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `LockedModuleEntity_temp` (" + + "`id` INTEGER NOT NULL," + + "`contextId` INTEGER NOT NULL," + + "`contextType` TEXT," + + "`name` TEXT," + + "`unlockAt` TEXT," + + "`isRequireSequentialProgress` INTEGER NOT NULL," + + "`lockInfoId` INTEGER, PRIMARY KEY(`id`)," + + "FOREIGN KEY(`lockInfoId`) REFERENCES `LockInfoEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + database.execSQL( + "INSERT INTO LockedModuleEntity_temp (id, contextId, contextType, name, unlockAt, isRequireSequentialProgress) " + + "SELECT id, contextId, contextType, name, unlockAt, isRequireSequentialProgress FROM LockedModuleEntity" + ) database.execSQL("DROP TABLE LockedModuleEntity") database.execSQL("ALTER TABLE LockedModuleEntity_temp RENAME TO LockedModuleEntity") }, From 24fcacb8fccaba07c70636963ac3242305301a13 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Tue, 6 Feb 2024 14:34:28 +0100 Subject: [PATCH 16/52] [MBL-17345][Student][Teacher] Make course discussion feature flag independent from account flag (#2327) Test plan: See ticket. refs: MBL-17345 affects: Student, Teacher release note: none --- .../router/DiscussionRouteHelperNetworkDataSource.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt index 69aecf616e..fc6460487a 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/router/DiscussionRouteHelperNetworkDataSource.kt @@ -40,14 +40,14 @@ class DiscussionRouteHelperNetworkDataSource( val params = RestParams(isForceReadFromNetwork = forceNetwork) return if (canvasContext.isCourse) { val featureFlags = featuresApi.getEnabledFeaturesForCourse(canvasContext.id, params).dataOrNull - featureFlags?.contains("react_discussions_post") ?: false && featureFlagProvider.getDiscussionRedesignFeatureFlag() + featureFlags?.contains("react_discussions_post") ?: false } else if (canvasContext.isGroup) { val group = canvasContext as Group if (group.courseId == 0L) { featureFlagProvider.getDiscussionRedesignFeatureFlag() } else { val featureFlags = featuresApi.getEnabledFeaturesForCourse(group.courseId, params).dataOrNull - featureFlags?.contains("react_discussions_post") ?: false && featureFlagProvider.getDiscussionRedesignFeatureFlag() + featureFlags?.contains("react_discussions_post") ?: false } } else { false From 835b92d7ccc311eea06d013d70fa1611b0f8f313 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:47:05 +0100 Subject: [PATCH 17/52] [MBL-17274][Student] Remind me UI tests refs: MBL-17274 affects: Student release note: none * UI tests * UI test fixes * Comment fixes --- .../AssignmentDetailsInteractionTest.kt | 130 +++++++++++++++++- .../student/ui/pages/AssignmentDetailsPage.kt | 75 ++++++++++ .../details/AssignmentDetailsFragment.kt | 8 +- .../espresso/page/PageExtensions.kt | 6 + 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt index 38edfa87c9..cd8fdb1b6e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt @@ -20,6 +20,7 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.checkToastText import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.addAssignmentsToGroups @@ -29,13 +30,14 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.utils.toApiString import com.instructure.dataseeding.model.SubmissionType +import com.instructure.student.R import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.routeTo import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertNotNull import org.junit.Test -import java.util.* +import java.util.Calendar @HiltAndroidTest class AssignmentDetailsInteractionTest : StudentTest() { @@ -385,6 +387,132 @@ class AssignmentDetailsInteractionTest : StudentTest() { assignmentDetailsPage.assertStatusSubmitted() } + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testReminderSectionIsNotVisibleWhenThereIsNoFutureDueDate() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertReminderSectionNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testReminderSectionIsVisibleWhenThereIsFutureDueDate() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertReminderSectionDisplayed() + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminder() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.clickOneHourBefore() + + assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testRemoveReminder() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.clickOneHourBefore() + + assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") + + assignmentDetailsPage.removeReminderWithText("1 Hour Before") + + assignmentDetailsPage.assertReminderNotDisplayedWithText("1 Hour Before") + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddCustomReminder() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.clickCustom() + assignmentDetailsPage.assertDoneButtonIsDisabled() + assignmentDetailsPage.fillQuantity("15") + assignmentDetailsPage.assertDoneButtonIsDisabled() + assignmentDetailsPage.clickHoursBefore() + assignmentDetailsPage.clickDone() + + assignmentDetailsPage.assertReminderDisplayedWithText("15 Hours Before") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminderInPastShowsError() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.MINUTE, 30) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.clickOneHourBefore() + + checkToastText(R.string.reminderInPast, activityRule.activity) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminderForTheSameTimeShowsError() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.clickOneHourBefore() + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.clickOneHourBefore() + + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + } + private fun setUpData(restrictQuantitativeData: Boolean = false): MockCanvas { // Test clicking on the Submission and Rubric button to load the Submission Details Page val data = MockCanvas.init( diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt index 5ac189a0f8..249720e25a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt @@ -20,13 +20,18 @@ import android.view.View import android.widget.ScrollView import androidx.appcompat.widget.AppCompatButton import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onData import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled import com.instructure.canvas.espresso.CanvasTest import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.stringContainsTextCaseInsensitive @@ -41,6 +46,8 @@ import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.clearText import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.getPluralFromResource +import com.instructure.espresso.page.getStringFromResource import com.instructure.espresso.page.onView import com.instructure.espresso.page.onViewWithText import com.instructure.espresso.page.plus @@ -58,6 +65,8 @@ import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anything +import org.hamcrest.Matchers.not open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { val toolbar by OnViewWithId(R.id.toolbar) @@ -239,6 +248,72 @@ open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { fun assertSubmissionTypeDisplayed(submissionType: String) { onView(withText(submissionType) + withAncestor(R.id.customPanel)).assertDisplayed() } + + fun assertReminderSectionNotDisplayed() { + onView(withId(R.id.reminderTitle)).assertNotDisplayed() + onView(withId(R.id.reminderDescription)).assertNotDisplayed() + onView(withId(R.id.reminderAdd)).assertNotDisplayed() + } + + fun assertReminderSectionDisplayed() { + onView(withId(R.id.reminderTitle)).scrollTo().assertDisplayed() + onView(withId(R.id.reminderDescription)).scrollTo().assertDisplayed() + onView(withId(R.id.reminderAdd)).scrollTo().assertDisplayed() + } + + fun clickAddReminder() { + onView(withId(R.id.reminderAdd)).scrollTo().click() + } + + fun clickOneHourBefore() { + onView( + withText( + getStringFromResource( + R.string.reminderBefore, + getPluralFromResource(R.plurals.reminderHour, 1, 1) + ) + ) + ).scrollTo().click() + } + + fun assertReminderDisplayedWithText(text: String) { + onView(withText(text)).scrollTo().assertDisplayed() + } + + fun removeReminderWithText(text: String) { + onView( + allOf( + withId(R.id.remove), + hasSibling(withText(text)) + ) + ).click() + onView(withText(R.string.yes)).scrollTo().click() + } + + fun assertReminderNotDisplayedWithText(text: String) { + onView(withText(text)).check(doesNotExist()) + } + + fun clickCustom() { + onData(anything()).inRoot(isDialog()).atPosition(6).perform(click()) + } + + fun fillQuantity(quantity: String) { + onView(withId(R.id.quantity)).scrollTo().typeText(quantity) + Espresso.closeSoftKeyboard() + } + + fun clickHoursBefore() { + onView(withId(R.id.hours)).scrollTo().click() + } + + fun assertDoneButtonIsDisabled() { + onView(withText(R.string.done)).check(matches(not(isEnabled()))) + } + + fun clickDone() { + onView(withText(R.string.done)).click() + } } /** diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt index 029a9551af..d9cf249358 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/AssignmentDetailsFragment.kt @@ -473,7 +473,13 @@ class AssignmentDetailsFragment : ParentFragment(), Bookmarkable { .setTitle(R.string.reminderTitle) .setNegativeButton(R.string.cancel, null) .setSingleChoiceItems( - choices.map { it.getText(resources) }.toTypedArray(), -1 + choices.map { + if (it is ReminderChoice.Custom) { + it.getText(resources) + } else { + getString(R.string.reminderBefore, it.getText(resources)) + } + }.toTypedArray(), -1 ) { dialog, which -> viewModel.onReminderSelected(choices[which]) dialog.dismiss() diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt index cae3a84948..87860ec791 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt @@ -18,6 +18,7 @@ package com.instructure.espresso.page import android.view.View +import androidx.annotation.PluralsRes import androidx.test.espresso.Espresso import androidx.test.espresso.ViewInteraction import androidx.test.espresso.matcher.ViewMatchers @@ -160,6 +161,11 @@ fun BasePage.getStringFromResource(stringResource: Int, vararg params: Any): Str return targetContext.resources.getString(stringResource, *params) } +fun BasePage.getPluralFromResource(@PluralsRes pluralsResource: Int, quantity: Int, vararg params: Any): String { + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + return targetContext.resources.getQuantityString(pluralsResource, quantity, *params) +} + fun BasePage.callOnClick(matcher: Matcher) = ViewCallOnClick.callOnClick(matcher) fun BasePage.scrollTo(viewId: Int) = BaristaScrollInteractions.safelyScrollTo(viewId) From 3f57c1d0709ff005b5b15adfd085f57e5c9f0208 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:47:24 +0100 Subject: [PATCH 18/52] [MBL-17328][Student] Fetch group people list with correct context (#2326) Test plan: See ticket. refs: MBL-17328 affects: Student release note: Fixed a bug where the group people list would show users not in the group. --- .../people/list/PeopleListRecyclerAdapter.kt | 96 +++++++++++++------ 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt index a74128b780..01cf25e3f6 100644 --- a/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListRecyclerAdapter.kt @@ -41,15 +41,24 @@ import kotlinx.coroutines.CoroutineScope import java.util.Locale class PeopleListRecyclerAdapter( - context: Context, - private val lifecycleScope: CoroutineScope, - private val repository: PeopleListRepository, - private val canvasContext: CanvasContext, - private val adapterToFragmentCallback: AdapterToFragmentCallback -) : ExpandableRecyclerAdapter(context, EnrollmentType::class.java, User::class.java) { + context: Context, + private val lifecycleScope: CoroutineScope, + private val repository: PeopleListRepository, + private val canvasContext: CanvasContext, + private val adapterToFragmentCallback: AdapterToFragmentCallback +) : ExpandableRecyclerAdapter( + context, + EnrollmentType::class.java, + User::class.java +) { private val mCourseColor = canvasContext.backgroundColor - private val mEnrollmentPriority = mapOf( EnrollmentType.Teacher to 4, EnrollmentType.Ta to 3, EnrollmentType.Student to 2, EnrollmentType.Observer to 1) + private val mEnrollmentPriority = mapOf( + EnrollmentType.Teacher to 4, + EnrollmentType.Ta to 3, + EnrollmentType.Student to 2, + EnrollmentType.Observer to 1 + ) init { isExpandedByDefault = true @@ -58,16 +67,18 @@ class PeopleListRecyclerAdapter( override fun loadFirstPage() { lifecycleScope.tryLaunch { - var canvasContext = canvasContext - - // If the canvasContext is a group, and has a course we want to add the Teachers and TAs from that course to the peoples list - if (CanvasContext.Type.isGroup(this@PeopleListRecyclerAdapter.canvasContext) && (this@PeopleListRecyclerAdapter.canvasContext as Group).courseId > 0) { - // We build a generic CanvasContext with type set to COURSE and give it the CourseId from the group, so that it wil use the course API not the group API - canvasContext = CanvasContext.getGenericContext(CanvasContext.Type.COURSE, this@PeopleListRecyclerAdapter.canvasContext.courseId, "") - } - - val teachers = repository.loadTeachers(canvasContext, isRefresh) - val tas = repository.loadTAs(canvasContext, isRefresh) + val teacherContext = + if (CanvasContext.Type.isGroup(this@PeopleListRecyclerAdapter.canvasContext) && (this@PeopleListRecyclerAdapter.canvasContext as Group).courseId > 0) { + // We build a generic CanvasContext with type set to COURSE and give it the CourseId from the group, so that it wil use the course API not the group API + CanvasContext.getGenericContext( + CanvasContext.Type.COURSE, + this@PeopleListRecyclerAdapter.canvasContext.courseId, + "" + ) + } else canvasContext + + val teachers = repository.loadTeachers(teacherContext, isRefresh) + val tas = repository.loadTAs(teacherContext, isRefresh) val peopleFirstPage = repository.loadFirstPagePeople(canvasContext, isRefresh) val result = teachers.dataOrThrow + tas.dataOrThrow + peopleFirstPage.dataOrThrow @@ -102,38 +113,59 @@ class PeopleListRecyclerAdapter( private fun populateAdapter(result: List) { val (enrolled, unEnrolled) = result.partition { it.enrollments.isNotEmpty() } enrolled - .groupBy { - it.enrollments.sortedByDescending { enrollment -> mEnrollmentPriority[enrollment.type] }[0].type - } - .forEach { (type, users) -> addOrUpdateAllItems(type!!, users) } + .groupBy { + it.enrollments.sortedByDescending { enrollment -> mEnrollmentPriority[enrollment.type] }[0].type + } + .forEach { (type, users) -> addOrUpdateAllItems(type!!, users) } if (CanvasContext.Type.isGroup(canvasContext)) addOrUpdateAllItems(EnrollmentType.NoEnrollment, unEnrolled) notifyDataSetChanged() adapterToFragmentCallback.onRefreshFinished() } override fun createViewHolder(v: View, viewType: Int): RecyclerView.ViewHolder = - if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder(v) else PeopleViewHolder(v) + if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder(v) else PeopleViewHolder(v) override fun itemLayoutResId(viewType: Int): Int = - if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder.HOLDER_RES_ID else PeopleViewHolder.HOLDER_RES_ID + if (viewType == Types.TYPE_HEADER) PeopleHeaderViewHolder.HOLDER_RES_ID else PeopleViewHolder.HOLDER_RES_ID override fun contextReady() = Unit override fun onBindChildHolder(holder: RecyclerView.ViewHolder, peopleGroupType: EnrollmentType, user: User) { val groupItemCount = getGroupItemCount(peopleGroupType) val itemPosition = storedIndexOfItem(peopleGroupType, user) - (holder as PeopleViewHolder).bind(user, adapterToFragmentCallback, mCourseColor, itemPosition == 0, itemPosition == groupItemCount - 1) + (holder as PeopleViewHolder).bind( + user, + adapterToFragmentCallback, + mCourseColor, + itemPosition == 0, + itemPosition == groupItemCount - 1 + ) } - override fun onBindHeaderHolder(holder: RecyclerView.ViewHolder, enrollmentType: EnrollmentType, isExpanded: Boolean) { - (holder as PeopleHeaderViewHolder).bind(enrollmentType, getHeaderTitle(enrollmentType), isExpanded, viewHolderHeaderClicked) + override fun onBindHeaderHolder( + holder: RecyclerView.ViewHolder, + enrollmentType: EnrollmentType, + isExpanded: Boolean + ) { + (holder as PeopleHeaderViewHolder).bind( + enrollmentType, + getHeaderTitle(enrollmentType), + isExpanded, + viewHolderHeaderClicked + ) } override fun createGroupCallback(): GroupSortedList.GroupComparatorCallback { return object : GroupSortedList.GroupComparatorCallback { - override fun compare(o1: EnrollmentType, o2: EnrollmentType) = getHeaderTitle(o2).compareTo(getHeaderTitle(o1)) - override fun areContentsTheSame(oldGroup: EnrollmentType, newGroup: EnrollmentType) = getHeaderTitle(oldGroup) == getHeaderTitle(newGroup) - override fun areItemsTheSame(group1: EnrollmentType, group2: EnrollmentType) = getHeaderTitle(group1) == getHeaderTitle(group2) + override fun compare(o1: EnrollmentType, o2: EnrollmentType) = + getHeaderTitle(o2).compareTo(getHeaderTitle(o1)) + + override fun areContentsTheSame(oldGroup: EnrollmentType, newGroup: EnrollmentType) = + getHeaderTitle(oldGroup) == getHeaderTitle(newGroup) + + override fun areItemsTheSame(group1: EnrollmentType, group2: EnrollmentType) = + getHeaderTitle(group1) == getHeaderTitle(group2) + override fun getUniqueGroupId(group: EnrollmentType) = getHeaderTitle(group).hashCode().toLong() override fun getGroupType(group: EnrollmentType) = Types.TYPE_HEADER } @@ -141,7 +173,11 @@ class PeopleListRecyclerAdapter( override fun createItemCallback(): GroupSortedList.ItemComparatorCallback { return object : GroupSortedList.ItemComparatorCallback { - override fun compare(group: EnrollmentType, o1: User, o2: User) = NaturalOrderComparator.compare(o1.sortableName?.lowercase(Locale.getDefault()).orEmpty(), o2.sortableName?.lowercase(Locale.getDefault()).orEmpty()) + override fun compare(group: EnrollmentType, o1: User, o2: User) = NaturalOrderComparator.compare( + o1.sortableName?.lowercase(Locale.getDefault()).orEmpty(), + o2.sortableName?.lowercase(Locale.getDefault()).orEmpty() + ) + override fun areContentsTheSame(oldItem: User, newItem: User) = oldItem.sortableName == newItem.sortableName override fun areItemsTheSame(item1: User, item2: User) = item1.id == item2.id override fun getUniqueItemId(item: User) = item.id From a5cbe34b95c060d02bbb5bcd2466315c65d1ee6e Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Wed, 7 Feb 2024 09:45:42 +0100 Subject: [PATCH 19/52] [MBL-17332][Teacher] Edit file permissions (#2320) Test plan: See ticket for design. Compare functionality with web. refs: MBL-17332 affects: Teacher release note: none --- .../teacher/ui/pages/ModulesPage.kt | 4 +- .../ui/renderTests/ModuleListRenderTest.kt | 40 +- .../renderTests/pages/ModuleListRenderPage.kt | 9 + .../instructure/teacher/di/EventBusModule.kt | 36 + .../modules/list/ModuleListEffectHandler.kt | 11 +- .../features/modules/list/ModuleListModels.kt | 29 +- .../modules/list/ModuleListPresenter.kt | 5 +- .../features/modules/list/ModuleListUpdate.kt | 8 + .../list/ui/ModuleListRecyclerAdapter.kt | 11 +- .../modules/list/ui/ModuleListView.kt | 92 ++- .../modules/list/ui/ModuleListViewState.kt | 12 +- .../list/ui/binders/ModuleListItemBinder.kt | 114 ++- .../list/ui/file/UpdateFileDialogFragment.kt | 140 ++++ .../list/ui/file/UpdateFileViewData.kt | 65 ++ .../list/ui/file/UpdateFileViewModel.kt | 247 +++++++ .../main/res/layout/adapter_module_item.xml | 16 +- .../layout/fragment_dialog_update_file.xml | 445 ++++++++++++ .../list/ModuleListEffectHandlerTest.kt | 9 + .../modules/list/ModuleListPresenterTest.kt | 28 +- .../unit/modules/list/ModuleListUpdateTest.kt | 50 +- .../list/file/UpdateFileViewModelTest.kt | 647 ++++++++++++++++++ .../matchers/WithDrawableViewMatcher.kt | 46 ++ .../canvasapi2/apis/FileFolderAPI.kt | 6 + .../instructure/canvasapi2/apis/ModuleAPI.kt | 3 + .../canvasapi2/models/CreateFolder.kt | 4 +- .../canvasapi2/models/FileFolder.kt | 130 ++-- .../canvasapi2/models/ModuleContentDetails.kt | 32 +- .../canvasapi2/utils/DateHelper.kt | 3 +- .../main/res/drawable/ic_calendar_month.xml | 28 + .../src/main/res/drawable/ic_eye_off.xml | 28 + libs/pandares/src/main/res/values/strings.xml | 18 + .../pandautils/binding/BindingAdapters.kt | 2 +- .../dialogs/DatePickerDialogFragment.kt | 21 +- .../instructure/pandautils/mvvm/ViewState.kt | 2 +- .../instructure/pandautils/views/EmptyView.kt | 12 +- 35 files changed, 2168 insertions(+), 185 deletions(-) create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/di/EventBusModule.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileDialogFragment.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewData.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewModel.kt create mode 100644 apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml create mode 100644 apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/file/UpdateFileViewModelTest.kt create mode 100644 automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt create mode 100644 libs/pandares/src/main/res/drawable/ic_calendar_month.xml create mode 100644 libs/pandares/src/main/res/drawable/ic_eye_off.xml diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt index 55acb833fc..50e83c8def 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt @@ -91,7 +91,7 @@ class ModulesPage : BasePage() { */ fun assertModuleItemIsPublished(moduleItemName: String) { val siblingChildMatcher = withChild(withId(R.id.moduleItemTitle) + withText(moduleItemName)) - onView(withId(R.id.moduleItemPublishedIcon) + hasSibling(siblingChildMatcher)).assertDisplayed() + onView(withId(R.id.moduleItemStatusIcon) + hasSibling(siblingChildMatcher)).assertDisplayed() onView(withId(R.id.moduleItemUnpublishedIcon) + hasSibling(siblingChildMatcher)).assertNotDisplayed() } @@ -104,7 +104,7 @@ class ModulesPage : BasePage() { fun assertModuleItemNotPublished(moduleTitle: String, moduleItemName: String) { val siblingChildMatcher = withChild(withId(R.id.moduleItemTitle) + withText(moduleItemName)) onView(withId(R.id.moduleItemUnpublishedIcon) + hasSibling(siblingChildMatcher)).assertDisplayed() - onView(withId(R.id.moduleItemPublishedIcon) + hasSibling(siblingChildMatcher)).assertNotDisplayed() + onView(withId(R.id.moduleItemStatusIcon) + hasSibling(siblingChildMatcher)).assertNotDisplayed() } /** diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt index 968552cf31..9f08f3918b 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt @@ -20,6 +20,7 @@ import android.os.Build import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isEnabled import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.ModuleItem import com.instructure.espresso.assertCompletelyDisplayed import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText @@ -63,7 +64,8 @@ class ModuleListRenderTest : TeacherRenderTest() { isPublished = true, indent = 0, tintColor = Color.BLUE, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment ) } @@ -158,8 +160,7 @@ class ModuleListRenderTest : TeacherRenderTest() { items = listOf(moduleItem) ) loadPageWithViewState(state) - page.moduleItemPublishedIcon.assertDisplayed() - page.moduleItemUnpublishedIcon.assertNotDisplayed() + page.assertStatusIcon(R.drawable.ic_complete_solid, R.color.textSuccess) } @Test @@ -171,21 +172,7 @@ class ModuleListRenderTest : TeacherRenderTest() { items = listOf(moduleItem) ) loadPageWithViewState(state) - page.moduleItemUnpublishedIcon.assertDisplayed() - page.moduleItemPublishedIcon.assertNotDisplayed() - } - - @Test - fun doesNotDisplayModuleItemPublishStatusIcon() { - val moduleItem = moduleItemTemplate.copy( - isPublished = null - ) - val state = ModuleListViewState( - items = listOf(moduleItem) - ) - loadPageWithViewState(state) - page.moduleItemUnpublishedIcon.assertNotDisplayed() - page.moduleItemPublishedIcon.assertNotDisplayed() + page.assertStatusIcon(R.drawable.ic_no, R.color.textDark) } @Test @@ -223,7 +210,8 @@ class ModuleListRenderTest : TeacherRenderTest() { isLoading = false, indent = 0, tintColor = Color.BLUE, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment ) }, false ) @@ -318,7 +306,8 @@ class ModuleListRenderTest : TeacherRenderTest() { isLoading = false, indent = 0, tintColor = Color.BLUE, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment ) }, false ) @@ -333,7 +322,16 @@ class ModuleListRenderTest : TeacherRenderTest() { fun scrollsToTargetItem() { val itemCount = 50 val targetItem = ModuleListItemData.ModuleItemData( - 1234L, "This is the target item", null, null, R.drawable.ic_attachment, false, 0, Color.BLUE, true + 1234L, + "This is the target item", + null, + null, + R.drawable.ic_attachment, + false, + 0, + Color.BLUE, + true, + type = ModuleItem.Type.Assignment ) val state = ModuleListViewState( items = listOf( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt index ca1832ec17..11570caefc 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt @@ -15,6 +15,8 @@ */ package com.instructure.teacher.ui.renderTests.pages +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches @@ -22,9 +24,11 @@ import androidx.test.espresso.contrib.RecyclerViewActions import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.matchers.WithDrawableViewMatcher import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText import com.instructure.teacher.R import com.instructure.teacher.ui.utils.SwipeRefreshLayoutMatchers @@ -51,6 +55,7 @@ class ModuleListRenderPage : BasePage(R.id.moduleList) { val moduleItemTitle by OnViewWithId(R.id.moduleItemTitle) val moduleItemIndent by OnViewWithId(R.id.moduleItemIndent) val moduleItemSubtitle by OnViewWithId(R.id.moduleItemSubtitle) + val moduleItemStatusIcon by OnViewWithId(R.id.moduleItemStatusIcon) val moduleItemPublishedIcon by OnViewWithId(R.id.moduleItemPublishedIcon) val moduleItemUnpublishedIcon by OnViewWithId(R.id.moduleItemUnpublishedIcon) val moduleItemLoadingView by OnViewWithId(R.id.moduleItemLoadingView) @@ -76,4 +81,8 @@ class ModuleListRenderPage : BasePage(R.id.moduleList) { fun assertHasItemIndent(indent: Int) { moduleItemIndent.check(matches(ViewSizeMatcher.hasWidth(indent))) } + + fun assertStatusIcon(@DrawableRes iconId: Int, @ColorRes tintId: Int) { + WithDrawableViewMatcher(iconId, tintId).matches(withId(R.id.moduleItemStatusIcon)) + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/EventBusModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/EventBusModule.kt new file mode 100644 index 0000000000..f021eb0648 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/di/EventBusModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import org.greenrobot.eventbus.EventBus + +@Module +@InstallIn(ViewModelComponent::class) +class EventBusModule { + + @Provides + fun provideEventBus(): EventBus { + return EventBus.getDefault() + } +} + diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt index 0b9a7dd293..8c0e9556aa 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt @@ -17,6 +17,7 @@ package com.instructure.teacher.features.modules.list import com.instructure.canvasapi2.CanvasRestAdapter +import com.instructure.canvasapi2.apis.FileFolderAPI import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.builders.RestParams @@ -25,11 +26,13 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.Progress +import com.instructure.canvasapi2.models.UpdateFileFolder import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.exhaustive import com.instructure.canvasapi2.utils.isValid +import com.instructure.canvasapi2.utils.toApiString import com.instructure.canvasapi2.utils.tryOrNull import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.awaitApiResponse @@ -39,10 +42,11 @@ import com.instructure.teacher.features.modules.list.ui.ModuleListView import com.instructure.teacher.mobius.common.ui.EffectHandler import kotlinx.coroutines.launch import retrofit2.Response +import java.util.Date class ModuleListEffectHandler( private val moduleApi: ModuleAPI.ModuleInterface, - private val progressApi: ProgressAPI.ProgressInterface + private val progressApi: ProgressAPI.ProgressInterface, ) : EffectHandler() { override fun accept(effect: ModuleListEffect) { when (effect) { @@ -76,9 +80,14 @@ class ModuleListEffectHandler( effect.itemId, effect.published ) + is ModuleListEffect.ShowSnackbar -> { view?.showSnackbar(effect.message) } + + is ModuleListEffect.UpdateFileModuleItem -> { + view?.showUpdateFileDialog(effect.fileId, effect.contentDetails) + } }.exhaustive } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt index 07871e8e88..aa29119aaf 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt @@ -18,10 +18,12 @@ package com.instructure.teacher.features.modules.list import androidx.annotation.StringRes import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.isValid +import java.util.Date sealed class ModuleListEvent { object PullToRefresh : ModuleListEvent() @@ -33,13 +35,24 @@ sealed class ModuleListEvent { data class ItemRefreshRequested(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent() data class ReplaceModuleItems(val items: List) : ModuleListEvent() data class RemoveModuleItems(val type: String, val predicate: (item: ModuleItem) -> Boolean) : ModuleListEvent() - data class BulkUpdateModule(val moduleId: Long, val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : ModuleListEvent() - data class BulkUpdateAllModules(val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : ModuleListEvent() + data class BulkUpdateModule(val moduleId: Long, val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : + ModuleListEvent() + + data class BulkUpdateAllModules(val action: BulkModuleUpdateAction, val skipContentTags: Boolean) : + ModuleListEvent() + data class UpdateModuleItem(val itemId: Long, val isPublished: Boolean) : ModuleListEvent() - data class ModuleItemUpdateSuccess(val item: ModuleItem, val published: Boolean): ModuleListEvent() - data class ModuleItemUpdateFailed(val itemId: Long): ModuleListEvent() - data class BulkUpdateSuccess(val skipContentTags: Boolean, val action: BulkModuleUpdateAction, val allModules: Boolean) : ModuleListEvent() + data class ModuleItemUpdateSuccess(val item: ModuleItem, val published: Boolean) : ModuleListEvent() + data class ModuleItemUpdateFailed(val itemId: Long) : ModuleListEvent() + data class BulkUpdateSuccess( + val skipContentTags: Boolean, + val action: BulkModuleUpdateAction, + val allModules: Boolean + ) : ModuleListEvent() + data class BulkUpdateFailed(val skipContentTags: Boolean) : ModuleListEvent() + + data class UpdateFileModuleItem(val fileId: Long, val contentDetails: ModuleContentDetails) : ModuleListEvent() } sealed class ModuleListEffect { @@ -62,6 +75,7 @@ sealed class ModuleListEffect { ) : ModuleListEffect() data class UpdateModuleItems(val canvasContext: CanvasContext, val items: List) : ModuleListEffect() + data class BulkUpdateModules( val canvasContext: CanvasContext, val moduleIds: List, @@ -78,6 +92,11 @@ sealed class ModuleListEffect { ) : ModuleListEffect() data class ShowSnackbar(@StringRes val message: Int) : ModuleListEffect() + + data class UpdateFileModuleItem( + val fileId: Long, + val contentDetails: ModuleContentDetails + ) : ModuleListEffect() } data class ModuleListModel( diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt index 96d7ad094a..369fa69bcc 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListPresenter.kt @@ -132,7 +132,10 @@ object ModuleListPresenter : Presenter { indent = item.indent * indentWidth, tintColor = courseColor, enabled = !loading, - isLoading = loading + isLoading = loading, + type = tryOrNull { ModuleItem.Type.valueOf(item.type.orEmpty()) } ?: ModuleItem.Type.Assignment, + contentDetails = item.moduleDetails, + contentId = item.contentId ) } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt index 9c1689770d..ef6ad24612 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt @@ -268,6 +268,14 @@ class ModuleListUpdate : UpdateInit { + val effect = ModuleListEffect.UpdateFileModuleItem( + event.fileId, + event.contentDetails + ) + return Next.dispatch(setOf(effect)) + } } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt index facaf612e5..5c1d808bf9 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListRecyclerAdapter.kt @@ -17,9 +17,17 @@ package com.instructure.teacher.features.modules.list.ui import android.content.Context +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.teacher.adapters.GroupedRecyclerAdapter import com.instructure.teacher.adapters.ListItemCallback -import com.instructure.teacher.features.modules.list.ui.binders.* +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListEmptyBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListEmptyItemBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListFullErrorBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListInlineErrorBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListItemBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListLoadingBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListModuleBinder +import com.instructure.teacher.features.modules.list.ui.binders.ModuleListSubHeaderBinder interface ModuleListCallback : ListItemCallback { @@ -30,6 +38,7 @@ interface ModuleListCallback : ListItemCallback { fun publishModule(moduleId: Long) fun publishModuleAndItems(moduleId: Long) fun unpublishModuleAndItems(moduleId: Long) + fun updateFileModuleItem(fileId: Long, contentDetails: ModuleContentDetails) } class ModuleListRecyclerAdapter( diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt index cd86aed154..cb8a93c9e7 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt @@ -24,6 +24,7 @@ import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.pandarecycler.PaginatedScrollListener import com.instructure.pandautils.utils.ViewStyler @@ -32,6 +33,7 @@ import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentModuleListBinding import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.ModuleListEvent +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileDialogFragment import com.instructure.teacher.features.modules.progression.ModuleProgressionFragment import com.instructure.teacher.mobius.common.ui.MobiusView import com.instructure.teacher.router.RouteMatcher @@ -42,7 +44,11 @@ class ModuleListView( inflater: LayoutInflater, parent: ViewGroup, val course: CanvasContext -) : MobiusView(inflater, FragmentModuleListBinding::inflate, parent) { +) : MobiusView( + inflater, + FragmentModuleListBinding::inflate, + parent +) { private var consumer: Consumer? = null @@ -66,26 +72,51 @@ class ModuleListView( } override fun publishModule(moduleId: Long) { - showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModuleDialogMessage, R.string.publish, R.string.cancel) { + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModuleDialogMessage, + R.string.publish, + R.string.cancel + ) { consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, true)) } } override fun publishModuleAndItems(moduleId: Long) { - showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModuleAndItemsDialogMessage, R.string.publish, R.string.cancel) { + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModuleAndItemsDialogMessage, + R.string.publish, + R.string.cancel + ) { consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, false)) } } override fun unpublishModuleAndItems(moduleId: Long) { - showConfirmationDialog(R.string.unpublishDialogTitle, R.string.unpublishModuleAndItemsDialogMessage, R.string.unpublish, R.string.cancel) { + showConfirmationDialog( + R.string.unpublishDialogTitle, + R.string.unpublishModuleAndItemsDialogMessage, + R.string.unpublish, + R.string.cancel + ) { consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.UNPUBLISH, false)) } } + override fun updateFileModuleItem(fileId: Long, contentDetails: ModuleContentDetails) { + consumer?.accept( + ModuleListEvent.UpdateFileModuleItem( + fileId, + contentDetails + ) + ) + } + override fun updateModuleItem(itemId: Long, isPublished: Boolean) { val title = if (isPublished) R.string.publishDialogTitle else R.string.unpublishDialogTitle - val message = if (isPublished) R.string.publishModuleItemDialogMessage else R.string.unpublishModuleItemDialogMessage + val message = + if (isPublished) R.string.publishModuleItemDialogMessage else R.string.unpublishModuleItemDialogMessage val positiveButton = if (isPublished) R.string.publish else R.string.unpublish showConfirmationDialog(title, message, positiveButton, R.string.cancel) { @@ -104,23 +135,51 @@ class ModuleListView( setOnMenuItemClickListener { when (it.itemId) { R.id.actionPublishModulesItems -> { - showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModulesAndItemsDialogMessage, R.string.publish, R.string.cancel) { - consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.PUBLISH, false)) + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModulesAndItemsDialogMessage, + R.string.publish, + R.string.cancel + ) { + consumer?.accept( + ModuleListEvent.BulkUpdateAllModules( + BulkModuleUpdateAction.PUBLISH, + false + ) + ) } true } + R.id.actionPublishModules -> { - showConfirmationDialog(R.string.publishDialogTitle, R.string.publishModulesDialogMessage, R.string.publish, R.string.cancel) { + showConfirmationDialog( + R.string.publishDialogTitle, + R.string.publishModulesDialogMessage, + R.string.publish, + R.string.cancel + ) { consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.PUBLISH, true)) } true } + R.id.actionUnpublishModulesItems -> { - showConfirmationDialog(R.string.unpublishDialogTitle, R.string.unpublishModulesAndItemsDialogMessage, R.string.unpublish, R.string.cancel) { - consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.UNPUBLISH, false)) + showConfirmationDialog( + R.string.unpublishDialogTitle, + R.string.unpublishModulesAndItemsDialogMessage, + R.string.unpublish, + R.string.cancel + ) { + consumer?.accept( + ModuleListEvent.BulkUpdateAllModules( + BulkModuleUpdateAction.UNPUBLISH, + false + ) + ) } true } + else -> false } } @@ -161,7 +220,13 @@ class ModuleListView( binding.recyclerView.scrollToPosition(itemPosition) } - fun showConfirmationDialog(title: Int, message: Int, positiveButton: Int, negativeButton: Int, onConfirmed: () -> Unit) { + fun showConfirmationDialog( + title: Int, + message: Int, + positiveButton: Int, + negativeButton: Int, + onConfirmed: () -> Unit + ) { AlertDialog.Builder(context) .setTitle(title) .setMessage(message) @@ -175,4 +240,9 @@ class ModuleListView( fun showSnackbar(@StringRes message: Int) { Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() } + + fun showUpdateFileDialog(fileId: Long, contentDetails: ModuleContentDetails) { + val fragment = UpdateFileDialogFragment.newInstance(fileId, contentDetails, course) + fragment.show((context as FragmentActivity).supportFragmentManager, "editFileDialog") + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt index 6828e12728..a330540f1d 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListViewState.kt @@ -17,6 +17,10 @@ package com.instructure.teacher.features.modules.list.ui import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem data class ModuleListViewState( val showRefreshing: Boolean = false, @@ -86,7 +90,13 @@ sealed class ModuleListItemData { * Whether additional data is being loaded for this item, either for the purpose of routing or for the purpose * of refreshing this item after it has been updated elsewhere in the app. */ - val isLoading: Boolean = false + val isLoading: Boolean = false, + + val type: ModuleItem.Type, + + val contentDetails: ModuleContentDetails? = null, + + val contentId: Long? = null ) : ModuleListItemData() } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt index 2f1bd1fd3b..8b4b7d5532 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt @@ -16,12 +16,17 @@ */ package com.instructure.teacher.features.modules.list.ui.binders -import android.content.res.ColorStateList import android.view.Gravity import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.appcompat.widget.PopupMenu +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.utils.isValid +import com.instructure.pandautils.binding.setTint +import com.instructure.pandautils.utils.getFragmentActivity import com.instructure.pandautils.utils.onClickWithRequireNetwork -import com.instructure.pandautils.utils.setHidden import com.instructure.pandautils.utils.setTextForVisibility import com.instructure.pandautils.utils.setVisible import com.instructure.teacher.R @@ -29,6 +34,7 @@ import com.instructure.teacher.adapters.ListItemBinder import com.instructure.teacher.databinding.AdapterModuleItemBinding import com.instructure.teacher.features.modules.list.ui.ModuleListCallback import com.instructure.teacher.features.modules.list.ui.ModuleListItemData +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileDialogFragment class ModuleListItemBinder : ListItemBinder() { @@ -47,43 +53,97 @@ class ModuleListItemBinder : ListItemBinder menu.add(0, 0, 0, R.string.unpublish) - false -> menu.add(0, 1, 1, R.string.publish) - else -> { - menu.add(0, 0, 0, R.string.unpublish) - menu.add(0, 1, 1, R.string.publish) + if (item.type == ModuleItem.Type.File) { + item.contentId?.let { + callback.updateFileModuleItem(item.contentId, item.contentDetails ?: ModuleContentDetails()) } + } else { + showModuleItemActions(it, item, callback) } + } - popup.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - 0 -> { - callback.updateModuleItem(item.id, false) - true - } - 1 -> { - callback.updateModuleItem(item.id, true) - true - } - else -> false - } + + } + } + + private fun getStatusIcon(data: ModuleListItemData.ModuleItemData): StatusIcon { + val icon: Int + val tint: Int + when (data.type) { + ModuleItem.Type.File -> { + if (data.contentDetails?.hidden == true) { + icon = R.drawable.ic_eye_off + tint = R.color.textWarning + } else if (data.contentDetails?.lockAt.isValid() || data.contentDetails?.unlockAt.isValid()) { + icon = R.drawable.ic_calendar_month + tint = R.color.textWarning + } else { + icon = if (data.isPublished == true) R.drawable.ic_complete_solid else R.drawable.ic_no + tint = if (data.isPublished == true) R.color.textSuccess else R.color.textDark + } + } + + else -> { + icon = if (data.isPublished == true) R.drawable.ic_complete_solid else R.drawable.ic_no + tint = if (data.isPublished == true) R.color.textSuccess else R.color.textDark + } + } + + return StatusIcon(icon, tint) + } + + private fun showModuleItemActions( + view: View, + item: ModuleListItemData.ModuleItemData, + callback: ModuleListCallback + ) { + val popup = PopupMenu(view.context, view, Gravity.START.and(Gravity.TOP)) + val menu = popup.menu + + when (item.isPublished) { + true -> menu.add(0, 0, 0, R.string.unpublish) + false -> menu.add(0, 1, 1, R.string.publish) + else -> { + menu.add(0, 0, 0, R.string.unpublish) + menu.add(0, 1, 1, R.string.publish) + } + } + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + 0 -> { + callback.updateModuleItem(item.id, false) + true + } + + 1 -> { + callback.updateModuleItem(item.id, true) + true } - overflow.contentDescription = it.context.getString(R.string.a11y_contentDescription_moduleOptions, item.title) - popup.show() + else -> false } } + + view.contentDescription = view.context.getString(R.string.a11y_contentDescription_moduleOptions, item.title) + popup.show() } } + +data class StatusIcon( + @DrawableRes val icon: Int, + @ColorRes val tint: Int +) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileDialogFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileDialogFragment.kt new file mode 100644 index 0000000000..d1bb776975 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileDialogFragment.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.features.modules.list.ui.file + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import androidx.core.widget.CompoundButtonCompat +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.pandautils.dialogs.DatePickerDialogFragment +import com.instructure.pandautils.dialogs.TimePickerDialogFragment +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.children +import com.instructure.pandautils.utils.textAndIconColor +import com.instructure.teacher.databinding.FragmentDialogUpdateFileBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class UpdateFileDialogFragment : BottomSheetDialogFragment() { + + private val canvasContext: CanvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) + + private lateinit var binding: FragmentDialogUpdateFileBinding + + private val viewModel: UpdateFileViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentDialogUpdateFileBinding.inflate(inflater, container, false) + binding.viewModel = viewModel + binding.lifecycleOwner = this + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.events.observe(viewLifecycleOwner) { + it.getContentIfNotHandled()?.let { + handleAction(it) + } + } + + setRadioButtonColors() + + binding.updateButton.setTextColor(canvasContext.textAndIconColor) + } + + private fun setRadioButtonColors() = with(binding) { + val radioButtonColor = ViewStyler.makeColorStateListForRadioGroup( + requireContext().getColor(com.instructure.pandautils.R.color.textDarkest), canvasContext.textAndIconColor + ) + + val radioButtons = + availabilityRadioGroup.children.filterIsInstance() + visibilityRadioGroup.children.filterIsInstance() + + radioButtons.forEach { + CompoundButtonCompat.setButtonTintList(it, radioButtonColor) + } + } + + private fun handleAction(event: UpdateFileEvent) { + when (event) { + is UpdateFileEvent.Close -> dismiss() + is UpdateFileEvent.ShowDatePicker -> { + val dialog = DatePickerDialogFragment.getInstance( + manager = childFragmentManager, + defaultDate = event.selectedDate, + minDate = event.minDate, + maxDate = event.maxDate, + callback = event.callback + ) + dialog.show(childFragmentManager, "datePicker") + } + + is UpdateFileEvent.ShowTimePicker -> { + val dialog = TimePickerDialogFragment.getInstance( + childFragmentManager, + event.selectedDate, + event.callback + ) + dialog.show(childFragmentManager, "timePicker") + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + setOnShowListener { + val bottomSheet = findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.skipCollapsed = true + } + } + } + + companion object { + + fun newInstance( + contentId: Long, + contentDetails: ModuleContentDetails?, + canvasContext: CanvasContext + ): UpdateFileDialogFragment { + return UpdateFileDialogFragment().apply { + arguments = Bundle().apply { + putLong("contentId", contentId) + putParcelable("contentDetails", contentDetails) + putParcelable(Const.CANVAS_CONTEXT, canvasContext) + } + } + + } + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewData.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewData.kt new file mode 100644 index 0000000000..0caa52feee --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewData.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.features.modules.list.ui.file + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.instructure.teacher.R +import java.util.Date + +data class UpdateFileViewData( + val selectedAvailability: FileAvailability, + val selectedVisibility: FileVisibility, + val lockAt: Date?, + val unlockAt: Date?, + val lockAtDateString: String?, + val lockAtTimeString: String?, + val unlockAtDateString: String?, + val unlockAtTimeString: String? +) + +enum class FileVisibility { + INHERIT, + CONTEXT, + INSTITUTION, + PUBLIC +} + +enum class FileAvailability { + PUBLISHED, + UNPUBLISHED, + HIDDEN, + SCHEDULED +} + +sealed class UpdateFileEvent { + object Close : UpdateFileEvent() + + data class ShowDatePicker( + val selectedDate: Date?, + val minDate: Date? = null, + val maxDate: Date? = null, + val callback: (year: Int, month: Int, dayOfMonth: Int) -> Unit + ) : UpdateFileEvent() + + data class ShowTimePicker( + val selectedDate: Date?, + val callback: (hourOfDay: Int, minute: Int) -> Unit + ) : UpdateFileEvent() +} \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewModel.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewModel.kt new file mode 100644 index 0000000000..454a20bc54 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/file/UpdateFileViewModel.kt @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.features.modules.list.ui.file + +import android.annotation.SuppressLint +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.UpdateFileFolder +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.mvvm.Event +import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.utils.FileFolderUpdatedEvent +import com.instructure.teacher.R +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +@SuppressLint("StaticFieldLeak") +class UpdateFileViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + @ApplicationContext private val context: Context, + private val fileApi: FileFolderAPI.FilesFoldersInterface, + private val eventBus: EventBus +) : ViewModel() { + + private val fileId: Long = savedStateHandle.get("contentId") ?: -1L + private val contentDetails: ModuleContentDetails = savedStateHandle.get("contentDetails") ?: ModuleContentDetails() + + val data: LiveData + get() = _data + private val _data = MutableLiveData() + + val events: LiveData> + get() = _events + private val _events = MutableLiveData>() + + val state: LiveData + get() = _state + private val _state = MutableLiveData() + + init { + _state.postValue(ViewState.Loading) + loadData() + } + + private fun loadData() { + viewModelScope.launch { + val file = fileApi.getFile(fileId, RestParams(isForceReadFromNetwork = true)).dataOrNull + + val availability = when { + contentDetails.locked == true -> FileAvailability.UNPUBLISHED + contentDetails.hidden == true -> FileAvailability.HIDDEN + contentDetails.lockAt != null || contentDetails.unlockAt != null -> FileAvailability.SCHEDULED + else -> FileAvailability.PUBLISHED + } + + val visibility = + file?.visibilityLevel?.let { FileVisibility.valueOf(it.uppercase()) } ?: FileVisibility.INHERIT + + _data.postValue( + UpdateFileViewData( + selectedAvailability = availability, + selectedVisibility = visibility, + lockAt = contentDetails.lockDate, + unlockAt = contentDetails.unlockDate, + lockAtDateString = DateHelper.getFormattedDate(context, contentDetails.lockDate), + lockAtTimeString = DateHelper.getFormattedTime(context, contentDetails.lockDate), + unlockAtDateString = DateHelper.getFormattedDate(context, contentDetails.unlockDate), + unlockAtTimeString = DateHelper.getFormattedTime(context, contentDetails.unlockDate), + ) + ) + _state.postValue(ViewState.Success) + } + } + + fun onAvailabilityChanged(availability: FileAvailability) { + _data.postValue( + data.value?.copy( + selectedAvailability = availability + ) + ) + } + + fun onVisibilityChanged(visibility: FileVisibility) { + _data.postValue( + data.value?.copy( + selectedVisibility = visibility + ) + ) + } + + fun close() { + _events.postValue(Event(UpdateFileEvent.Close)) + } + + fun update() { + viewModelScope.launch { + _state.postValue(ViewState.Loading) + val updateFileFolder = UpdateFileFolder( + locked = data.value?.selectedAvailability == FileAvailability.UNPUBLISHED, + hidden = data.value?.selectedAvailability == FileAvailability.HIDDEN, + lockAt = if (data.value?.selectedAvailability == FileAvailability.SCHEDULED) data.value?.lockAt?.toApiString() + .orEmpty() else "", + unlockAt = if (data.value?.selectedAvailability == FileAvailability.SCHEDULED) data.value?.unlockAt?.toApiString() + .orEmpty() else "", + visibilityLevel = data.value?.selectedVisibility?.name?.lowercase() + ) + val updatedFile = + fileApi.updateFile(fileId, updateFileFolder, RestParams(isForceReadFromNetwork = true)).dataOrNull + if (updatedFile != null) { + eventBus.post(FileFolderUpdatedEvent(updatedFile)) + _events.postValue(Event(UpdateFileEvent.Close)) + } else { + _state.postValue( + ViewState.Error( + context.getString(R.string.errorOccurred), + R.drawable.ic_panda_nofiles + ) + ) + } + } + } + + fun updateLockAt() { + _events.postValue(Event(UpdateFileEvent.ShowDatePicker(selectedDate = data.value?.lockAt, minDate = data.value?.unlockAt, callback = ::setLockAtDate))) + } + + fun updateLockTime() { + _events.postValue(Event(UpdateFileEvent.ShowTimePicker(data.value?.lockAt, ::setLockTime))) + } + + fun updateUnlockAt() { + _events.postValue(Event(UpdateFileEvent.ShowDatePicker(selectedDate = data.value?.unlockAt, maxDate = data.value?.lockAt, callback = ::setUnlockAtDate))) + } + + fun updateUnlockTime() { + _events.postValue(Event(UpdateFileEvent.ShowTimePicker(data.value?.unlockAt, ::setUnlockTime))) + } + + private fun setUnlockAtDate(year: Int, month: Int, day: Int) { + val selectedDate = getSelectedDate(data.value?.unlockAt, year, month, day) + _data.postValue( + data.value?.copy( + unlockAt = selectedDate, + unlockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + unlockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + private fun setLockAtDate(year: Int, month: Int, day: Int) { + val selectedDate = getSelectedDate(data.value?.lockAt, year, month, day) + _data.postValue( + data.value?.copy( + lockAt = selectedDate, + lockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + lockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + private fun setUnlockTime(hourOfDay: Int, minutes: Int) { + val selectedDate = getSelectedTime(data.value?.unlockAt, hourOfDay, minutes) + _data.postValue( + data.value?.copy( + unlockAt = selectedDate, + unlockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + unlockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + private fun setLockTime(hourOfDay: Int, minutes: Int) { + val selectedDate = getSelectedTime(data.value?.lockAt, hourOfDay, minutes) + _data.postValue( + data.value?.copy( + lockAt = selectedDate, + lockAtDateString = DateHelper.getFormattedDate(context, selectedDate), + lockAtTimeString = DateHelper.getFormattedTime(context, selectedDate) + ) + ) + } + + fun clearUnlockDate() { + _data.postValue( + data.value?.copy( + unlockAt = null, + unlockAtDateString = null, + unlockAtTimeString = null + ) + ) + } + + fun clearLockDate() { + _data.postValue( + data.value?.copy( + lockAt = null, + lockAtDateString = null, + lockAtTimeString = null + ) + ) + } + + private fun getSelectedDate(previousDate: Date?, year: Int, month: Int, day: Int): Date { + val calendar = Calendar.getInstance() + calendar.time = previousDate ?: Date() + calendar.set(year, month, day, 0, 0) + return calendar.time + } + + private fun getSelectedTime(previousDate: Date?, hourOfDay: Int, minutes: Int): Date { + val calendar = Calendar.getInstance() + calendar.time = previousDate ?: Date() + calendar.set(Calendar.HOUR_OF_DAY, hourOfDay) + calendar.set(Calendar.MINUTE, minutes) + return calendar.time + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/res/layout/adapter_module_item.xml b/apps/teacher/src/main/res/layout/adapter_module_item.xml index 5495aa2ec4..58589b3f0d 100644 --- a/apps/teacher/src/main/res/layout/adapter_module_item.xml +++ b/apps/teacher/src/main/res/layout/adapter_module_item.xml @@ -101,23 +101,13 @@ app:layout_constraintTop_toTopOf="parent"> - - + tools:src="@drawable/ic_complete_solid" + tools:tint="@color/textSuccess" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt index eb5db8d2c7..43d75ea9a1 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt @@ -20,6 +20,7 @@ import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.managers.ModuleManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.Progress @@ -380,6 +381,14 @@ class ModuleListEffectHandlerTest : Assert() { } } + @Test + fun `UpdateFileModuleItem calls showUpdateFileDialog on view`() { + val fileId = 123L + val contentDetails = ModuleContentDetails() + connection.accept(ModuleListEffect.UpdateFileModuleItem(fileId, contentDetails)) + verify(timeout = 100) { view.showUpdateFileDialog(fileId, contentDetails) } + confirmVerified(view) + } private fun makeLinkHeader(nextUrl: String) = mapOf("Link" to """<$nextUrl>; rel="next"""").toHeaders() diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt index 458889143f..25ee3f90c1 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListPresenterTest.kt @@ -85,7 +85,12 @@ class ModuleListPresenterTest : Assert() { isPublished = true, indent = 0, tintColor = course.backgroundColor, - enabled = true + enabled = true, + type = ModuleItem.Type.Assignment, + contentDetails = ModuleContentDetails( + dueAt = DateHelper.makeDate(2050, 1, 12, 15, 7, 0).toApiString() + ), + contentId = 0 ) modelTemplate = ModuleListModel( course = course, @@ -212,7 +217,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_discussion + iconResId = R.drawable.ic_discussion, + type = ModuleItem.Type.Discussion ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -232,7 +238,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_attachment + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -252,7 +259,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_pages + iconResId = R.drawable.ic_pages, + type = ModuleItem.Type.Page ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -272,7 +280,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_quiz + iconResId = R.drawable.ic_quiz, + type = ModuleItem.Type.Quiz ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -292,7 +301,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_link + iconResId = R.drawable.ic_link, + type = ModuleItem.Type.ExternalUrl ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -312,7 +322,8 @@ class ModuleListPresenterTest : Assert() { ) val expectedState = moduleItemDataTemplate.copy( title = item.title, - iconResId = R.drawable.ic_lti + iconResId = R.drawable.ic_lti, + type = ModuleItem.Type.ExternalTool ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() @@ -352,7 +363,8 @@ class ModuleListPresenterTest : Assert() { title = item.title, enabled = false, isLoading = true, - iconResId = R.drawable.ic_attachment + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File ) val viewState = ModuleListPresenter.present(model, context) val itemState = (viewState.items[0] as ModuleListItemData.ModuleData).moduleItems.first() diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt index 15cba19b91..f097179a80 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt @@ -17,6 +17,7 @@ package com.instructure.teacher.unit.modules.list import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.DataResult @@ -575,7 +576,10 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( isLoading = false, pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), - modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), loadingModuleItemIds = setOf(1L, 100L, 2L, 200L) ) val expectedModel = initModel.copy( @@ -605,7 +609,10 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( isLoading = false, pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), - modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), loadingModuleItemIds = setOf(1L, 100L, 2L, 200L) ) val expectedModel = initModel.copy( @@ -635,7 +642,10 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( isLoading = false, pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), - modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), loadingModuleItemIds = setOf(1L, 2L) ) val expectedModel = initModel.copy( @@ -665,7 +675,10 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( isLoading = false, pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), - modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), loadingModuleItemIds = setOf(1L, 100L) ) val expectedModel = initModel.copy( @@ -695,7 +708,10 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( isLoading = false, pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), - modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), loadingModuleItemIds = setOf(1L, 100L) ) val expectedModel = initModel.copy( @@ -725,7 +741,10 @@ class ModuleListUpdateTest : Assert() { val model = initModel.copy( isLoading = false, pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), - modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L))), ModuleObject(2L, items = listOf(ModuleItem(200L)))), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), loadingModuleItemIds = setOf(1L) ) val expectedModel = initModel.copy( @@ -839,5 +858,24 @@ class ModuleListUpdateTest : Assert() { ) } + @Test + fun `UpdateFileModuleItem emits UpdateFileModuleItem effect`() { + val model = initModel.copy( + modules = listOf(ModuleObject(1L, items = listOf(ModuleItem(100L, 1L, published = false)))), + ) + val expectedEffect = ModuleListEffect.UpdateFileModuleItem( + 100L, + ModuleContentDetails() + ) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.UpdateFileModuleItem(100L, ModuleContentDetails())) + .then( + assertThatNext( + matchesEffects(expectedEffect) + ) + ) + } + } diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/file/UpdateFileViewModelTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/file/UpdateFileViewModelTest.kt new file mode 100644 index 0000000000..16a3f38d19 --- /dev/null +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/file/UpdateFileViewModelTest.kt @@ -0,0 +1,647 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.unit.modules.list.file + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.UpdateFileFolder +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.utils.FileFolderUpdatedEvent +import com.instructure.teacher.features.modules.list.ui.file.FileAvailability +import com.instructure.teacher.features.modules.list.ui.file.FileVisibility +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileEvent +import com.instructure.teacher.features.modules.list.ui.file.UpdateFileViewModel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.setMain +import org.greenrobot.eventbus.EventBus +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date + +@ExperimentalCoroutinesApi +class UpdateFileViewModelTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val lifecycleOwner: LifecycleOwner = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(lifecycleOwner) + + private val testDispatcher = UnconfinedTestDispatcher() + + private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val fileApi: FileFolderAPI.FilesFoldersInterface = mockk(relaxed = true) + private val eventBus: EventBus = mockk(relaxed = true) + + private lateinit var viewModel: UpdateFileViewModel + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd") + private val timeFormat = SimpleDateFormat("HH:mm") + + @Before + fun setUp() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + Dispatchers.setMain(testDispatcher) + + mockkObject(DateHelper) + val dateCaptor = slot() + every { DateHelper.getFormattedDate(any(), capture(dateCaptor)) } answers { + dateFormat.format(dateCaptor.captured) + } + val timeCaptor = slot() + every { DateHelper.getFormattedTime(any(), capture(timeCaptor)) } answers { + timeFormat.format(timeCaptor.captured) + } + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `loadData sets correct state`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(ViewState.Success, viewModel.state.value) + } + + @Test + fun `Error during file fetching sets visibility to Inherit`() { + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Fail() + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.INHERIT, viewModel.data.value?.selectedVisibility) + assert(viewModel.state.value is ViewState.Success) + } + + @Test + fun `Published file maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.PUBLISHED, viewModel.data.value?.selectedAvailability) + assert(viewModel.state.value is ViewState.Success) + } + + @Test + fun `Unpublished file maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = true) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.UNPUBLISHED, viewModel.data.value?.selectedAvailability) + } + + @Test + fun `Hidden file maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = true, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.HIDDEN, viewModel.data.value?.selectedAvailability) + } + + @Test + fun `Scheduled file maps correctly`() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + calendar.add(Calendar.DAY_OF_YEAR, 1) + val lockDate = calendar.time + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails( + hidden = false, + locked = false, + lockAt = lockDate.toApiString(), + unlockAt = unlockDate.toApiString() + ) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileAvailability.SCHEDULED, viewModel.data.value?.selectedAvailability) + assertEquals(dateFormat.format(lockDate), viewModel.data.value?.lockAtDateString) + assertEquals(timeFormat.format(lockDate), viewModel.data.value?.lockAtTimeString) + assertEquals(dateFormat.format(unlockDate), viewModel.data.value?.unlockAtDateString) + assertEquals(timeFormat.format(unlockDate), viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Inherit visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.INHERIT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.INHERIT, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Context visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.CONTEXT, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Institution visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.INSTITUTION.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.INSTITUTION, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Public visibility maps correctly`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + assertEquals(FileVisibility.PUBLIC, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Availability change updates data`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.HIDDEN) + + assertEquals(FileAvailability.HIDDEN, viewModel.data.value?.selectedAvailability) + } + + @Test + fun `Visibility change updates data`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onVisibilityChanged(FileVisibility.CONTEXT) + + assertEquals(FileVisibility.CONTEXT, viewModel.data.value?.selectedVisibility) + } + + @Test + fun `Close emits correct event`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = false) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel = createViewModel() + viewModel.close() + + assertEquals(UpdateFileEvent.Close, viewModel.events.value?.getContentIfNotHandled()) + } + + @Test + fun `Update lock at date`() { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, -1) + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, lockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateLockAt() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + (event as UpdateFileEvent.ShowDatePicker).callback( + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.lockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.lockAtTimeString) + } + + @Test + fun `Update lock at time`() { + val calendar = Calendar.getInstance() + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, lockAt = calendar.time.toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateLockTime() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowTimePicker) + + calendar.add(Calendar.HOUR_OF_DAY, 1) + calendar.add(Calendar.MINUTE, 1) + (event as UpdateFileEvent.ShowTimePicker).callback( + calendar.get(Calendar.HOUR_OF_DAY), + calendar.get(Calendar.MINUTE) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.lockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.lockAtTimeString) + } + + @Test + fun `Update unlock at date`() { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, -1) + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateUnlockAt() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + (event as UpdateFileEvent.ShowDatePicker).callback( + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.unlockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Update unlock at time`() { + val calendar = Calendar.getInstance() + + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = + ModuleContentDetails(hidden = false, locked = false, unlockAt = calendar.time.toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateUnlockTime() + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowTimePicker) + + calendar.add(Calendar.HOUR_OF_DAY, 1) + calendar.add(Calendar.MINUTE, 1) + (event as UpdateFileEvent.ShowTimePicker).callback( + calendar.get(Calendar.HOUR_OF_DAY), + calendar.get(Calendar.MINUTE) + ) + + assertEquals(dateFormat.format(calendar.time), viewModel.data.value?.unlockAtDateString) + assertEquals(timeFormat.format(calendar.time), viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Clear lock time`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, lockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.clearLockDate() + + assertNull(viewModel.data.value?.lockAt) + assertNull(viewModel.data.value?.lockAtDateString) + assertNull(viewModel.data.value?.lockAtTimeString) + } + + @Test + fun `Clear unlock time`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.clearUnlockDate() + + assertNull(viewModel.data.value?.unlockAt) + assertNull(viewModel.data.value?.unlockAtDateString) + assertNull(viewModel.data.value?.unlockAtTimeString) + } + + @Test + fun `Hiding file calls api with correct params`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.HIDDEN) + + viewModel.update() + + coVerify { + fileApi.updateFile( + 1L, + UpdateFileFolder( + hidden = true, + unlockAt = "", + lockAt = "", + locked = false, + visibilityLevel = FileVisibility.PUBLIC.name.lowercase() + ), + any() + ) + } + } + + @Test + fun `Unpublishing file calls api with correct params`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.PUBLIC.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = false, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.UNPUBLISHED) + + viewModel.update() + + coVerify { + fileApi.updateFile( + 1L, + UpdateFileFolder( + hidden = false, + unlockAt = "", + lockAt = "", + locked = true, + visibilityLevel = FileVisibility.PUBLIC.name.lowercase() + ), + any() + ) + } + } + + @Test + fun `Publishing file calls api with correct params`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.PUBLISHED) + + viewModel.update() + + coVerify { + fileApi.updateFile( + 1L, + UpdateFileFolder( + hidden = false, + unlockAt = "", + lockAt = "", + locked = false, + visibilityLevel = FileVisibility.CONTEXT.name.lowercase() + ), + any() + ) + } + } + + @Test + fun `Updating file emits correct events`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Success(file) + + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.PUBLISHED) + + viewModel.update() + + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.Close) + verify { + eventBus.post(any()) + } + } + + @Test + fun `Update error sets correct view state`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + coEvery { fileApi.updateFile(any(), any(), any()) } returns DataResult.Fail() + + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.onAvailabilityChanged(FileAvailability.PUBLISHED) + + viewModel.update() + + assert(viewModel.state.value is ViewState.Error) + } + + @Test + fun `minDate is set if unlockDate is not null`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateLockAt() + + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + assertEquals(contentDetails.unlockDate, (event as UpdateFileEvent.ShowDatePicker).minDate) + } + + @Test + fun `maxDate is set if lockDate is not null`() { + val file = FileFolder(id = 1L, visibilityLevel = FileVisibility.CONTEXT.name.lowercase()) + coEvery { fileApi.getFile(any(), any()) } returns DataResult.Success(file) + val contentDetails = ModuleContentDetails(hidden = false, locked = true, unlockAt = Date().toApiString()) + + every { savedStateHandle.get("contentId") } returns 1L + every { savedStateHandle.get("contentDetails") } returns contentDetails + + viewModel = createViewModel() + + viewModel.updateUnlockAt() + + val event = viewModel.events.value?.getContentIfNotHandled() + assert(event is UpdateFileEvent.ShowDatePicker) + assertEquals(contentDetails.lockDate, (event as UpdateFileEvent.ShowDatePicker).maxDate) + } + + private fun createViewModel() = UpdateFileViewModel(savedStateHandle, context, fileApi, eventBus) +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt new file mode 100644 index 0000000000..2b29b797f8 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.espresso.matchers + +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.appcompat.content.res.AppCompatResources +import org.hamcrest.TypeSafeMatcher + +class WithDrawableViewMatcher(@DrawableRes private val drawable: Int, @ColorRes private val tint: Int? = null) : TypeSafeMatcher() { + + override fun matchesSafely(view: ImageView): Boolean { + val drawableMatcher = view.drawable != null && view.drawable.constantState?.newDrawable()?.constantState?.hashCode() == AppCompatResources.getDrawable( + view.context, + drawable + )?.constantState?.hashCode() + + val tintMatcher = tint != null && view.imageTintList?.defaultColor == AppCompatResources.getColorStateList( + view.context, + tint + )?.defaultColor + + return drawableMatcher && (tintMatcher || tint == null) + } + + override fun describeTo(description: org.hamcrest.Description) { + description.appendText("with drawable and tint") + } +} \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt index cf85789cdc..fe61a10dd4 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt @@ -98,6 +98,9 @@ object FileFolderAPI { @PUT("files/{fileId}?include[]=usage_rights") fun updateFile(@Path("fileId") fileId: Long, @Body updateFileFolder: UpdateFileFolder): Call + @PUT("files/{fileId}?include[]=usage_rights") + suspend fun updateFile(@Path("fileId") fileId: Long, @Body updateFileFolder: UpdateFileFolder, @Tag params: RestParams): DataResult + @POST("folders/{folderId}/folders") fun createFolder(@Path("folderId") folderId: Long, @Body newFolderName: CreateFolder): Call @@ -119,6 +122,9 @@ object FileFolderAPI { @GET("files/{fileNumber}?include=avatar") fun getAvatarFileToken(@Path("fileNumber") fileNumber: String): Call + + @GET("files/{fileId}") + suspend fun getFile(@Path("fileId") fileId: Long, @Tag params: RestParams): DataResult } fun getFileFolderFromURL(adapter: RestBuilder, url: String, callback: StatusCallback, params: RestParams) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt index b3b9129f92..04f55a7d98 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ModuleAPI.kt @@ -83,6 +83,9 @@ object ModuleAPI { @GET("{contextId}/modules/{moduleId}/items/{itemId}?include[]=content_details") fun getModuleItem(@Path("contextId") contextId: Long, @Path("moduleId") moduleId: Long, @Path("itemId") itemId: Long) : Call + @GET("{contextType}/{contextId}/modules/{moduleId}/items/{itemId}?include[]=content_details") + suspend fun getModuleItem(@Path("contextType") contextType: String, @Path("contextId") contextId: Long, @Path("moduleId") moduleId: Long, @Path("itemId") itemId: Long, @Tag params: RestParams) : DataResult + @PUT("{contextType}/{contextId}/modules") suspend fun bulkUpdateModules(@Path("contextType") contextType: String, @Path("contextId") contextId: Long, @Query("module_ids[]") moduleIds: List, @Query("event") event: String, @Query("skip_content_tags") skipContentTags: Boolean, @Query("async") async: Boolean, @Tag params: RestParams): DataResult diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt index 159e04fc24..324699cd25 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CreateFolder.kt @@ -33,7 +33,9 @@ data class UpdateFileFolder( @SerializedName("parent_folder_id") var parentFolderId: Long? = null, // Used for Files @SerializedName("on_duplicate") - var onDuplicate: String? = null // Used for files - "overwrite" or "rename" + var onDuplicate: String? = null, // Used for files - "overwrite" or "rename" + @SerializedName("visibility_level") + val visibilityLevel: String? = null ) diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt index d520c2ebb5..c65f9aa223 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/FileFolder.kt @@ -27,65 +27,67 @@ import java.util.Locale @Parcelize data class FileFolder( - // Common Attributes - override val id: Long = 0, - @SerializedName("created_at") - val createdDate: Date? = null, - @SerializedName("updated_at") - val updatedDate: Date? = null, - @SerializedName("unlock_at") - var unlockDate: Date? = null, - @SerializedName("lock_at") - var lockDate: Date? = null, - @SerializedName("locked") - var isLocked: Boolean = false, - @SerializedName("hidden") - var isHidden: Boolean = false, - @SerializedName("locked_for_user") - val isLockedForUser: Boolean = false, - @SerializedName("hidden_for_user") - val isHiddenForUser: Boolean = false, + // Common Attributes + override val id: Long = 0, + @SerializedName("created_at") + val createdDate: Date? = null, + @SerializedName("updated_at") + val updatedDate: Date? = null, + @SerializedName("unlock_at") + var unlockDate: Date? = null, + @SerializedName("lock_at") + var lockDate: Date? = null, + @SerializedName("locked") + var isLocked: Boolean = false, + @SerializedName("hidden") + var isHidden: Boolean = false, + @SerializedName("locked_for_user") + val isLockedForUser: Boolean = false, + @SerializedName("hidden_for_user") + val isHiddenForUser: Boolean = false, - // File Attributes - @SerializedName("folder_id") - val folderId: Long = 0, - val size: Long = 0, - @SerializedName("content-type") - val contentType: String? = null, - val url: String? = null, - @SerializedName("display_name") - val displayName: String? = null, - @SerializedName("thumbnail_url") - val thumbnailUrl: String? = null, - @SerializedName("lock_info") - val lockInfo: LockInfo? = null, + // File Attributes + @SerializedName("folder_id") + val folderId: Long = 0, + val size: Long = 0, + @SerializedName("content-type") + val contentType: String? = null, + val url: String? = null, + @SerializedName("display_name") + val displayName: String? = null, + @SerializedName("thumbnail_url") + val thumbnailUrl: String? = null, + @SerializedName("lock_info") + val lockInfo: LockInfo? = null, - // Folder Attributes - @SerializedName("parent_folder_id") - val parentFolderId: Long = 0, - @SerializedName("context_id") - val contextId: Long = 0, - @SerializedName("files_count") - val filesCount: Int = 0, - val position: Int = 0, - @SerializedName("folders_count") - val foldersCount: Int = 0, - @SerializedName("context_type") - val contextType: String? = null, - val name: String? = null, - @SerializedName("folders_url") - val foldersUrl: String? = null, - @SerializedName("files_url") - val filesUrl: String? = null, - @SerializedName("full_name") - val fullName: String? = null, - @SerializedName("usage_rights") - var usageRights: UsageRights? = null, - @SerializedName("for_submissions") - var forSubmissions: Boolean = false, // Only for folders - @SerializedName("can_upload") - val canUpload: Boolean = false, - var avatar: Avatar? = null // Used to get a file token to update avatars with vanity URLs + // Folder Attributes + @SerializedName("parent_folder_id") + val parentFolderId: Long = 0, + @SerializedName("context_id") + val contextId: Long = 0, + @SerializedName("files_count") + val filesCount: Int = 0, + val position: Int = 0, + @SerializedName("folders_count") + val foldersCount: Int = 0, + @SerializedName("context_type") + val contextType: String? = null, + val name: String? = null, + @SerializedName("folders_url") + val foldersUrl: String? = null, + @SerializedName("files_url") + val filesUrl: String? = null, + @SerializedName("full_name") + val fullName: String? = null, + @SerializedName("usage_rights") + var usageRights: UsageRights? = null, + @SerializedName("for_submissions") + var forSubmissions: Boolean = false, // Only for folders + @SerializedName("can_upload") + val canUpload: Boolean = false, + var avatar: Avatar? = null, // Used to get a file token to update avatars with vanity URLs + @SerializedName("visibility_level") + val visibilityLevel: String? = null ) : CanvasModel() { val isRoot: Boolean get() = parentFolderId == 0L val isFile: Boolean get() = !displayName.isNullOrBlank() @@ -102,12 +104,20 @@ data class FileFolder( /* We override compareTo instead of using Canvas Comparable methods */ override fun compareTo(other: FileFolder) = compareFiles(this, other) - private fun compareFiles(file1: FileFolder, file2: FileFolder): Int{ + private fun compareFiles(file1: FileFolder, file2: FileFolder): Int { return when { (file1.fullName == null && file2.fullName != null) -> 1 (file1.fullName != null && file2.fullName == null) -> -1 - (file1.fullName != null && file2.fullName != null) -> NaturalOrderComparator.compare(file1.fullName.lowercase(Locale.getDefault()), file2.fullName.lowercase(Locale.getDefault())) - else -> NaturalOrderComparator.compare(file1.displayName?.lowercase(Locale.getDefault()), file2.displayName?.lowercase(Locale.getDefault())) + (file1.fullName != null && file2.fullName != null) -> NaturalOrderComparator.compare( + file1.fullName.lowercase( + Locale.getDefault() + ), file2.fullName.lowercase(Locale.getDefault()) + ) + + else -> NaturalOrderComparator.compare( + file1.displayName?.lowercase(Locale.getDefault()), + file2.displayName?.lowercase(Locale.getDefault()) + ) } } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt index 6a282f2979..30e9f31b18 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/ModuleContentDetails.kt @@ -25,25 +25,29 @@ import java.util.* @Parcelize data class ModuleContentDetails( - @SerializedName("points_possible") - val pointsPossible: String? = null, - @SerializedName("due_at") - val dueAt: String? = null, - @SerializedName("unlock_at") - val unlockAt: String? = null, - @SerializedName("lock_at") - val lockAt: String? = null, - @SerializedName("locked_for_user") - val lockedForUser: Boolean = false, - @SerializedName("lock_explanation") - val lockExplanation: String? = null, - @SerializedName("lock_info") - val lockInfo: LockInfo? = null + @SerializedName("points_possible") + val pointsPossible: String? = null, + @SerializedName("due_at") + val dueAt: String? = null, + @SerializedName("unlock_at") + val unlockAt: String? = null, + @SerializedName("lock_at") + val lockAt: String? = null, + @SerializedName("locked_for_user") + val lockedForUser: Boolean = false, + @SerializedName("lock_explanation") + val lockExplanation: String? = null, + @SerializedName("lock_info") + val lockInfo: LockInfo? = null, + val hidden: Boolean? = null, + val locked: Boolean? = null ) : CanvasComparable() { @IgnoredOnParcel val dueDate: Date? get() = dueAt.toDate() + @IgnoredOnParcel val unlockDate: Date? get() = unlockAt.toDate() + @IgnoredOnParcel val lockDate: Date? get() = lockAt.toDate() } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt index a54ae08819..d5b30749b0 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/DateHelper.kt @@ -88,7 +88,8 @@ object DateHelper { fun getFormattedTime(context: Context?, date: Date?): String? { - return context?.let { getPreferredTimeFormat(it).format(date) } + if (context == null || date == null) return null + return getPreferredTimeFormat(context).format(date) } fun createPrefixedDateString(context: Context?, prefix: String, date: Date?): String? { diff --git a/libs/pandares/src/main/res/drawable/ic_calendar_month.xml b/libs/pandares/src/main/res/drawable/ic_calendar_month.xml new file mode 100644 index 0000000000..a656223aec --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_calendar_month.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_eye_off.xml b/libs/pandares/src/main/res/drawable/ic_eye_off.xml new file mode 100644 index 0000000000..bfb6d91deb --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_eye_off.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 883668d91e..d46b460d57 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1512,6 +1512,8 @@ This will make all modules and items visible to students. This will make only the modules visible to students. This will make all modules and items invisible to students. + Only available with link + Schedule availability Item published Item unpublished Only Module published @@ -1520,6 +1522,22 @@ Only Modules published All Modules and all Items published All Modules and all Items unpublished + Inherit from Course + Course Members + Institutions Members + Public + Edit Permissions + Update + Availability + Visibility + Available From + Available Until + From + Until + Date + Time + Clear From Date + Clear Until Date %.0f pt %.0f pts diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt index 4db7dad41e..c83f680874 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/binding/BindingAdapters.kt @@ -99,7 +99,7 @@ private fun handleErrorState(emptyView: EmptyView, error: ViewState.Error) { emptyView.setGone() } else { emptyView.setVisible() - emptyView.setError(error.errorMessage) + emptyView.setError(error.errorMessage, error.errorImage) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt index d05898c8f1..7f88d0a4ce 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/DatePickerDialogFragment.kt @@ -25,6 +25,7 @@ import androidx.appcompat.app.AppCompatDialogFragment import androidx.fragment.app.FragmentManager import com.instructure.pandautils.analytics.SCREEN_VIEW_DATE_PICKER import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.utils.NullableSerializableArg import com.instructure.pandautils.utils.SerializableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.dismissExisting @@ -34,19 +35,23 @@ import kotlin.properties.Delegates @ScreenView(SCREEN_VIEW_DATE_PICKER) class DatePickerDialogFragment : AppCompatDialogFragment(), DatePickerDialog.OnDateSetListener { - var mCallback: (year: Int, month: Int, dayOfMonth: Int) -> Unit by Delegates.notNull() - var mDefaultDate by SerializableArg(Date()) + var callback: (year: Int, month: Int, dayOfMonth: Int) -> Unit by Delegates.notNull() + var defaultDate by SerializableArg(Date()) + var minDate by NullableSerializableArg() + var maxDate by NullableSerializableArg() override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { // Setup default date val c = Calendar.getInstance() - c.time = mDefaultDate + c.time = defaultDate val year = c.get(Calendar.YEAR) val month = c.get(Calendar.MONTH) val day = c.get(Calendar.DAY_OF_MONTH) val dialog = DatePickerDialog(requireContext(), this, year, month, day) + minDate?.let { dialog.datePicker.minDate = it.time } + maxDate?.let { dialog.datePicker.maxDate = it.time } dialog.setOnShowListener { dialog.getButton(AppCompatDialog.BUTTON_POSITIVE).setTextColor(ThemePrefs.textButtonColor) @@ -57,15 +62,17 @@ class DatePickerDialogFragment : AppCompatDialogFragment(), DatePickerDialog.OnD } override fun onDateSet(view: DatePicker?, year: Int, month: Int, dayOfMonth: Int) { - mCallback(year, month, dayOfMonth) + callback(year, month, dayOfMonth) } companion object { - fun getInstance(manager: FragmentManager, defaultDate: Date? = null, callback: (Int, Int, Int) -> Unit) : DatePickerDialogFragment { + fun getInstance(manager: FragmentManager, defaultDate: Date? = null, minDate: Date? = null, maxDate: Date? = null, callback: (Int, Int, Int) -> Unit) : DatePickerDialogFragment { manager.dismissExisting() val dialog = DatePickerDialogFragment() - dialog.mCallback = callback - defaultDate?.let { dialog.mDefaultDate = it } + dialog.callback = callback + defaultDate?.let { dialog.defaultDate = it } + minDate?.let { dialog.minDate = it } + maxDate?.let { dialog.maxDate = it } return dialog } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt index 748f133f3d..95dfaa04ce 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/mvvm/ViewState.kt @@ -31,7 +31,7 @@ sealed class ViewState { object Refresh : ViewState() object LoadingNextPage : ViewState() data class Empty(@StringRes val emptyTitle: Int? = null, @StringRes val emptyMessage: Int? = null, @DrawableRes val emptyImage: Int? = null) : ViewState() - data class Error(val errorMessage: String = "") : ViewState() + data class Error(val errorMessage: String = "", @DrawableRes val errorImage: Int? = null) : ViewState() fun isInLoadingState(): Boolean { return this is Loading || this is Refresh || this is LoadingNextPage diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt index 503c3c682d..402c781647 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/views/EmptyView.kt @@ -25,6 +25,7 @@ import android.view.View import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat @@ -172,11 +173,16 @@ class EmptyView @JvmOverloads constructor( } } - fun setError(errorMessage: String) = with(binding) { + fun setError(errorMessage: String, @DrawableRes errorImage: Int?) = with(binding) { title.setVisible() - image.setGone() + if (errorImage != null) { + image.setImageResource(errorImage) + image.setVisible() + } else { + centerTitle() + image.setGone() + } loading.root.setGone() - centerTitle() titleText = errorMessage messageText = "" From 75d5001a1602b51a9712a45123f06d30fc5cc7f1 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Thu, 8 Feb 2024 10:02:14 +0100 Subject: [PATCH 20/52] [Student][Teacher] Add compose dependencies (#2333) --- apps/student/build.gradle | 1 + .../student/activity/NavigationActivity.kt | 2 +- .../navigation/DefaultNavigationBehavior.kt | 6 +- .../ElementaryNavigationBehavior.kt | 6 +- .../student/navigation/NavigationBehavior.kt | 4 +- .../teacher/activities/InitActivity.kt | 2 +- buildSrc/src/main/java/GlobalDependencies.kt | 8 +++ .../src/main/res/font/balsamiq_regular.ttf | Bin 0 -> 389784 bytes libs/pandautils/build.gradle | 17 ++++++ .../pandautils/compose/CanvasTheme.kt | 53 ++++++++++++++++++ .../pandautils/typeface/TypefaceBehavior.kt | 11 +++- .../utils/{FontFamily.kt => CanvasFont.kt} | 8 ++- 12 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 libs/pandares/src/main/res/font/balsamiq_regular.ttf create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt rename libs/pandautils/src/main/java/com/instructure/pandautils/utils/{FontFamily.kt => CanvasFont.kt} (75%) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index 5552a9bf58..b2f2ce47d7 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -240,6 +240,7 @@ android { hilt { enableTransformForLocalTests = true enableAggregatingTask = false + enableExperimentalClasspathAggregation = true } } diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index e104fe1432..e5be625fff 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -727,7 +727,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. override fun overrideFont() { super.overrideFont() - typefaceBehavior.overrideFont(navigationBehavior.fontFamily.fontPath) + typefaceBehavior.overrideFont(navigationBehavior.canvasFont) } //endregion diff --git a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt index 49bb171205..558b7761ba 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt @@ -20,7 +20,7 @@ import androidx.fragment.app.Fragment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route -import com.instructure.pandautils.utils.FontFamily +import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R import com.instructure.student.fragment.CalendarFragment import com.instructure.student.fragment.DashboardFragment @@ -46,8 +46,8 @@ class DefaultNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBeha override val visibleAccountMenuItems: Set = setOf(AccountMenuItem.HELP, AccountMenuItem.CHANGE_USER, AccountMenuItem.LOGOUT) - override val fontFamily: FontFamily - get() = FontFamily.REGULAR + override val canvasFont: CanvasFont + get() = CanvasFont.REGULAR override val bottomBarMenu: Int = R.menu.bottom_bar_menu diff --git a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt index 37bbbfa2ea..9c36505bea 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt @@ -20,7 +20,7 @@ import androidx.fragment.app.Fragment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route -import com.instructure.pandautils.utils.FontFamily +import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R import com.instructure.student.fragment.CalendarFragment import com.instructure.student.fragment.NotificationListFragment @@ -46,8 +46,8 @@ class ElementaryNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationB override val visibleAccountMenuItems: Set = setOf(AccountMenuItem.HELP, AccountMenuItem.CHANGE_USER, AccountMenuItem.LOGOUT) - override val fontFamily: FontFamily - get() = FontFamily.K5 + override val canvasFont: CanvasFont + get() = CanvasFont.K5 override val bottomBarMenu: Int = R.menu.bottom_bar_menu_elementary diff --git a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt index e650131e0b..7ac1999403 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt @@ -22,7 +22,7 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route import com.instructure.pandautils.features.inbox.list.InboxFragment -import com.instructure.pandautils.utils.FontFamily +import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.activity.NothingToSeeHereFragment import com.instructure.student.fragment.ParentFragment @@ -39,7 +39,7 @@ interface NavigationBehavior { val visibleAccountMenuItems: Set - val fontFamily: FontFamily + val canvasFont: CanvasFont @get:MenuRes val bottomBarMenu: Int diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt index a00a2671b8..0e550b3576 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt @@ -167,7 +167,7 @@ class InitActivity : BasePresenterActivity On Create") val masqueradingUserId: Long = intent.getLongExtra(Const.QR_CODE_MASQUERADE_ID, 0L) diff --git a/buildSrc/src/main/java/GlobalDependencies.kt b/buildSrc/src/main/java/GlobalDependencies.kt index 287dfe6e02..a5ec07d941 100644 --- a/buildSrc/src/main/java/GlobalDependencies.kt +++ b/buildSrc/src/main/java/GlobalDependencies.kt @@ -18,6 +18,7 @@ object Versions { /* Kotlin */ const val KOTLIN = "1.9.20" const val KOTLIN_COROUTINES = "1.6.4" + const val KOTLIN_COMPOSE_COMPILER_VERSION = "1.5.4" /* Google, Play Services */ const val GOOGLE_SERVICES = "4.3.15" @@ -161,6 +162,13 @@ object Libs { const val ROOM_TEST = "androidx.room:room-testing:${Versions.ROOM}" const val HAMCREST = "org.hamcrest:hamcrest:${Versions.HAMCREST}" + + // Compose + const val COMPOSE_BOM = "androidx.compose:compose-bom:2023.10.01" + const val COMPOSE_MATERIAL = "androidx.compose.material:material" + const val COMPOSE_PREVIEW = "androidx.compose.ui:ui-tooling-preview" + const val COMPOSE_TOOLING = "androidx.compose.ui:ui-tooling" + const val COMPOSE_VIEW_MODEL = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1" } object Plugins { diff --git a/libs/pandares/src/main/res/font/balsamiq_regular.ttf b/libs/pandares/src/main/res/font/balsamiq_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5f3febff076214c6b0b59c574965fd486204c603 GIT binary patch literal 389784 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

Regular Page Text

") + val pagePublished = PagesApi.createCoursePage(course.id, teacher.token, editingRoles = "teachers,students", body = "

Regular Page Text

") Log.d(PREPARATION_TAG,"Seed a PUBLISHED, but NOT editable page for '${course.name}' course.") - val pageNotEditable = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") + val pageNotEditable = PagesApi.createCoursePage(course.id, teacher.token, body = "

Regular Page Text

") Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for '${course.name}' course.") - val pagePublishedFront = createCoursePage(course, teacher, published = true, frontPage = true, editingRoles = "public", body = "

Front Page Text

") + val pagePublishedFront = PagesApi.createCoursePage(course.id, teacher.token, frontPage = true, editingRoles = "public", body = "

Front Page Text

") Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) @@ -137,20 +134,4 @@ class PagesE2ETest: StudentTest() { Log.d(STEP_TAG, "Assert that the new, edited text is displayed in the page body.") canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text Mod")) } - - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel, - published: Boolean, - frontPage: Boolean, - editingRoles: String? = null, - body: String = Randomizer.randomPageBody() - ) = PagesApi.createCoursePage( - courseId = course.id, - published = published, - frontPage = frontPage, - editingRoles = editingRoles, - token = teacher.token, - body = body - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt index 89709d0222..4a05b67783 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt @@ -51,7 +51,7 @@ class PeopleE2ETest : StudentTest() { tokenLogin(student1) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Navigate to ${course.name} course's People Page.") + Log.d(STEP_TAG,"Navigate to '${course.name}' course's People Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectPeople() @@ -70,7 +70,7 @@ class PeopleE2ETest : StudentTest() { peopleListPage.assertPersonListed(student2) peopleListPage.assertPeopleCount(3) - Log.d(STEP_TAG,"Select ${student2.name} student and assert if we are landing on the Person Details Page.") + Log.d(STEP_TAG,"Select ${student2.name} student and assert if we are landing on the Person Details Page. Assert that the Person Details page's information (user name, role, and picture) are displayed.") peopleListPage.selectPerson(student2) personDetailsPage.assertPageObjects() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt index f6094a40ba..e1073754a1 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt @@ -36,9 +36,6 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.isElementDisplayed import com.instructure.dataseeding.api.QuizzesApi -import com.instructure.dataseeding.api.QuizzesApi.createAndPublishQuiz -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.QuizAnswer import com.instructure.dataseeding.model.QuizQuestion import com.instructure.student.R @@ -57,7 +54,7 @@ class QuizzesE2ETest: StudentTest() { override fun enableAndConfigureAccessibilityChecks() = Unit - // Fairly basic test of webview-based quizzes. Seeds/takes a quiz with two multiple-choice + // Fairly basic test of web view-based quizzes. Seeds/takes a quiz with two multiple-choice // questions. // // STUBBING THIS OUT. Usually passes locally, but I can't get a simple webClick() to work on FTL. @@ -75,13 +72,13 @@ class QuizzesE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course.") - val quizUnpublished = createQuiz(course, teacher, withDescription = true, published = false) + val quizUnpublished = QuizzesApi.createQuiz(course.id, teacher.token, published = false) Log.d(PREPARATION_TAG,"Seed another quiz for ${course.name} with some questions.") val quizQuestions = makeQuizQuestions() Log.d(PREPARATION_TAG,"Publish the previously seeded quiz.") - val quizPublished = createAndPublishQuiz(course.id, teacher.token, quizQuestions) + val quizPublished = QuizzesApi.createAndPublishQuiz(course.id, teacher.token, quizQuestions) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -181,6 +178,7 @@ class QuizzesE2ETest: StudentTest() { Log.d(STEP_TAG,"Select ${quizPublished.title} quiz.") quizListPage.selectQuiz(quizPublished) + sleep(5000) Log.d(STEP_TAG,"Assert (on web) that the ${quizPublished.title} quiz now has a history.") onWebView(withId(R.id.contentWebView)) .withElement(findElement(Locator.ID, "quiz-submission-version-table")) @@ -204,20 +202,6 @@ class QuizzesE2ETest: StudentTest() { } - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel, - withDescription: Boolean, - published: Boolean, - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = withDescription, - published = published, - token = teacher.token - ) - ) - private fun makeQuizQuestions() = listOf( QuizQuestion( questionText = "What's your favorite color?", diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt index babe1f110d..ca431ea2ab 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SettingsE2ETest.kt @@ -66,10 +66,10 @@ class SettingsE2ETest : StudentTest() { profileSettingsPage.assertPageObjects() val newUserName = "John Doe" - Log.d(STEP_TAG, "Edit username to: $newUserName. Click on 'Save' button.") + Log.d(STEP_TAG, "Edit username to: '$newUserName'. Click on 'Save' button.") profileSettingsPage.changeUserNameTo(newUserName) - Log.d(STEP_TAG, "Navigate back to Dashboard Page. Assert that the username has been changed to $newUserName.") + Log.d(STEP_TAG, "Navigate back to Dashboard Page. Assert that the username has been changed to '$newUserName'.") ViewUtils.pressBackButton(2) leftSideNavigationDrawerPage.assertUserLoggedIn(newUserName) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt index 8df03a41d9..0d611d4474 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt @@ -25,9 +25,6 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import com.instructure.canvas.espresso.E2E import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days @@ -61,11 +58,11 @@ class ShareExtensionE2ETest: StudentTest() { val course = data.coursesList[0] val teacher = data.teachersList[0] - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val testAssignmentOne = createAssignment(course, teacher, 1.days.fromNow.iso8601, 15.0) + Log.d(PREPARATION_TAG,"Seeding 'File upload' assignment for ${course.name} course.") + val testAssignmentOne = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD)) - Log.d(PREPARATION_TAG,"Seeding another 'Text Entry' assignment for ${course.name} course.") - createAssignment(course, teacher, 1.days.fromNow.iso8601, 30.0) + Log.d(PREPARATION_TAG,"Seeding another 'File upload' assignment for ${course.name} course.") + AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 30.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD)) Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @@ -205,24 +202,6 @@ class ShareExtensionE2ETest: StudentTest() { fileListPage.assertItemDisplayed(jpgTestFileName) } - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel, - dueAt: String, - pointsPossible: Double - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt - ) - ) - } - private fun shareMultipleFiles(uris: ArrayList) { val intent = Intent().apply { action = Intent.ACTION_SEND_MULTIPLE diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt index 8db435bf9b..245e3c135d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt @@ -24,8 +24,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -55,9 +53,9 @@ class SyllabusE2ETest: StudentTest() { Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) - dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Wait for the Dashboard Page to be rendered. Select '${course.name}' course.") + dashboardPage.waitForRender() dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to Syllabus Page. Assert that the syllabus body string is displayed, and there are no tabs yet.") @@ -65,42 +63,16 @@ class SyllabusE2ETest: StudentTest() { syllabusPage.assertNoTabs() syllabusPage.assertSyllabusBody("this is the syllabus body") - Log.d(PREPARATION_TAG,"Seed an assignment for ${course.name} course.") - val assignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, submissionTypes = listOf(SubmissionType.ON_PAPER), withDescription = true, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601) - Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course.") - val quiz = createQuiz(course, teacher) + Log.d(PREPARATION_TAG,"Seed a quiz for '${course.name}' course.") + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 2.days.fromNow.iso8601) - Log.d(STEP_TAG,"Refresh the page. Navigate to 'Summary' tab. Assert that all of the items, so ${assignment.name} assignment and ${quiz.title} quiz are displayed.") + Log.d(STEP_TAG,"Refresh the page. Navigate to 'Summary' tab. Assert that all of the items, so '${assignment.name}' assignment and '${quiz.title}' quiz are displayed.") syllabusPage.refresh() syllabusPage.selectSummaryTab() syllabusPage.assertItemDisplayed(assignment.name) syllabusPage.assertItemDisplayed(quiz.title) } - - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - published = true, - token = teacher.token, - dueAt = 2.days.fromNow.iso8601 - ) - ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - teacherToken = teacher.token, - submissionTypes = listOf(SubmissionType.ON_PAPER), - dueAt = 1.days.fromNow.iso8601, - withDescription = true - ) - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt index 32b3540313..08e00b02e0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt @@ -3,23 +3,20 @@ package com.instructure.student.ui.e2e import android.util.Log import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.espresso.retry -import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData +import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedAssignments import com.instructure.student.ui.utils.seedData @@ -46,10 +43,10 @@ class TodoE2ETest: StudentTest() { val course = data.coursesList[0] val favoriteCourse = data.coursesList[1] - Log.d(PREPARATION_TAG,"Seed an assignment for ${course.name} course with tomorrow due date.") - val testAssignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${course.name}' course with tomorrow due date.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seed another assignment for ${course.name} course with 7 days from now due date.") + Log.d(PREPARATION_TAG,"Seed another assignment for '${course.name}' course with 7 days from now due date.") val seededAssignments2 = seedAssignments( courseId = course.id, teacherToken = teacher.token, @@ -58,38 +55,35 @@ class TodoE2ETest: StudentTest() { val borderDateAssignment = seededAssignments2[0] //We show items in the to do section which are within 7 days. - Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course with tomorrow due date.") - val quiz = createQuiz(course, teacher, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seed a quiz for '${course.name}' course with tomorrow due date.") + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 1.days.fromNow.iso8601) - Log.d(PREPARATION_TAG,"Seed another quiz for ${course.name} course with 8 days from now due date..") - val tooFarAwayQuiz = createQuiz(course, teacher, 8.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seed another quiz for '${course.name}' course with 8 days from now due date..") + val tooFarAwayQuiz = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 8.days.fromNow.iso8601) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) - dashboardPage.waitForRender() - Log.d(STEP_TAG,"Navigate to 'To Do' Page via bottom-menu.") + Log.d(STEP_TAG,"Wait for the Dashboard Page to be rendered. Navigate to 'To Do' Page via bottom-menu.") + dashboardPage.waitForRender() dashboardPage.clickTodoTab() - Log.d(STEP_TAG,"Assert that ${testAssignment.name} assignment is displayed and ${borderDateAssignment.name} is displayed because it's 7 days away from now..") - Log.d(STEP_TAG,"Assert that ${quiz.title} quiz is displayed and ${tooFarAwayQuiz.title} quiz is not displayed because it's end date is more than a week away..") - retry(times = 5, delay = 3000, catchBlock = { refresh() } ) { + Log.d(STEP_TAG,"Assert that '${testAssignment.name}' assignment is displayed and '${borderDateAssignment.name}' assignment is displayed because it's 7 days away from now.") + Log.d(STEP_TAG,"Assert that '${quiz.title}' quiz is displayed and '${tooFarAwayQuiz.title}' quiz is not displayed because it's end date is more than a week away.") + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { refresh() } ) { todoPage.assertAssignmentDisplayed(testAssignment) todoPage.assertAssignmentDisplayed(borderDateAssignment) todoPage.assertQuizDisplayed(quiz) todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) } - Log.d(PREPARATION_TAG,"Submit ${testAssignment.name} assignment for ${student.name} student.") - SubmissionsApi.seedAssignmentSubmission(SubmissionsApi.SubmissionSeedRequest( - assignmentId = testAssignment.id, - courseId = course.id, - studentToken = student.token, + Log.d(PREPARATION_TAG,"Submit' ${testAssignment.name}' assignment for ${student.name} student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, testAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY )) - )) + ) Log.d(STEP_TAG, "Refresh the 'To Do' Page.") refresh() @@ -115,10 +109,10 @@ class TodoE2ETest: StudentTest() { todoPage.assertAssignmentNotDisplayed(testAssignment) todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) - Log.d(PREPARATION_TAG,"Seed an assignment for ${favoriteCourse.name} course with tomorrow due date.") - val favoriteCourseAssignment = createAssignment(favoriteCourse, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${favoriteCourse.name}' course with tomorrow due date.") + val favoriteCourseAssignment = AssignmentsApi.createAssignment(favoriteCourse.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Navigate back to the Dashboard Page. Open ${favoriteCourse.name} course. Mark it as favorite.") + Log.d(STEP_TAG, "Navigate back to the Dashboard Page. Open '${favoriteCourse.name}' course. Mark it as favorite.") Espresso.pressBack() dashboardPage.openAllCoursesPage() allCoursesPage.favoriteCourse(favoriteCourse.name) @@ -141,34 +135,4 @@ class TodoE2ETest: StudentTest() { todoPage.assertQuizNotDisplayed(quiz) todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) } - - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel, - dueAt: String - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - published = true, - token = teacher.token, - dueAt = dueAt - ) - ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = 15.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt index 088b6e0858..6714978284 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/GradesElementaryE2ETest.kt @@ -21,16 +21,17 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData -import com.instructure.canvasapi2.utils.toApiString import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.GradingPeriodsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.iso8601 import com.instructure.espresso.page.getStringFromResource import com.instructure.student.R import com.instructure.student.ui.pages.ElementaryDashboardPage @@ -39,17 +40,17 @@ import com.instructure.student.ui.utils.seedDataForK5 import com.instructure.student.ui.utils.tokenLoginElementary import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test -import java.util.* @HiltAndroidTest class GradesElementaryE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.K5_GRADES) fun gradesE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -65,20 +66,19 @@ class GradesElementaryE2ETest : StudentTest() { val student = data.studentsList[0] val teacher = data.teachersList[0] val nonHomeroomCourses = data.coursesList.filter { !it.homeroomCourse } - val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val testGradingPeriodListApiModel = GradingPeriodsApi.getGradingPeriodsOfCourse(nonHomeroomCourses[0].id) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${nonHomeroomCourses[1].name} course.") - val testAssignment = createAssignment(nonHomeroomCourses[1].id, teacher, calendar, GradingType.PERCENT, 100.0) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${nonHomeroomCourses[1].name}' course.") + val testAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[1].id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 100.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seeding another 'Text Entry' assignment for ${nonHomeroomCourses[0].name} course.") - val testAssignment2 = createAssignment(nonHomeroomCourses[0].id, teacher, calendar, GradingType.LETTER_GRADE, 100.0) + Log.d(PREPARATION_TAG,"Seeding another 'Text Entry' assignment for '${nonHomeroomCourses[0].name}' course.") + val testAssignment2 = AssignmentsApi.createAssignment(nonHomeroomCourses[0].id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 100.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${nonHomeroomCourses[1].name} assignment.") - gradeSubmission(teacher,nonHomeroomCourses[1].id, student, testAssignment.id, "9") + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${nonHomeroomCourses[1].name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, nonHomeroomCourses[1].id, testAssignment.id, student.id, postedGrade = "9") - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${nonHomeroomCourses[0].name} assignment.") - gradeSubmission(teacher, nonHomeroomCourses[0].id, student, testAssignment2.id, "A-") + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${nonHomeroomCourses[0].name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, nonHomeroomCourses[0].id, testAssignment2.id, student.id, postedGrade = "A-") Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -94,16 +94,17 @@ class GradesElementaryE2ETest : StudentTest() { gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[1].name, "9%") gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[2].name, "Not Graded") - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${testAssignment2.name} assignment.") - gradeSubmission(teacher,nonHomeroomCourses[0].id, student, testAssignment2.id, "C-") + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${testAssignment2.name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, nonHomeroomCourses[0].id, testAssignment2.id, student.id, postedGrade = "C-") Thread.sleep(5000) //This time is needed here to let the SubMissionApi does it's job. - Log.d(STEP_TAG, "Refresh Grades Elementary Page. Assert that the previously graded, ${testAssignment2.name} assignment's grade has been changed, but only that one.") + Log.d(STEP_TAG, "Refresh Grades Elementary Page. Assert that the previously graded, '${testAssignment2.name}' assignment's grade has been changed, but only that one.") gradesPage.refresh() Thread.sleep(5000) //We need to wait here because sometimes if we refresh the page fastly, the old grade will be seen. gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[0].name, "73%") gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[1].name, "9%") + gradesPage.assertCourseShownWithGrades(nonHomeroomCourses[2].name, "Not Graded") Log.d(STEP_TAG, "Change 'Current Grading Period' to '${testGradingPeriodListApiModel.gradingPeriods[0].title}'.") gradesPage.assertSelectedGradingPeriod(gradesPage.getStringFromResource(R.string.currentGradingPeriod)) @@ -111,50 +112,14 @@ class GradesElementaryE2ETest : StudentTest() { gradesPage.clickGradingPeriodSelector() gradesPage.selectGradingPeriod(testGradingPeriodListApiModel.gradingPeriods[0].title) - Log.d(STEP_TAG, "Checking if a course's grades page is displayed after clicking on a course row on elementary grades page. Assert that we have left the grades elementary page. We are asserting this because in beta environment, subject page's not always available for k5 user.") + Log.d(STEP_TAG, "Checking if a course's grades page is displayed after clicking on a course row on elementary grades page." + + "Assert that we have left the grades elementary page. We are asserting this because in beta environment, subject page's not always available for k5 user.") gradesPage.clickGradeRow(nonHomeroomCourses[0].name) gradesPage.assertCourseNotDisplayed(nonHomeroomCourses[0].name) Log.d(STEP_TAG, "Navigate back to Grades Elementary Page and assert it's displayed.") Espresso.pressBack() gradesPage.assertPageObjects() - - } - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - calendar: Calendar, - gradingType: GradingType, - pointsPossible: Double - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = calendar.time.toApiString() - ) - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - courseId: Long, - student: CanvasUserApiModel, - assignmentId: Long, - postedGrade: String - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = courseId, - assignmentId = assignmentId, - studentId = student.id, - postedGrade = postedGrade, - excused = false - ) } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt index 6c6703011d..b9d1d7d138 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/HomeroomE2ETest.kt @@ -21,11 +21,10 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.ago @@ -44,13 +43,14 @@ import org.threeten.bp.format.DateTimeFormatter @HiltAndroidTest class HomeroomE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.HOMEROOM) fun homeroomE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -68,11 +68,11 @@ class HomeroomE2ETest : StudentTest() { val homeroomAnnouncement = data.announcementsList[0] val nonHomeroomCourses = data.coursesList.filter { !it.homeroomCourse } - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${nonHomeroomCourses[2].name} course.") - val testAssignment = createAssignment(nonHomeroomCourses[2].id, teacher, GradingType.LETTER_GRADE, 100.0, OffsetDateTime.now().plusHours(1).format(DateTimeFormatter.ISO_DATE_TIME)) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${nonHomeroomCourses[2].name}' course.") + val testAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[2].id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 100.0, dueAt = OffsetDateTime.now().plusHours(1).format(DateTimeFormatter.ISO_DATE_TIME), submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' MISSING assignment for ${nonHomeroomCourses[2].name} course.") - val testAssignmentMissing = createAssignment(nonHomeroomCourses[2].id, teacher, GradingType.PERCENT, 100.0, 3.days.ago.iso8601) + val testAssignmentMissing = AssignmentsApi.createAssignment(nonHomeroomCourses[2].id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 100.0, dueAt = 3.days.ago.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -81,10 +81,10 @@ class HomeroomE2ETest : StudentTest() { Log.d(STEP_TAG, "Navigate to K5 Important Dates Page and assert it is loaded.") elementaryDashboardPage.selectTab(ElementaryDashboardPage.ElementaryTabType.HOMEROOM) - Log.d(STEP_TAG, "Assert that there is a welcome text with the student's shortname (${student.shortName}).") + Log.d(STEP_TAG, "Assert that there is a welcome text with the student's shortname: '${student.shortName}'.") homeroomPage.assertWelcomeText(student.shortName) - Log.d(STEP_TAG, "Assert that the ${homeroomAnnouncement.title} announcement (which belongs to ${homeroomCourse.name} homeroom course) is displayed.") + Log.d(STEP_TAG, "Assert that the '${homeroomAnnouncement.title}' announcement (which belongs to '${homeroomCourse.name}' homeroom course) is displayed.") homeroomPage.assertAnnouncementDisplayed(homeroomCourse.name, homeroomAnnouncement.title, homeroomAnnouncement.message) Log.d(STEP_TAG, "Assert that under the 'My Subject' section there are 3 items.") @@ -92,7 +92,7 @@ class HomeroomE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'View Previous Announcements'." + "Assert that the Announcement List Page is displayed" + - "and the ${homeroomAnnouncement.title} announcement is displayed as well within the announcement list..") + "and the '${homeroomAnnouncement.title}' announcement is displayed as well within the announcement list..") homeroomPage.clickOnViewPreviousAnnouncements() announcementListPage.assertToolbarTitle() announcementListPage.assertAnnouncementTitleVisible(homeroomAnnouncement.title) @@ -103,7 +103,7 @@ class HomeroomE2ETest : StudentTest() { elementaryDashboardPage.waitForRender() for (i in 0 until nonHomeroomCourses.size - 1) { - Log.d(STEP_TAG, "Assert that the ${nonHomeroomCourses[i].name} course is displayed with the announcements which belongs to it.") + Log.d(STEP_TAG, "Assert that the '${nonHomeroomCourses[i].name}' course is displayed with the announcements which belongs to it.") homeroomPage.assertCourseDisplayed( nonHomeroomCourses[i].name, homeroomPage.getStringFromResource(R.string.nothingDueToday), @@ -115,50 +115,30 @@ class HomeroomE2ETest : StudentTest() { homeroomPage.assertPageObjects() homeroomPage.assertToDoText("1 due today | 1 missing") - Log.d(STEP_TAG, "Open ${nonHomeroomCourses[0].name} course." + + Log.d(STEP_TAG, "Open '${nonHomeroomCourses[0].name}' course." + "Assert that the Course Details Page is displayed and the title is '${nonHomeroomCourses[0].name}' (the course's name).") homeroomPage.openCourse(nonHomeroomCourses[0].name) elementaryCoursePage.assertPageObjects() elementaryCoursePage.assertTitleCorrect(nonHomeroomCourses[0].name) - Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open ${data.announcementsList[1].title} announcement by clicking on it.") + Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open '${data.announcementsList[1].title}' announcement by clicking on it.") Espresso.pressBack() homeroomPage.assertPageObjects() homeroomPage.openCourseAnnouncement(data.announcementsList[1].title) - Log.d(STEP_TAG, "Assert that the ${data.announcementsList[1].title} announcement's details page is displayed.") + Log.d(STEP_TAG, "Assert that the '${data.announcementsList[1].title}' announcement's details page is displayed.") discussionDetailsPage.assertTitleText(data.announcementsList[1].title) - Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open the Assignment List Page of ${nonHomeroomCourses[2].name} course.") + Log.d(STEP_TAG, "Navigate back to Homeroom Page. Open the Assignment List Page of '${nonHomeroomCourses[2].name}' course.") Espresso.pressBack() homeroomPage.assertPageObjects() homeroomPage.openAssignments("1 due today | 1 missing") - Log.d(STEP_TAG, "Assert that the Assignment list page of ${nonHomeroomCourses[2].name} course is loaded well" + - "and the corresponding assignments (Not missing: ${testAssignment.name}, missing: ${testAssignmentMissing.name}) are displayed.") + Log.d(STEP_TAG, "Assert that the Assignment list page of '${nonHomeroomCourses[2].name}' course is loaded well" + + "and the corresponding assignments (Not missing: '${testAssignment.name}', missing: '${testAssignmentMissing.name}') are displayed.") assignmentListPage.assertPageObjects() assignmentListPage.assertHasAssignment(testAssignment) assignmentListPage.assertHasAssignment(testAssignmentMissing) } - - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - gradingType: GradingType, - pointsPossible: Double, - dueAt: String - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt - ) - ) - } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt index d569f6c99b..3628506f0b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt @@ -21,14 +21,12 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.utils.toDate import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType -import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 @@ -43,13 +41,14 @@ import java.util.* @HiltAndroidTest class ImportantDatesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.IMPORTANT_DATES) fun importantDatesE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -67,17 +66,17 @@ class ImportantDatesE2ETest : StudentTest() { val elementaryCourse3 = data.coursesList[2] val elementaryCourse4 = data.coursesList[3] - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for ${elementaryCourse1.name} course.") - val testAssignment1 = createAssignment(elementaryCourse1.id,teacher, GradingType.POINTS, 100.0, 3.days.fromNow.iso8601, importantDate = true) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for '${elementaryCourse1.name}' course.") + val testAssignment1 = AssignmentsApi.createAssignment(elementaryCourse1.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 3.days.fromNow.iso8601, importantDate = true) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for ${elementaryCourse2.name} course.") - val testAssignment2 = createAssignment(elementaryCourse2.id,teacher, GradingType.POINTS, 100.0, 4.days.fromNow.iso8601, importantDate = true) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for '${elementaryCourse2.name}' course.") + val testAssignment2 = AssignmentsApi.createAssignment(elementaryCourse2.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 4.days.fromNow.iso8601, importantDate = true) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for ${elementaryCourse3.name} course.") - val testAssignment3 = createAssignment(elementaryCourse3.id,teacher, GradingType.POINTS, 100.0, 4.days.fromNow.iso8601, importantDate = true) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' IMPORTANT assignment for '${elementaryCourse3.name}' course.") + val testAssignment3 = AssignmentsApi.createAssignment(elementaryCourse3.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 4.days.fromNow.iso8601, importantDate = true) - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' NOT IMPORTANT assignment for ${elementaryCourse4.name} course.") - val testNotImportantAssignment = createAssignment(elementaryCourse4.id,teacher, GradingType.POINTS, 100.0, 4.days.fromNow.iso8601, importantDate = false) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' NOT IMPORTANT assignment for '${elementaryCourse4.name}' course.") + val testNotImportantAssignment = AssignmentsApi.createAssignment(elementaryCourse4.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 100.0, dueAt = 4.days.fromNow.iso8601, importantDate = false) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -87,7 +86,7 @@ class ImportantDatesE2ETest : StudentTest() { elementaryDashboardPage.selectTab(ElementaryDashboardPage.ElementaryTabType.IMPORTANT_DATES) importantDatesPage.assertPageObjects() - Log.d(STEP_TAG, "Assert that the important date assignments are displayed and the 'not' important (${testNotImportantAssignment.name}) is not displayed.") + Log.d(STEP_TAG, "Assert that the important date assignments are displayed and the 'not' important one, '${testNotImportantAssignment.name}' is not displayed.") importantDatesPage.assertItemDisplayed(testAssignment1.name) importantDatesPage.assertItemDisplayed(testAssignment2.name) importantDatesPage.assertItemDisplayed(testAssignment3.name) @@ -107,7 +106,7 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertPageObjects() Log.d(STEP_TAG, "Refresh the Important Dates page. Assert that the corresponding items" + - "(all the assignments, except ${testNotImportantAssignment.name} named assignment) and their labels are still displayed after the refresh.") + "(all the assignments, except '${testNotImportantAssignment.name}' named assignment) and their labels are still displayed after the refresh.") importantDatesPage.pullToRefresh() importantDatesPage.assertItemDisplayed(testAssignment1.name) importantDatesPage.assertItemDisplayed(testAssignment2.name) @@ -118,32 +117,10 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertRecyclerViewItemCount(3) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment1.dueAt.toDate())) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment2.dueAt.toDate())) - } private fun generateDayString(date: Date?): String { return SimpleDateFormat("EEEE, MMMM dd", Locale.getDefault()).format(date) } - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - gradingType: GradingType, - pointsPossible: Double, - dueAt: String, - importantDate: Boolean - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt, - importantDate = importantDate - ) - ) - } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt index dfdf5c229d..0337f8d4c5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ResourcesE2ETest.kt @@ -21,6 +21,7 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.model.CanvasUserApiModel @@ -33,13 +34,14 @@ import org.junit.Test @HiltAndroidTest class ResourcesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.RESOURCES) fun resourcesE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -65,9 +67,9 @@ class ResourcesE2ETest : StudentTest() { resourcesPage.assertPageObjects() Log.d(STEP_TAG, "Assert that the important links, LTI tools and contacts are displayed.") - assertElementaryResourcesPageInformations(teacher) + assertElementaryResourcesPageInformation(teacher) - Log.d(STEP_TAG, "Click on the compose message icon next to a contact (${teacher.name}), and verify if the new message page is displayed.") + Log.d(STEP_TAG, "Click on the compose message icon next to a contact ('${teacher.name}' teacher), and verify if the new message page is displayed.") resourcesPage.openComposeMessage(teacher.shortName) assertNewMessagePageDisplayed() @@ -76,7 +78,7 @@ class ResourcesE2ETest : StudentTest() { resourcesPage.assertPageObjects() Log.d(STEP_TAG, "Assert that the important links, LTI tools and contacts are still displayed correctly, after the navigation.") - assertElementaryResourcesPageInformations(teacher) + assertElementaryResourcesPageInformation(teacher) Log.d(STEP_TAG, "Open an LTI tool (Google Drive), and verify if all the NON-homeroom courses are displayed within the 'Choose a Course' list.") resourcesPage.openLtiApp("Google Drive") @@ -85,9 +87,7 @@ class ResourcesE2ETest : StudentTest() { } } - private fun assertElementaryResourcesPageInformations( - teacher: CanvasUserApiModel - ) { + private fun assertElementaryResourcesPageInformation(teacher: CanvasUserApiModel) { resourcesPage.assertImportantLinksHeaderDisplayed() resourcesPage.assertStudentApplicationsHeaderDisplayed() resourcesPage.assertStaffInfoHeaderDisplayed() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt index a178f13783..29efacabea 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt @@ -21,12 +21,11 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.utils.toApiString import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.espresso.page.getStringFromResource @@ -50,11 +49,12 @@ class ScheduleE2ETest : StudentTest() { override fun enableAndConfigureAccessibilityChecks() = Unit @Rule + @JvmField var globalTimeout: Timeout = Timeout.millis(600000) // //TODO: workaround for that sometimes this test is running infinite time because of scrollToElement does not find an element. @E2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.SCHEDULE) fun scheduleE2ETest() { Log.d(PREPARATION_TAG,"Seeding data for K5 sub-account.") @@ -74,13 +74,13 @@ class ScheduleE2ETest : StudentTest() { val twoWeeksAfterCalendar = getCustomDateCalendar(15) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' MISSING assignment for ${nonHomeroomCourses[2].name} course.") - val testMissingAssignment = createAssignment(nonHomeroomCourses[2].id, teacher, currentDateCalendar, GradingType.LETTER_GRADE,100.0) + val testMissingAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[2].id, teacher.token, dueAt = currentDateCalendar.time.toApiString(), gradingType = GradingType.LETTER_GRADE, pointsPossible = 100.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' Two weeks before end date assignment for ${nonHomeroomCourses[1].name} course.") - val testTwoWeeksBeforeAssignment = createAssignment(nonHomeroomCourses[1].id, teacher, twoWeeksBeforeCalendar, GradingType.PERCENT,100.0) + val testTwoWeeksBeforeAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[1].id, teacher.token, dueAt = twoWeeksBeforeCalendar.time.toApiString(), gradingType = GradingType.PERCENT, pointsPossible = 100.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding 'Text Entry' Two weeks after end date assignment for ${nonHomeroomCourses[0].name} course.") - val testTwoWeeksAfterAssignment = createAssignment(nonHomeroomCourses[0].id, teacher, twoWeeksAfterCalendar, GradingType.POINTS,25.0) + val testTwoWeeksAfterAssignment = AssignmentsApi.createAssignment(nonHomeroomCourses[0].id, teacher.token, dueAt = twoWeeksAfterCalendar.time.toApiString(), gradingType = GradingType.POINTS, pointsPossible = 25.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLoginElementary(student) @@ -214,24 +214,5 @@ class ScheduleE2ETest : StudentTest() { return cal } - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - calendar: Calendar, - gradingType: GradingType, - pointsPossible: Double - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = calendar.time.toApiString() - ) - ) - } - } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index f66fa17e87..81b89120c9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -34,6 +34,7 @@ import org.junit.Test @HiltAndroidTest class ManageOfflineContentE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt index b9868b8487..8ce02e5c4b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt @@ -36,6 +36,7 @@ import org.junit.Test @HiltAndroidTest class OfflineAllCoursesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt index c31bb58e50..463100b18a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -35,6 +35,7 @@ import java.lang.Thread.sleep @HiltAndroidTest class OfflineCourseBrowserE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt index 21bd315781..8a2b22aa66 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt @@ -37,6 +37,7 @@ import org.junit.Test @HiltAndroidTest class OfflineDashboardE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt index 4a2a2009c7..3e312331c1 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt @@ -37,6 +37,7 @@ import org.junit.Test @HiltAndroidTest class OfflineFilesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -55,7 +56,7 @@ class OfflineFilesE2ETest : StudentTest() { val testCourseFolderName = "Goodya" Log.d(PREPARATION_TAG, "Create a course folder within the 'Files' tab with the name: '$testCourseFolderName'.") val courseRootFolder = FileFolderApi.getCourseRootFolder(course.id, teacher.token) - val courseTestFolder = FileFolderApi.createCourseFolder(courseRootFolder.id, testCourseFolderName, false, teacher.token) + val courseTestFolder = FileFolderApi.createCourseFolder(courseRootFolder.id, teacher.token, testCourseFolderName) Log.d(PREPARATION_TAG, "Create a (text) file within the root folder (so the 'Files' tab file list) of the '${course.name}' course.") val rootFolderTestTextFile = uploadTextFile(courseRootFolder.id, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt index 6deba129d3..3e12ff66f3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt @@ -35,6 +35,7 @@ import org.junit.Test @HiltAndroidTest class OfflineLeftSideMenuE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt index f8eede4b34..f3797ef48a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt @@ -36,6 +36,7 @@ import java.lang.Thread.sleep @HiltAndroidTest class OfflineLoginE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -76,7 +77,7 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that ${student1.name} and ${student2.name} students are displayed within the previous login section.") + Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that '${student1.name}' and '${student2.name}' students are displayed within the previous login section.") loginLandingPage.assertDisplaysPreviousLogins() loginLandingPage.assertPreviousLoginUserDisplayed(student1.name) loginLandingPage.assertPreviousLoginUserDisplayed(student2.name) @@ -91,7 +92,7 @@ class OfflineLoginE2ETest : StudentTest() { assertNoInternetConnectionDialog() dismissNoInternetConnectionDialog() - Log.d(STEP_TAG, "Login with the previous user, ${student1.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG, "Login with the previous user, '${student1.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(student1) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Assert that the offline indicator is displayed to ensure we are in offline mode, and change user function is supported.") @@ -101,7 +102,7 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Login with the previous user, ${student2.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG, "Login with the previous user, '${student2.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(student2) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Assert that the offline indicator is displayed to ensure we are in offline mode, and change user function is supported.") @@ -122,7 +123,7 @@ class OfflineLoginE2ETest : StudentTest() { loginLandingPage.clickFindMySchoolButton() } - Log.d(STEP_TAG,"Enter domain: ${user.domain}.") + Log.d(STEP_TAG,"Enter domain: '${user.domain}'.") loginFindSchoolPage.enterDomain(user.domain) Log.d(STEP_TAG,"Click on 'Next' button on the Toolbar.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt index e4e90fb2f7..4a36f758cc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt @@ -27,9 +27,6 @@ import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.PagesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.util.Randomizer import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertOfflineIndicator import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline @@ -43,6 +40,7 @@ import org.junit.Test @HiltAndroidTest class OfflinePagesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -59,16 +57,16 @@ class OfflinePagesE2ETest : StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for '${course.name}' course.") - val pageUnpublished = createCoursePage(course, teacher, published = false, frontPage = false) + val pageUnpublished = PagesApi.createCoursePage(course.id, teacher.token, published = false) Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for '${course.name}' course.") - val pagePublished = createCoursePage(course, teacher, published = true, frontPage = false, editingRoles = "teachers,students", body = "

Regular Page Text

") + val pagePublished = PagesApi.createCoursePage(course.id, teacher.token, editingRoles = "teachers,students", body = "

Regular Page Text

") Log.d(PREPARATION_TAG,"Seed a PUBLISHED, but NOT editable page for '${course.name}' course.") - val pageNotEditable = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") + val pageNotEditable = PagesApi.createCoursePage(course.id, teacher.token, body = "

Regular Page Text

") Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for '${course.name}' course.") - val pagePublishedFront = createCoursePage(course, teacher, published = true, frontPage = true, editingRoles = "public", body = "

Front Page Text

") + val pagePublishedFront = PagesApi.createCoursePage(course.id, teacher.token, frontPage = true, editingRoles = "public", body = "

Front Page Text

") Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -168,20 +166,4 @@ class OfflinePagesE2ETest : StudentTest() { turnOnConnectionViaADB() } - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel, - published: Boolean, - frontPage: Boolean, - editingRoles: String? = null, - body: String = Randomizer.randomPageBody() - ) = PagesApi.createCoursePage( - courseId = course.id, - published = published, - frontPage = frontPage, - editingRoles = editingRoles, - token = teacher.token, - body = body - ) - } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt index 2ebcdfd833..3a83c15a91 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt @@ -33,6 +33,7 @@ import org.junit.Test @HiltAndroidTest class OfflinePeopleE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt index 275575ba0e..bd4afc5a27 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt @@ -34,6 +34,7 @@ import org.junit.Test @HiltAndroidTest class OfflineSyncProgressE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt index 67bae6de61..bd8e82b5a5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt @@ -33,6 +33,7 @@ import org.junit.Test @HiltAndroidTest class OfflineSyncSettingsE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt index 4f0f1f8db9..e1762cd4be 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryDashboardInteractionTest.kt @@ -34,7 +34,7 @@ class ElementaryDashboardInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testNavigateToElementaryDashboard() { // User should be able to tap and navigate to dashboard page goToElementaryDashboard(courseCount = 1, favoriteCourseCount = 1) @@ -46,7 +46,7 @@ class ElementaryDashboardInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testTabsNavigation() { goToElementaryDashboard(courseCount = 1, favoriteCourseCount = 1) elementaryDashboardPage.assertElementaryTabVisibleAndSelected(ElementaryDashboardPage.ElementaryTabType.HOMEROOM) @@ -70,7 +70,7 @@ class ElementaryDashboardInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyElementarySpecificNavigationItemsShownInTheNavigationDrawer() { goToElementaryDashboard(courseCount = 1, favoriteCourseCount = 1) elementaryDashboardPage.openDrawer() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt index 36923ce040..bad0d87003 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt @@ -39,7 +39,7 @@ class ElementaryGradesInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowGrades() { val data = createMockData(courseCount = 3) goToGradesTab(data) @@ -52,7 +52,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefresh() { val data = createMockData(courseCount = 1) goToGradesTab(data) @@ -72,7 +72,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourseGrades() { val data = createMockData(courseCount = 3) goToGradesTab(data) @@ -90,7 +90,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testChangeGradingPeriod() { val data = createMockData(courseCount = 3, withGradingPeriods = true) goToGradesTab(data) @@ -104,7 +104,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyView() { val data = createMockData(homeroomCourseCount = 1) goToGradesTab(data) @@ -114,7 +114,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowPercentageOnlyIfNoAlphabeticalGrade() { val data = createMockData(courseCount = 1) goToGradesTab(data) @@ -135,7 +135,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testDontShowProgressWhenQuantitativeDataIsRestricted() { val data = createMockData(courseCount = 1) goToGradesTab(data) @@ -157,7 +157,7 @@ class ElementaryGradesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testDontShowGradeWhenQuantitativeDataIsRestrictedAndThereIsOnlyScore() { val data = createMockData(courseCount = 1) goToGradesTab(data) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt index fa08c724fc..fc2ed7ace6 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/HomeroomInteractionTest.kt @@ -41,7 +41,7 @@ class HomeroomInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testAnnouncementsAndCoursesShowUpOnHomeroom() { val data = createMockDataWithHomeroomCourse(courseCount = 3) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -66,7 +66,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyCoursesShowUpOnHomeroomIfNoHomeroomAnnouncement() { val data = createMockDataWithHomeroomCourse(courseCount = 3) @@ -87,7 +87,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyAnnouncementShowsUpOnHomeroomIfNoCourses() { val data = createMockDataWithHomeroomCourse() val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -106,7 +106,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourse() { val data = createMockDataWithHomeroomCourse(courseCount = 3) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -127,7 +127,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefreshAfterEnrolledToCourses() { val data = createMockDataWithHomeroomCourse() @@ -160,7 +160,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenHomeroomCourseAnnouncements() { val data = createMockDataWithHomeroomCourse(courseCount = 3, homeroomCourseCount = 2) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -184,7 +184,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourseAnnouncements() { val data = createMockDataWithHomeroomCourse(courseCount = 1) @@ -203,7 +203,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowCourseCardWithAnnouncement() { val data = createMockDataWithHomeroomCourse(courseCount = 3) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -224,7 +224,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testDueTodayAndMissingAssignments() { val data = createMockDataWithHomeroomCourse(courseCount = 1) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -249,7 +249,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenAssignments() { val data = createMockDataWithHomeroomCourse(courseCount = 1) val homeroomCourse = data.courses.values.first { it.homeroomCourse } @@ -272,7 +272,7 @@ class HomeroomInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyState() { val data = createMockDataWithHomeroomCourse() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt index 53b95676a4..2262539a98 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt @@ -44,7 +44,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowCalendarEvents() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -59,7 +59,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowAssignment() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -75,7 +75,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyView() { val data = createMockData(courseCount = 1) @@ -86,7 +86,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testPullToRefresh() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -107,7 +107,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCalendarEvent() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -127,7 +127,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenAssignment() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -147,7 +147,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowMultipleCalendarEventsOnSameDay() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] @@ -171,7 +171,7 @@ class ImportantDatesInteractionTest : StudentTest() { @Test @StubTablet(description = "The UI is different on tablet, so we only check the phone version") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testMultipleCalendarEventsOnDifferentDays() { val data = createMockData(courseCount = 1) val course = data.courses.values.toList()[0] diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt index 42c1233741..c33e422e9c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ResourcesInteractionTest.kt @@ -38,7 +38,7 @@ class ResourcesInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testImportantLinksAndActionItemsShowUpInResourcesScreen() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -67,7 +67,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyActionItemsShowIfSyllabusIsEmpty() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -95,7 +95,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOnlyLtiToolsShowIfNoHomeroomCourse() { val data = createMockDataWithHomeroomCourse(courseCount = 2, homeroomCourseCount = 0) @@ -117,7 +117,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefresh() { val data = createMockDataWithHomeroomCourse(courseCount = 2, homeroomCourseCount = 0) @@ -152,7 +152,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenLtiToolShowsCourseSelector() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -175,7 +175,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenComposeMessageScreen() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -201,7 +201,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testImportantLinksForTwoCourses() { val data = createMockDataWithHomeroomCourse(courseCount = 2) @@ -225,7 +225,7 @@ class ResourcesInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testEmptyState() { val data = createMockDataWithHomeroomCourse(courseCount = 2, homeroomCourseCount = 0) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt index 2cfe7c7cbb..29eed58195 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt @@ -49,7 +49,7 @@ class ScheduleInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowCorrectHeaderItems() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -74,7 +74,7 @@ class ScheduleInteractionTest : StudentTest() { @Test @StubLandscape(description = "This is intentionally stubbed on landscape mode because the item view is too narrow, but that's not a bug, it's intentional.") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowScheduledAssignments() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -93,7 +93,7 @@ class ScheduleInteractionTest : StudentTest() { @Test @StubLandscape(description = "This is intentionally stubbed on landscape mode because the item view is too narrow, but that's not a bug, it's intentional.") - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowMissingAssignments() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -109,7 +109,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testShowToDoEvents() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -125,7 +125,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testRefresh() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -154,7 +154,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testGoBack2Weeks() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -177,7 +177,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testGoForward2Weeks() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -200,7 +200,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenAssignment() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -220,7 +220,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testOpenCourse() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -239,7 +239,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testMarkAsDone() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) @@ -256,7 +256,7 @@ class ScheduleInteractionTest : StudentTest() { } @Test - @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + @TestMetaData(Priority.COMMON, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.INTERACTION) fun testTodayButton() { setDate(2021, Calendar.AUGUST, 11) val data = createMockData(courseCount = 1) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt index ae17fa6e2a..3fc55b3592 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTestExtensions.kt @@ -271,16 +271,7 @@ fun seedAssignmentSubmission( it.attachmentsList.addAll(fileAttachments) } - // Seed the submissions - val submissionRequest = SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignmentId, - courseId = courseId, - studentToken = studentToken, - commentSeedsList = commentSeeds, - submissionSeedsList = submissionSeeds - ) - - return SubmissionsApi.seedAssignmentSubmission(submissionRequest) + return SubmissionsApi.seedAssignmentSubmission(courseId, studentToken, assignmentId, commentSeeds, submissionSeeds) } fun uploadTextFile( @@ -304,10 +295,11 @@ fun uploadTextFile( // Start the Canvas file upload process return FileUploadsApi.uploadFile( - courseId, - assignmentId, - file.readBytes(), - file.name, - token, - fileUploadType) + courseId, + assignmentId, + file.readBytes(), + file.name, + token, + fileUploadType + ) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt index 49a4aea411..08521aef09 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt @@ -306,16 +306,13 @@ class AssignmentE2ETest : TeacherTest() { dueAt = 1.days.fromNow.iso8601 )) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - SubmissionsApi.seedAssignmentSubmission(SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignment.id, - courseId = course.id, - studentToken = student.token, + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, assignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY )) - )) + ) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(teacher) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt index 900c775687..4049eb69bd 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt @@ -96,7 +96,7 @@ class ModulesE2ETest : TeacherTest() { Log.d(PREPARATION_TAG,"Publish ${module.name} module via API.") ModulesApi.updateModule( courseId = course.id, - id = module.id, + moduleId = module.id, published = true, teacherToken = teacher.token ) @@ -127,7 +127,7 @@ class ModulesE2ETest : TeacherTest() { Log.d(PREPARATION_TAG,"Unpublish ${module.name} module via API.") ModulesApi.updateModule( courseId = course.id, - id = module.id, + moduleId = module.id, published = false, teacherToken = teacher.token ) @@ -176,8 +176,8 @@ class ModulesE2ETest : TeacherTest() { courseId = course.id, moduleId = module.id, teacherToken = teacher.token, - title = title, - type = moduleItemType, + moduleItemTitle = title, + moduleItemType = moduleItemType, contentId = contentId, pageUrl = pageUrl ) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt index 83aa31f580..37ecc53877 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTestExtensions.kt @@ -29,8 +29,29 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.waitForMatcherWithSleeps import com.instructure.canvasapi2.models.User -import com.instructure.dataseeding.api.* -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.ConversationsApi +import com.instructure.dataseeding.api.CoursesApi +import com.instructure.dataseeding.api.EnrollmentsApi +import com.instructure.dataseeding.api.FileUploadsApi +import com.instructure.dataseeding.api.PagesApi +import com.instructure.dataseeding.api.QuizzesApi +import com.instructure.dataseeding.api.SeedApi +import com.instructure.dataseeding.api.SubmissionsApi +import com.instructure.dataseeding.api.UserApi +import com.instructure.dataseeding.model.AssignmentApiModel +import com.instructure.dataseeding.model.AttachmentApiModel +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.ConversationListApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.EnrollmentTypes +import com.instructure.dataseeding.model.FileType +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.dataseeding.model.PageApiModel +import com.instructure.dataseeding.model.QuizListApiModel +import com.instructure.dataseeding.model.QuizSubmissionApiModel +import com.instructure.dataseeding.model.SubmissionApiModel +import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.CanvasNetworkAdapter import com.instructure.dataseeding.util.DataSeedingException import com.instructure.dataseeding.util.Randomizer @@ -40,7 +61,11 @@ import com.instructure.teacher.activities.LoginActivity import com.instructure.teacher.router.RouteMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Matchers.anyOf -import java.io.* +import java.io.BufferedInputStream +import java.io.DataInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileWriter fun TeacherTest.enterDomain(enrollmentType: String = EnrollmentTypes.TEACHER_ENROLLMENT): CanvasUserApiModel { @@ -251,16 +276,7 @@ fun TeacherTest.seedAssignmentSubmission( it.attachmentsList.addAll(fileAttachments) } - // Seed the submissions - val submissionRequest = SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignmentId, - courseId = courseId, - studentToken = studentToken, - submissionSeedsList = submissionSeeds, - commentSeedsList = commentSeeds - ) - - return SubmissionsApi.seedAssignmentSubmission(submissionRequest) + return SubmissionsApi.seedAssignmentSubmission(courseId, studentToken, assignmentId, commentSeeds, submissionSeeds) } fun TeacherTest.uploadTextFile(courseId: Long, assignmentId: Long, token: String, fileUploadType: FileUploadType): AttachmentApiModel { diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt index 6bc4513023..82f37c068a 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentGroupsApi.kt @@ -34,7 +34,7 @@ object AssignmentGroupsApi { private fun assignmentGroupsService(token: String): AssignmentGroupsService = CanvasNetworkAdapter.retrofitWithToken(token).create(AssignmentGroupsService::class.java) - fun createAssignmentGroup(token: String, courseId: Long, name: String, position: Int?, groupWeight: Int?, sisSourceId: Long?): AssignmentGroupApiModel { + fun createAssignmentGroup(token: String, courseId: Long, name: String, position: Int? = null, groupWeight: Int? = null, sisSourceId: Long? = null): AssignmentGroupApiModel { val assignmentGroup = CreateAssignmentGroup(name, position, groupWeight, sisSourceId) return assignmentGroupsService(token) .createAssignmentGroup(courseId, assignmentGroup) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt index 15b030191b..b665471602 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/AssignmentsApi.kt @@ -39,23 +39,24 @@ object AssignmentsApi { = CanvasNetworkAdapter.retrofitWithToken(token).create(AssignmentsService::class.java) data class CreateAssignmentRequest( - val courseId : Long, - val withDescription: Boolean = false, - val lockAt: String = "", - val unlockAt: String = "", - val dueAt: String = "", - val submissionTypes: List, - val gradingType: GradingType = GradingType.POINTS, - val allowedExtensions: List? = null, - val teacherToken: String, - val groupCategoryId: Long? = null, - val pointsPossible: Double? = null, - val importantDate: Boolean? = null, - val assignmentGroupId: Long? = null) + val courseId: Long, + val withDescription: Boolean = false, + val lockAt: String = "", + val unlockAt: String = "", + val dueAt: String = "", + val submissionTypes: List, + val gradingType: GradingType = GradingType.POINTS, + val allowedExtensions: List? = null, + val teacherToken: String, + val groupCategoryId: Long? = null, + val pointsPossible: Double? = null, + val importantDate: Boolean? = null, + val assignmentGroupId: Long? = null) fun createAssignment(request: CreateAssignmentRequest): AssignmentApiModel { return createAssignment( request.courseId, + request.teacherToken, request.withDescription, request.lockAt, request.unlockAt, @@ -63,7 +64,6 @@ object AssignmentsApi { request.submissionTypes, request.gradingType, request.allowedExtensions, - request.teacherToken, request.groupCategoryId, request.pointsPossible, request.importantDate, @@ -72,19 +72,19 @@ object AssignmentsApi { } fun createAssignment( - courseId: Long, - withDescription: Boolean, - lockAt: String, - unlockAt: String, - dueAt: String, - submissionTypes: List, - gradingType: GradingType, - allowedExtensions: List?, - teacherToken: String, - groupCategoryId: Long?, - pointsPossible: Double?, - importantDate: Boolean?, - assignmentGroupId: Long?): AssignmentApiModel { + courseId: Long, + teacherToken: String, + withDescription: Boolean = false, + lockAt: String = "", + unlockAt: String = "", + dueAt: String = "", + submissionTypes: List = emptyList(), + gradingType: GradingType = GradingType.POINTS, + allowedExtensions: List? = null, + groupCategoryId: Long? = null, + pointsPossible: Double? = null, + importantDate: Boolean? = null, + assignmentGroupId: Long? = null): AssignmentApiModel { val assignment = CreateAssignmentWrapper(Randomizer.randomAssignment( withDescription, lockAt, diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt index 3359cf5004..d00fa68907 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConferencesApi.kt @@ -4,6 +4,7 @@ import com.instructure.dataseeding.model.ConferencesRequestApiModel import com.instructure.dataseeding.model.ConferencesResponseApiModel import com.instructure.dataseeding.model.WebConferenceWrapper import com.instructure.dataseeding.util.CanvasNetworkAdapter +import com.instructure.dataseeding.util.Randomizer import retrofit2.Call import retrofit2.http.Body import retrofit2.http.POST @@ -19,14 +20,14 @@ object ConferencesApi { private fun conferencesService(token: String): ConferencesService = CanvasNetworkAdapter.retrofitWithToken(token).create(ConferencesService::class.java) - fun createCourseConference(token: String, title: String, description: String, conferenceType: String, longRunning: Boolean, duration: Int, userIds: List, courseId: Long): ConferencesResponseApiModel { + fun createCourseConference(courseId: Long, token: String, title: String = Randomizer.randomConferenceTitle(), description: String = Randomizer.randomConferenceDescription(), conferenceType: String = "BigBlueButton", longRunning: Boolean = false, duration: Int = 70, recipientUserIds: List): ConferencesResponseApiModel { val conference = WebConferenceWrapper(webConference = ConferencesRequestApiModel( title, description, conferenceType, longRunning, duration, - userIds) + recipientUserIds) ) return conferencesService(token).createCourseConference(courseId, conference).execute().body()!! diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt index fc2989bd96..5885fa8caf 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ConversationsApi.kt @@ -44,7 +44,7 @@ object ConversationsApi { } private fun conversationsService(token: String): ConversationsService - = CanvasNetworkAdapter.retrofitWithToken(token).create(ConversationsApi.ConversationsService::class.java) + = CanvasNetworkAdapter.retrofitWithToken(token).create(ConversationsService::class.java) fun createConversation(token: String, recipients: List, subject: String = Randomizer.randomConversationSubject(), body: String = Randomizer.randomConversationBody()): List { val conversation = CreateConversation(recipients, subject, body) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt index 1365fc0705..c2dc5eb811 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/EnrollmentsApi.kt @@ -57,7 +57,7 @@ object EnrollmentsApi { = enrollUser(courseId, userId, TA_ENROLLMENT) fun enrollUserAsObserver(courseId: Long, userId: Long, associatedUserId: Long): EnrollmentApiModel - = enrollUser(courseId, userId, OBSERVER_ENROLLMENT, associatedUserId.takeIf { it > 0 }) + = enrollUser(courseId, userId, OBSERVER_ENROLLMENT, associatedUserId = associatedUserId.takeIf { it > 0 }) fun enrollUserAsDesigner(courseId: Long, userId: Long): EnrollmentApiModel = enrollUser(courseId, userId, DESIGNER_ENROLLMENT) @@ -66,8 +66,8 @@ object EnrollmentsApi { courseId: Long, userId: Long, enrollmentType: String, - associatedUserId: Long? = null, - enrollmentService: EnrollmentsService = enrollmentsService + enrollmentService: EnrollmentsService = enrollmentsService, + associatedUserId: Long? = null ): EnrollmentApiModel { val enrollment = EnrollmentApiRequestModel(userId, enrollmentType, enrollmentType, associatedUserId = associatedUserId) return enrollmentService diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt index 8207e383df..5d3d1663bd 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/FileFolderApi.kt @@ -48,16 +48,20 @@ object FileFolderApi { .body()!! } - fun createCourseFolder(folderId: Long, name: String, locked: Boolean, token: String): CourseFolderUploadApiModel { + fun createCourseFolder( + folderId: Long, + token: String, + name: String, + locked: Boolean = false + ): CourseFolderUploadApiModel { val courseFolderUploadRequestModel = CourseFolderUploadApiRequestModel( name = name, locked = locked ) - val createFolderUploadApiModel: CourseFolderUploadApiModel = fileFolderService(token) + return fileFolderService(token) .createCourseFolder(folderId, courseFolderUploadRequestModel) .execute() .body()!! - return createFolderUploadApiModel } } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt index ac428b1ebc..80673ea634 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/ModulesApi.kt @@ -34,28 +34,28 @@ object ModulesApi { private fun modulesService(token: String): ModulesService = CanvasNetworkAdapter.retrofitWithToken(token).create(ModulesService::class.java) - fun createModule(courseId: Long, teacherToken: String, unlockAt: String?): ModuleApiModel { + fun createModule(courseId: Long, teacherToken: String, unlockAt: String? = null): ModuleApiModel { val module = CreateModuleWrapper(Randomizer.createModule(unlockAt)) return modulesService(teacherToken).createModules(courseId, module).execute().body()!! } // Canvas API does not support creating a published module. // All modules must be created and published in separate calls. - fun updateModule(courseId: Long, id: Long, published: Boolean, teacherToken: String): ModuleApiModel { + fun updateModule(courseId: Long, teacherToken: String, moduleId: Long, published: Boolean = true): ModuleApiModel { val update = UpdateModuleWrapper(UpdateModule(published)) - return modulesService(teacherToken).updateModule(courseId, id, update).execute().body()!! + return modulesService(teacherToken).updateModule(courseId, moduleId, update).execute().body()!! } fun createModuleItem( - courseId: Long, - moduleId: Long, - teacherToken: String, - title: String, - type: String, - contentId: String?, - pageUrl: String? = null + courseId: Long, + teacherToken: String, + moduleId: Long, + moduleItemTitle: String, + moduleItemType: String, + contentId: String? = null, + pageUrl: String? = null ): ModuleItemApiModel { - val newItem = CreateModuleItem(title, type, contentId, pageUrl) + val newItem = CreateModuleItem(moduleItemTitle, moduleItemType, contentId, pageUrl) val newItemWrapper = CreateModuleItemWrapper(moduleItem = newItem) return modulesService(teacherToken).createModuleItem( courseId = courseId, diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt index 32e480f9e4..5c5353a4a0 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt @@ -39,12 +39,12 @@ object PagesApi { fun createCoursePage( courseId: Long, - published: Boolean, - frontPage: Boolean, - editingRoles: String? = null, token: String, - body: String = Randomizer.randomPageBody() - ): PageApiModel { + published: Boolean = true, + frontPage: Boolean = false, + body: String = Randomizer.randomPageBody(), + editingRoles: String? = null, + ): PageApiModel { val page = CreatePageWrapper(CreatePage(Randomizer.randomPageTitle(), body, published, frontPage, editingRoles)) return pagesService(token) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt index 91c0c36af9..13b575ed2d 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/QuizzesApi.kt @@ -80,12 +80,13 @@ object QuizzesApi { fun createQuiz( courseId: Long, - withDescription: Boolean, - lockAt: String, - unlockAt: String, - dueAt: String, - published: Boolean, - token: String): QuizApiModel { + token: String, + withDescription: Boolean = true, + lockAt: String = "", + unlockAt: String = "", + dueAt: String = "", + published: Boolean = true + ): QuizApiModel { val quiz = CreateQuiz(Randomizer.randomQuiz(withDescription, lockAt, unlockAt, dueAt, published)) return quizzesService(token) @@ -178,7 +179,7 @@ object QuizzesApi { val result = QuizListApiModel( quizList = (0 until numQuizzes).map { - QuizzesApi.createQuiz(request) + createQuiz(request) } ) @@ -208,7 +209,7 @@ object QuizzesApi { // Convenience method to create and publish a quiz with questions fun createAndPublishQuiz(courseId: Long, teacherToken: String, questions: List) : QuizApiModel { - val result = QuizzesApi.createQuiz(QuizzesApi.CreateQuizRequest( + val result = createQuiz(CreateQuizRequest( courseId = courseId, withDescription = true, published = false, // Will publish in just a bit, after we add questions @@ -216,7 +217,7 @@ object QuizzesApi { )) for(question in questions) { - val result = QuizzesApi.createQuizQuestion( + val result = createQuizQuestion( courseId = courseId, quizId = result.id, teacherToken = teacherToken, @@ -225,7 +226,7 @@ object QuizzesApi { question.id = result.id // back-fill the question id } - QuizzesApi.publishQuiz( + publishQuiz( courseId = courseId, quizId = result.id, teacherToken = teacherToken, diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt index ca8fe7fac6..4f11a7a1e7 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt @@ -59,11 +59,12 @@ object SubmissionsApi { private fun submissionsService(token: String): SubmissionsService = CanvasNetworkAdapter.retrofitWithToken(token).create(SubmissionsService::class.java) - fun submitCourseAssignment(submissionType: SubmissionType, - courseId: Long, + fun submitCourseAssignment(courseId: Long, + studentToken: String, assignmentId: Long, - fileIds: MutableList, - studentToken: String): SubmissionApiModel { + submissionType: SubmissionType, + fileIds: MutableList = mutableListOf() + ): SubmissionApiModel { val submission = Randomizer.randomSubmission(submissionType, fileIds) @@ -73,10 +74,10 @@ object SubmissionsApi { .body()!! } - fun commentOnSubmission(studentToken: String, - courseId: Long, + fun commentOnSubmission(courseId: Long, + studentToken: String, assignmentId: Long, - fileIds: MutableList, + fileIds: MutableList = mutableListOf(), attempt: Int = 1): AssignmentApiModel { val comment = Randomizer.randomSubmissionComment(fileIds, attempt) @@ -101,8 +102,8 @@ object SubmissionsApi { courseId: Long, assignmentId: Long, studentId: Long, - postedGrade: String? = null, - excused: Boolean): SubmissionApiModel { + excused: Boolean = false, + postedGrade: String? = null): SubmissionApiModel { return submissionsService(teacherToken) .gradeSubmission(courseId, assignmentId, studentId, GradeSubmissionWrapper(GradeSubmission(postedGrade, excused))) @@ -144,70 +145,69 @@ object SubmissionsApi { /** Seed one or more submissions for an assignment. Accepts a SubmissionSeedRequest, returns a * list of SubmissionApiModel objects. */ - fun seedAssignmentSubmission(request: SubmissionSeedRequest) : List { + fun seedAssignmentSubmission(courseId: Long, studentToken: String, assignmentId: Long, commentSeedsList: List = listOf(), submissionSeedsList: List = listOf()): List { val submissionsList = mutableListOf() - with(request) { - for (seed in submissionSeedsList) { - for (t in 0 until seed.amount) { - - // Submit an assignment - - // Canvas will only record submissions with unique "submitted_at" values. - // Sleep for 1 second to ensure submissions are recorded!!! - // - // https://github.com/instructure/mobile_qa/blob/7f985a08161f457e9b5d60987bd6278d21e2557e/SoSeedy/lib/so_seedy/canvas_models/account_admin.rb#L357-L359 - Thread.sleep(1000) - var submission = submitCourseAssignment( - submissionType = seed.submissionType, - courseId = courseId, - assignmentId = assignmentId, - fileIds = seed.attachmentsList.map { it.id }.toMutableList(), - studentToken = studentToken - ) - - if (seed.checkForLateStatus) { - val maxAttempts = 6 - var attempts = 1 - while (attempts < maxAttempts) { - val submissionResponse = getSubmission ( + + for (seed in submissionSeedsList) { + for (t in 0 until seed.amount) { + + // Submit an assignment + + // Canvas will only record submissions with unique "submitted_at" values. + // Sleep for 1 second to ensure submissions are recorded!!! + // + // https://github.com/instructure/mobile_qa/blob/7f985a08161f457e9b5d60987bd6278d21e2557e/SoSeedy/lib/so_seedy/canvas_models/account_admin.rb#L357-L359 + Thread.sleep(1000) + var submission = submitCourseAssignment( + submissionType = seed.submissionType, + courseId = courseId, + assignmentId = assignmentId, + fileIds = seed.attachmentsList.map { it.id }.toMutableList(), + studentToken = studentToken + ) + + if (seed.checkForLateStatus) { + val maxAttempts = 6 + var attempts = 1 + while (attempts < maxAttempts) { + val submissionResponse = getSubmission ( + studentToken = studentToken, + courseId = courseId, + assignmentId = assignmentId, + studentId = submission.userId + ) + if (submissionResponse.late) break + RetryBackoff.wait(attempts) + attempts++ + } + } + + // Create comments on the submitted assignment + submission = commentSeedsList + .map { + // Create comments with any assigned upload file types + val assignment = commentOnSubmission( studentToken = studentToken, courseId = courseId, assignmentId = assignmentId, - studentId = submission.userId + fileIds = it.attachmentsList.filter { it.id != -1L }.map { it.id }.toMutableList()) + + // Apparently, we only care about id and submissionComments + SubmissionApiModel( + id = assignment.id, + submissionComments = assignment.submissionComments!!, + url = null, + body = null, + userId = 0, + grade = null, + attempt = assignment.attempt!! + ) - if (submissionResponse.late) break - RetryBackoff.wait(attempts) - attempts++ } - } + .lastOrNull() ?: submission // Last one (if it exists) will have all the comments loaded up on it - // Create comments on the submitted assignment - submission = commentSeedsList - .map { - // Create comments with any assigned upload file types - val assignment = commentOnSubmission( - studentToken = studentToken, - courseId = courseId, - assignmentId = assignmentId, - fileIds = it.attachmentsList.filter { it.id != -1L }.map { it.id }.toMutableList()) - - // Apparently, we only care about id and submissionComments - SubmissionApiModel( - id = assignment.id, - submissionComments = assignment.submissionComments!!, - url = null, - body = null, - userId = 0, - grade = null, - attempt = assignment.attempt!! - - ) - } - .lastOrNull() ?: submission // Last one (if it exists) will have all the comments loaded up on it - - // Add submission to our collection - submissionsList.add(submission) - } + // Add submission to our collection + submissionsList.add(submission) } } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt index 4ad56323f6..320922357e 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/UserApi.kt @@ -63,7 +63,7 @@ object UserApi { userAdminService.putSelfSettings(userId, requestApiModel).execute() } - fun createCanvasUser( + fun createCanvasUser( userService: UserService = userAdminService, userDomain: String = CanvasNetworkAdapter.canvasDomain ): CanvasUserApiModel { diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt index 32e38aa4cb..dab41adb43 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/util/Randomizer.kt @@ -18,7 +18,15 @@ package com.instructure.dataseeding.util import com.github.javafaker.Faker -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.model.CreateAssignment +import com.instructure.dataseeding.model.CreateDiscussionTopic +import com.instructure.dataseeding.model.CreateGroup +import com.instructure.dataseeding.model.CreateModule +import com.instructure.dataseeding.model.CreateQuiz +import com.instructure.dataseeding.model.CreateSubmissionComment +import com.instructure.dataseeding.model.GradingType +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.model.SubmitCourseAssignmentSubmission import java.util.* object Randomizer { @@ -60,6 +68,10 @@ object Randomizer { fun randomConversationSubject(): String = faker.chuckNorris().fact() fun randomConversationBody(): String = faker.lorem().paragraph() + fun randomConferenceTitle(): String = faker.chuckNorris().fact() + fun randomConferenceDescription(): String = faker.lorem().paragraph() + + fun randomEnrollmentTitle(): String = "${faker.pokemon()} Term" fun randomGradingPeriodSetTitle(): String = "${faker.pokemon().location()} Set" diff --git a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt index 89eb012d4e..35d43b88d0 100644 --- a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt +++ b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/AssignmentsTest.kt @@ -28,12 +28,12 @@ import com.instructure.dataseeding.api.UserApi import com.instructure.dataseeding.model.AssignmentApiModel import com.instructure.dataseeding.model.AssignmentOverrideApiModel import com.instructure.dataseeding.model.AttachmentApiModel +import com.instructure.dataseeding.model.FileType +import com.instructure.dataseeding.model.GradingType.PERCENT import com.instructure.dataseeding.model.SubmissionApiModel import com.instructure.dataseeding.model.SubmissionCommentApiModel -import com.instructure.dataseeding.model.FileType -import com.instructure.dataseeding.model.SubmissionType.ONLINE_UPLOAD import com.instructure.dataseeding.model.SubmissionType.ONLINE_TEXT_ENTRY -import com.instructure.dataseeding.model.GradingType.PERCENT +import com.instructure.dataseeding.model.SubmissionType.ONLINE_UPLOAD import org.hamcrest.CoreMatchers.instanceOf import org.junit.Assert.* import org.junit.Before @@ -112,7 +112,7 @@ class AssignmentsTest { submissionTypes = listOf(ONLINE_TEXT_ENTRY), teacherToken = teacher.token )) - val submission = SubmissionsApi.commentOnSubmission(student.token,course.id,assignment.id,ArrayList()) + val submission = SubmissionsApi.commentOnSubmission(course.id,student.token,assignment.id,ArrayList()) assertThat(submission, instanceOf(AssignmentApiModel::class.java)) assertEquals(1, submission.submissionComments?.size ?: 0) val comment = submission.submissionComments?.get(0) @@ -283,7 +283,7 @@ class AssignmentsTest { studentToken = student.token, submissionSeedsList = listOf(submissionSeed) ) - val submissions = SubmissionsApi.seedAssignmentSubmission( request ) + val submissions = SubmissionsApi.seedAssignmentSubmission( course.id, student.token, assignment.id, submissionSeedsList = listOf(submissionSeed)) if(submissions.isNotEmpty()) { assertThat(submissions[0], instanceOf(SubmissionApiModel::class.java)) } diff --git a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt index 02fd032053..31b0aeb4d6 100644 --- a/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt +++ b/automation/dataseedingapi/src/test/kotlin/com/instructure/dataseeding/soseedy/ModulesTest.kt @@ -6,7 +6,9 @@ import com.instructure.dataseeding.api.ModulesApi import com.instructure.dataseeding.api.UserApi import com.instructure.dataseeding.model.ModuleApiModel import org.hamcrest.CoreMatchers.instanceOf -import org.junit.Assert.* +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThat +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -57,7 +59,7 @@ class ModulesTest { module = ModulesApi.updateModule( courseId = course.id, - id = module.id, + moduleId = module.id, published = true, teacherToken = teacher.token ) diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt index 8a0d3ff2b5..045d36932b 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt @@ -33,13 +33,13 @@ enum class Priority { enum class FeatureCategory { ASSIGNMENTS, SUBMISSIONS, LOGIN, COURSE, DASHBOARD, GROUPS, SETTINGS, PAGES, DISCUSSIONS, MODULES, INBOX, GRADES, FILES, EVENTS, PEOPLE, CONFERENCES, COLLABORATIONS, SYLLABUS, TODOS, QUIZZES, NOTIFICATIONS, - ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, K5_DASHBOARD, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT, LEFT_SIDE_MENU + ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, CANVAS_FOR_ELEMENTARY, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT, LEFT_SIDE_MENU } enum class SecondaryFeatureCategory { NONE, LOGIN_K5, SUBMISSIONS_TEXT_ENTRY, SUBMISSIONS_ANNOTATIONS, SUBMISSIONS_ONLINE_URL, SUBMISSIONS_MULTIPLE_TYPE, - ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, + ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, HOMEROOM, K5_GRADES, IMPORTANT_DATES, RESOURCES, SCHEDULE, GROUPS_DASHBOARD, GROUPS_FILES, GROUPS_ANNOUNCEMENTS, GROUPS_DISCUSSIONS, GROUPS_PAGES, GROUPS_PEOPLE, EVENTS_DISCUSSIONS, EVENTS_QUIZZES, EVENTS_ASSIGNMENTS, EVENTS_NOTIFICATIONS, MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE, ALL_COURSES, CHANGE_USER, ASSIGNMENT_REMINDER From 68f6b102cdc9514211a8b868cd42d9fded475a96 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Fri, 16 Feb 2024 15:35:39 +0100 Subject: [PATCH 29/52] [MBL-17353][Student] - Remove unused TestRail annotation from teacher page tests. (#2345) --- .../teacher/ui/AssignmentDetailsPageTest.kt | 8 -------- .../teacher/ui/AssignmentDueDatesPageTest.kt | 5 ----- .../teacher/ui/AssignmentListPageTest.kt | 5 ----- .../teacher/ui/CourseBrowserPageTest.kt | 2 -- .../teacher/ui/CourseSettingsPageTest.kt | 4 ---- .../instructure/teacher/ui/DashboardPageTest.kt | 4 ---- .../teacher/ui/EditAssignmentDetailsPageTest.kt | 4 ---- .../teacher/ui/EditDashboardPageTest.kt | 5 ----- .../teacher/ui/LoginFindSchoolPageTest.kt | 2 -- .../instructure/teacher/ui/LoginLandingPageTest.kt | 3 --- .../instructure/teacher/ui/LoginSignInPageTest.kt | 2 -- .../instructure/teacher/ui/QuizDetailsPageTest.kt | 14 +++++--------- .../kotlin/com/instructure/espresso/TestRail.kt | 8 -------- 13 files changed, 5 insertions(+), 61 deletions(-) delete mode 100644 automation/espresso/src/main/kotlin/com/instructure/espresso/TestRail.kt diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt index 935ec1fd2b..33fb1837ee 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDetailsPageTest.kt @@ -32,7 +32,6 @@ import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -42,7 +41,6 @@ import org.junit.Test class AssignmentDetailsPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109579") override fun displaysPageObjects() { getToAssignmentDetailsPage( submissionTypes = listOf(ONLINE_TEXT_ENTRY), @@ -52,35 +50,30 @@ class AssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109579") fun displaysCorrectDetails() { val assignment = getToAssignmentDetailsPage() assignmentDetailsPage.assertAssignmentDetails(assignment) } @Test - @TestRail(ID = "C3109579") fun displaysInstructions() { getToAssignmentDetailsPage(withDescription = true) assignmentDetailsPage.assertDisplaysInstructions() } @Test - @TestRail(ID = "C3134480") fun displaysNoInstructionsMessage() { getToAssignmentDetailsPage() assignmentDetailsPage.assertDisplaysNoInstructionsView() } @Test - @TestRail(ID = "C3134481") fun displaysClosedAvailability() { getToAssignmentDetailsPage(lockAt = 7.days.ago.iso8601) assignmentDetailsPage.assertAssignmentClosed() } @Test - @TestRail(ID = "C313448 2") fun displaysNoFromDate() { val lockAt = 7.days.fromNow.iso8601 getToAssignmentDetailsPage(lockAt = lockAt) @@ -88,7 +81,6 @@ class AssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3134483") fun displaysNoToDate() { getToAssignmentDetailsPage(unlockAt = 7.days.ago.iso8601) assignmentDetailsPage.assertFromFilledAndToEmpty() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt index ca15ef0125..832ffce0e6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentDueDatesPageTest.kt @@ -25,7 +25,6 @@ import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -35,28 +34,24 @@ import org.junit.Test class AssignmentDueDatesPageTest : TeacherTest() { @Test - @TestRail(ID = "C3134131") override fun displaysPageObjects() { getToDueDatesPage() assignmentDueDatesPage.assertPageObjects() } @Test - @TestRail(ID = "C3134484") fun displaysNoDueDate() { getToDueDatesPage() assignmentDueDatesPage.assertDisplaysNoDueDate() } @Test - @TestRail(ID = "C3134485") fun displaysSingleDueDate() { getToDueDatesPage(dueAt = 7.days.fromNow.iso8601) assignmentDueDatesPage.assertDisplaysSingleDueDate() } @Test - @TestRail(ID = "C3134486") fun displaysAvailabilityDates() { getToDueDatesPage(lockAt = 7.days.fromNow.iso8601, unlockAt = 7.days.ago.iso8601) assignmentDueDatesPage.assertDisplaysAvailabilityDates() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt index 545c030706..aed434808d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/AssignmentListPageTest.kt @@ -22,7 +22,6 @@ import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.AssignmentGroup import com.instructure.canvasapi2.models.CanvasContextPermission -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -32,28 +31,24 @@ import org.junit.Test class AssignmentListPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109578") override fun displaysPageObjects() { getToAssignmentsPage() assignmentListPage.assertPageObjects() } @Test - @TestRail(ID = "C3134487") fun displaysNoAssignmentsView() { getToAssignmentsPage(0) assignmentListPage.assertDisplaysNoAssignmentsView() } @Test - @TestRail(ID = "C3109578") fun displaysAssignment() { val assignment = getToAssignmentsPage().assignments.values.first() assignmentListPage.assertHasAssignment(assignment) } @Test - @TestRail(ID = "C3134488") fun displaysGradingPeriods() { getToAssignmentsPage(gradingPeriods = true) assignmentListPage.assertHasGradingPeriods() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt index d79890cb61..23fab0637c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseBrowserPageTest.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -27,7 +26,6 @@ import org.junit.Test class CourseBrowserPageTest : TeacherTest() { @Test - @TestRail(ID = "C3108909") override fun displaysPageObjects() { val data = MockCanvas.init(teacherCount = 1, courseCount = 3, favoriteCourseCount = 3) val teacher = data.teachers[0] diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt index 03a0ce089a..293c165756 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/CourseSettingsPageTest.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.espresso.randomString import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin @@ -28,14 +27,12 @@ import org.junit.Test class CourseSettingsPageTest : TeacherTest() { @Test - @TestRail(ID = "C3108914") override fun displaysPageObjects() { navigateToCourseSettings() courseSettingsPage.assertPageObjects() } @Test - @TestRail(ID = "C3108915") fun editCourseName() { navigateToCourseSettings() courseSettingsPage.clickCourseName() @@ -45,7 +42,6 @@ class CourseSettingsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3108916") fun editCourseHomePage() { navigateToCourseSettings() courseSettingsPage.clickSetHomePage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt index e7c8834be9..ed8c118f6c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/DashboardPageTest.kt @@ -19,7 +19,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -29,7 +28,6 @@ import org.junit.Test class DashboardPageTest : TeacherTest() { @Test - @TestRail(ID = "C3108898") override fun displaysPageObjects() { val data = MockCanvas.init(teacherCount = 1, courseCount = 1, favoriteCourseCount = 1) val teacher = data.teachers[0] @@ -39,7 +37,6 @@ class DashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109494") fun displaysNoCoursesView() { val data = MockCanvas.init(teacherCount = 1, pastCourseCount = 1) val teacher = data.teachers[0] @@ -49,7 +46,6 @@ class DashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3108898") fun displaysCourseList() { val data = MockCanvas.init(teacherCount = 1, favoriteCourseCount = 3, courseCount = 3) val teacher = data.teachers[0] diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt index 4713c578b8..8b529305da 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditAssignmentDetailsPageTest.kt @@ -25,7 +25,6 @@ import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.canvasapi2.utils.NumberHelper -import com.instructure.espresso.TestRail import com.instructure.espresso.randomDouble import com.instructure.espresso.randomString import com.instructure.teacher.R @@ -39,7 +38,6 @@ import org.junit.Test class EditAssignmentDetailsPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109580") override fun displaysPageObjects() { getToEditAssignmentDetailsPage() editAssignmentDetailsPage.assertPageObjects() @@ -55,7 +53,6 @@ class EditAssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3134126") fun editAssignmentName() { getToEditAssignmentDetailsPage() editAssignmentDetailsPage.clickAssignmentNameEditText() @@ -66,7 +63,6 @@ class EditAssignmentDetailsPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3134126") fun editAssignmentPoints() { getToEditAssignmentDetailsPage() editAssignmentDetailsPage.clickPointsPossibleEditText() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt index 6ac25bf8cc..f39d3e15aa 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/EditDashboardPageTest.kt @@ -19,7 +19,6 @@ package com.instructure.teacher.ui import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -29,7 +28,6 @@ import org.junit.Test class EditDashboardPageTest : TeacherTest() { @Test - @TestRail(ID = "C3109572") override fun displaysPageObjects() { setUpAndSignIn(numCourses = 1, numFavoriteCourses = 0) dashboardPage.clickEditDashboard() @@ -37,7 +35,6 @@ class EditDashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109572") fun displaysCourseList() { val data = setUpAndSignIn(numCourses = 1, numFavoriteCourses = 0) val course = data.courses.values.toList()[0] @@ -46,7 +43,6 @@ class EditDashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109574") fun addCourseToFavourites() { val data = setUpAndSignIn(numCourses = 1, numFavoriteCourses = 0) val courses = data.courses.values.toList() @@ -57,7 +53,6 @@ class EditDashboardPageTest : TeacherTest() { } @Test - @TestRail(ID = "C3109575") fun removeCourseFromFavourites() { val data = setUpAndSignIn(numCourses = 1, numFavoriteCourses = 1) val courses = data.courses.values.toList() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt index 0b580f2304..48bdaf59c6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginFindSchoolPageTest.kt @@ -1,6 +1,5 @@ package com.instructure.teacher.ui -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @@ -9,7 +8,6 @@ import org.junit.Test class LoginFindSchoolPageTest: TeacherTest() { @Test - @TestRail(ID = "C3108892") override fun displaysPageObjects() { loginLandingPage.clickFindMySchoolButton() loginFindSchoolPage.assertPageObjects() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt index 2b2e096153..ec1382b24c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginLandingPageTest.kt @@ -1,6 +1,5 @@ package com.instructure.teacher.ui -import com.instructure.espresso.TestRail import com.instructure.espresso.filters.P1 import com.instructure.teacher.ui.utils.TeacherTest import dagger.hilt.android.testing.HiltAndroidTest @@ -11,7 +10,6 @@ class LoginLandingPageTest: TeacherTest() { // Runs live; no MockCanvas @Test - @TestRail(ID = "C3108891") @P1 override fun displaysPageObjects() { loginLandingPage.assertPageObjects() @@ -19,7 +17,6 @@ class LoginLandingPageTest: TeacherTest() { // Runs live; no MockCanvas @Test - @TestRail(ID = "C3108893") fun opensCanvasNetworksSignInPage() { loginLandingPage.clickCanvasNetworkButton() loginSignInPage.assertPageObjects() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt index 898252fea7..2bdbcdd5d0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/LoginSignInPageTest.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.ui -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.enterDomain import dagger.hilt.android.testing.HiltAndroidTest @@ -28,7 +27,6 @@ class LoginSignInPageTest: TeacherTest() { // Runs live; no MockCanvas @Test - @TestRail(ID = "C3108896") override fun displaysPageObjects() { loginLandingPage.clickFindMySchoolButton() enterDomain() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt index 859e63e851..d6177861e7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/QuizDetailsPageTest.kt @@ -15,14 +15,17 @@ */ package com.instructure.teacher.ui -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addQuizSubmission +import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.CanvasContextPermission import com.instructure.canvasapi2.models.Quiz import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.espresso.TestRail import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -32,42 +35,36 @@ import org.junit.Test class QuizDetailsPageTest: TeacherTest() { @Test - @TestRail(ID = "C3109579") override fun displaysPageObjects() { getToQuizDetailsPage() quizDetailsPage.assertPageObjects() } @Test - @TestRail(ID = "C3109579") fun displaysCorrectDetails() { val quiz = getToQuizDetailsPage() quizDetailsPage.assertQuizDetails(quiz) } @Test - @TestRail(ID = "C3109579") fun displaysInstructions() { getToQuizDetailsPage(withDescription = true) quizDetailsPage.assertDisplaysInstructions() } @Test - @TestRail(ID = "C3134480") fun displaysNoInstructionsMessage() { getToQuizDetailsPage() quizDetailsPage.assertDisplaysNoInstructionsView() } @Test - @TestRail(ID = "C3134481") fun displaysClosedAvailability() { getToQuizDetailsPage(lockAt = 1.days.ago.iso8601) quizDetailsPage.assertQuizClosed() } @Test - @TestRail(ID = "C3134482") fun displaysNoFromDate() { val lockAt = 2.days.fromNow.iso8601 getToQuizDetailsPage(lockAt = lockAt) @@ -75,7 +72,6 @@ class QuizDetailsPageTest: TeacherTest() { } @Test - @TestRail(ID = "C3134483") fun displaysNoToDate() { getToQuizDetailsPage(unlockAt = 2.days.ago.iso8601) quizDetailsPage.assertFromFilledAndToEmpty() diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestRail.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestRail.kt deleted file mode 100644 index d00d894208..0000000000 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestRail.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.instructure.espresso - -import java.lang.annotation.Retention -import java.lang.annotation.RetentionPolicy - -@Retention(RetentionPolicy.RUNTIME) -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) -annotation class TestRail(val ID: String = "unknown") \ No newline at end of file From 04007309adc8d3cefa3e9471c0c42d4b80398a7b Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Tue, 20 Feb 2024 15:18:45 +0100 Subject: [PATCH 30/52] [MBL-17382][Student/Teacher] Create build flavor for Compose Bitrise builds #2347 refs: MBL-17382 affects: Student/Teacher release note: none --- apps/student/build.gradle | 8 ++++++++ apps/teacher/build.gradle | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index b2f2ce47d7..669b4a7bb5 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -128,6 +128,14 @@ android { } } + debugMinify { + initWith debug + debuggable false + minifyEnabled true + shrinkResources true + matchingFallbacks = ['debug'] + } + release { signingConfig signingConfigs.release debuggable false diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index 734a9e30ab..2dd5bb1965 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -111,6 +111,15 @@ android { heapEnabled = true } } + + debugMinify { + initWith debug + debuggable false + minifyEnabled true + shrinkResources true + matchingFallbacks = ['debug'] + } + release { minifyEnabled true shrinkResources true From 06121697601240a9797e03e062b7bfcd0d3f0c48 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 20 Feb 2024 19:53:00 +0100 Subject: [PATCH 31/52] [MBL-17246][Teacher] - Extend modules e2e test with navigating by previous/next button and publishing on the UI (#2346) --- .../teacher/ui/e2e/ModulesE2ETest.kt | 167 +++++++++++------- .../teacher/ui/pages/AssignmentDetailsPage.kt | 15 +- .../ui/pages/DiscussionsDetailsPage.kt | 3 +- .../teacher/ui/pages/EditPageDetailsPage.kt | 15 +- .../teacher/ui/pages/QuizDetailsPage.kt | 29 ++- .../teacher/ui/utils/TeacherTest.kt | 11 +- .../dataseeding/model/DiscussionApiModel.kt | 4 +- .../espresso/ModuleItemInteractions.kt | 80 +++++++++ .../com/instructure/espresso/TestingUtils.kt | 6 + 9 files changed, 249 insertions(+), 81 deletions(-) create mode 100644 automation/espresso/src/main/kotlin/com/instructure/espresso/ModuleItemInteractions.kt diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt index 4049eb69bd..df9557786c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt @@ -2,7 +2,6 @@ package com.instructure.teacher.ui.e2e import android.util.Log import androidx.test.espresso.Espresso -import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority @@ -24,7 +23,6 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.teacher.ui.pages.WebViewTextCheck import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.seedData import com.instructure.teacher.ui.utils.tokenLogin @@ -47,53 +45,53 @@ class ModulesE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}. Assert that ${course.name} course is displayed on the Dashboard.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'. Assert that '${course.name}' course is displayed on the Dashboard.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Modules Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Modules Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openModulesTab() Log.d(STEP_TAG,"Assert that empty view is displayed because there is no Module within the course.") - modulesPage.assertEmptyView() + moduleListPage.assertEmptyView() - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") val assignment = createAssignment(course, teacher) - Log.d(PREPARATION_TAG,"Seeding quiz for ${course.name} course.") + Log.d(PREPARATION_TAG,"Seeding quiz for '${course.name}' course.") val quiz = createQuiz(course, teacher) - Log.d(PREPARATION_TAG,"Create an unpublished page for course: ${course.name}.") + Log.d(PREPARATION_TAG,"Create an unpublished page for course: '${course.name}'.") val testPage = createCoursePage(course, teacher, published = false, frontPage = false, body = "

Test Page Text

") - Log.d(PREPARATION_TAG,"Create a discussion topic for ${course.name} course.") + Log.d(PREPARATION_TAG,"Create a discussion topic for '${course.name}' course.") val discussionTopic = createDiscussion(course, teacher) - Log.d(PREPARATION_TAG,"Seeding a module for ${course.name} course. It starts as unpublished.") + Log.d(PREPARATION_TAG,"Seeding a module for '${course.name}' course. It starts as unpublished.") val module = createModule(course, teacher) - Log.d(PREPARATION_TAG,"Associate ${assignment.name} assignment (and the quiz within it) with module: ${module.id}.") + Log.d(PREPARATION_TAG,"Associate '${assignment.name}' assignment (and the quiz within it) with module: '${module.id}'.") createModuleItem(course, module, teacher, assignment.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment.id.toString()) createModuleItem(course, module, teacher, quiz.title, ModuleItemTypes.QUIZ.stringVal, quiz.id.toString()) - Log.d(PREPARATION_TAG,"Associate ${testPage.title} page with module: ${module.id}.") + Log.d(PREPARATION_TAG,"Associate '${testPage.title}' page with module: '${module.id}'.") createModuleItem(course, module, teacher, testPage.title, ModuleItemTypes.PAGE.stringVal, null, pageUrl = testPage.url) - Log.d(PREPARATION_TAG,"Associate ${discussionTopic.title} discussion with module: ${module.id}.") + Log.d(PREPARATION_TAG,"Associate '${discussionTopic.title}' discussion with module: '${module.id}'.") createModuleItem(course, module, teacher, discussionTopic.title, ModuleItemTypes.DISCUSSION.stringVal, discussionTopic.id.toString()) - Log.d(STEP_TAG,"Refresh the page. Assert that ${module.name} module is displayed and it is unpublished by default.") - modulesPage.refresh() - modulesPage.assertModuleIsDisplayed(module.name) - modulesPage.assertModuleNotPublished() + Log.d(STEP_TAG,"Refresh the page. Assert that '${module.name}' module is displayed and it is unpublished by default.") + moduleListPage.refresh() + moduleListPage.assertModuleIsDisplayed(module.name) + moduleListPage.assertModuleNotPublished() - Log.d(STEP_TAG,"Assert that ${testPage.title} page is present as a module item, but it's not published.") - modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemNotPublished(module.name, testPage.title) + Log.d(STEP_TAG,"Assert that '${testPage.title}' page is present as a module item, but it's not published.") + moduleListPage.assertModuleItemIsDisplayed(testPage.title) + moduleListPage.assertModuleItemNotPublished(module.name, testPage.title) - Log.d(PREPARATION_TAG,"Publish ${module.name} module via API.") + Log.d(PREPARATION_TAG,"Publish '${module.name}' module via API.") ModulesApi.updateModule( courseId = course.id, moduleId = module.id, @@ -101,28 +99,74 @@ class ModulesE2ETest : TeacherTest() { teacherToken = teacher.token ) - Log.d(STEP_TAG,"Refresh the page. Assert that ${module.name} module is displayed and it is published.") - modulesPage.refresh() - modulesPage.assertModuleIsDisplayed(module.name) - modulesPage.assertModuleIsPublished() + Log.d(STEP_TAG,"Refresh the page. Assert that '${module.name}' module is displayed and it is published.") + moduleListPage.refresh() + moduleListPage.assertModuleIsDisplayed(module.name) + moduleListPage.assertModuleIsPublished() - Log.d(STEP_TAG,"Assert that ${assignment.name} assignment and ${quiz.title} quiz are present as module items, and they are published since their module is published.") - modulesPage.assertModuleItemIsDisplayed(assignment.name) - modulesPage.assertModuleItemIsPublished(assignment.name) - modulesPage.assertModuleItemIsDisplayed(quiz.title) - modulesPage.assertModuleItemIsPublished(quiz.title) + Log.d(STEP_TAG,"Assert that '${assignment.name}' assignment and '${quiz.title}' quiz are present as module items, and they are published since their module is published.") + moduleListPage.assertModuleItemIsDisplayed(assignment.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsDisplayed(quiz.title) + moduleListPage.assertModuleItemIsPublished(quiz.title) - Log.d(STEP_TAG,"Assert that ${testPage.title} page is present as a module item, but it's not published.") - modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemIsPublished(testPage.title) + Log.d(STEP_TAG,"Assert that '${testPage.title}' page is present as a module item, but it's not published.") + moduleListPage.assertModuleItemIsDisplayed(testPage.title) + moduleListPage.assertModuleItemIsPublished(testPage.title) - Log.d(STEP_TAG, "Collapse the ${module.name} and assert that the module items has not displayed.") - modulesPage.clickOnCollapseExpandIcon() - modulesPage.assertItemCountInModule(module.name, 0) + Log.d(STEP_TAG, "Collapse the '${module.name}' and assert that the module items has not displayed.") + moduleListPage.clickOnCollapseExpandIcon() + moduleListPage.assertItemCountInModule(module.name, 0) - Log.d(STEP_TAG, "Expand the ${module.name} and assert that the module items are displayed.") - modulesPage.clickOnCollapseExpandIcon() - modulesPage.assertItemCountInModule(module.name, 4) + Log.d(STEP_TAG, "Expand the '${module.name}' and assert that the module items are displayed.") + moduleListPage.clickOnCollapseExpandIcon() + moduleListPage.assertItemCountInModule(module.name, 4) + + Log.d(STEP_TAG, "Open the '${assignment.name}' assignment module item and assert that the Assignment Details Page is displayed. Assert that the module name is displayed at the bottom.") + moduleListPage.clickOnModuleItem(assignment.name) + assignmentDetailsPage.assertPageObjects() + assignmentDetailsPage.assertAssignmentDetails(assignment) + assignmentDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that the previous arrow button is not displayed because the user is on the first assignment's details page, but the next arrow button is displayed.") + assignmentDetailsPage.moduleItemInteractions.assertPreviousArrowNotDisplayed() + assignmentDetailsPage.moduleItemInteractions.assertNextArrowDisplayed() + + Log.d(STEP_TAG, "Click on the next arrow button and assert that the '${quiz.title}' quiz module item's details page is displayed. Assert that the module name is displayed at the bottom.") + assignmentDetailsPage.moduleItemInteractions.clickOnNextArrow() + quizDetailsPage.assertQuizDetails(quiz) + quizDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that both the previous and next arrow buttons are displayed.") + quizDetailsPage.moduleItemInteractions.assertPreviousArrowDisplayed() + quizDetailsPage.moduleItemInteractions.assertNextArrowDisplayed() + + Log.d(STEP_TAG, "Click on the next arrow button and assert that the '${testPage.title}' page module item's details page is displayed. Assert that the module name is displayed at the bottom.") + quizDetailsPage.moduleItemInteractions.clickOnNextArrow() + editPageDetailsPage.assertPageDetails(testPage) + editPageDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that both the previous and next arrow buttons are displayed.") + editPageDetailsPage.moduleItemInteractions.assertPreviousArrowDisplayed() + editPageDetailsPage.moduleItemInteractions.assertNextArrowDisplayed() + + Log.d(STEP_TAG, "Click on the next arrow button and assert that the '${discussionTopic.title}' discussion module item's details page is displayed. Assert that the module name is displayed at the bottom.") + editPageDetailsPage.moduleItemInteractions.clickOnNextArrow() + discussionsDetailsPage.assertDiscussionTitle(discussionTopic.title) + discussionsDetailsPage.assertDiscussionPublished() + discussionsDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Assert that the next arrow button is not displayed because the user is on the last assignment's details page, but the previous arrow button is displayed.") + discussionsDetailsPage.moduleItemInteractions.assertPreviousArrowDisplayed() + discussionsDetailsPage.moduleItemInteractions.assertNextArrowNotDisplayed() + + Log.d(STEP_TAG, "Click on the previous arrow button and assert that the '${testPage.title}' page module item's details page is displayed. Assert that the module name is displayed at the bottom.") + quizDetailsPage.moduleItemInteractions.clickOnPreviousArrow() + editPageDetailsPage.assertPageDetails(testPage) + editPageDetailsPage.moduleItemInteractions.assertModuleNameDisplayed(module.name) + + Log.d(STEP_TAG, "Navigate back to Module List Page.") + Espresso.pressBack() Log.d(PREPARATION_TAG,"Unpublish ${module.name} module via API.") ModulesApi.updateModule( @@ -133,34 +177,33 @@ class ModulesE2ETest : TeacherTest() { ) Log.d(STEP_TAG, "Refresh the Modules Page.") - modulesPage.refresh() + moduleListPage.refresh() Log.d(STEP_TAG,"Assert that ${assignment.name} assignment and ${quiz.title} quiz and ${testPage.title} page are present as module items, and they are NOT published since their module is unpublished.") - modulesPage.assertModuleItemIsDisplayed(assignment.name) - modulesPage.assertModuleItemNotPublished(module.name, assignment.name) - modulesPage.assertModuleItemIsDisplayed(quiz.title) - modulesPage.assertModuleItemNotPublished(module.name, quiz.title) - modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemNotPublished(module.name, testPage.title) - - Log.d(STEP_TAG, "Open the ${assignment.name} assignment module item and assert that the Assignment Details Page is displayed. Navigate back to Modules Page.") - modulesPage.clickOnModuleItem(assignment.name) - assignmentDetailsPage.assertPageObjects() - Espresso.pressBack() + moduleListPage.assertModuleItemIsDisplayed(assignment.name) + moduleListPage.assertModuleItemNotPublished(module.name, assignment.name) + moduleListPage.assertModuleItemIsDisplayed(quiz.title) + moduleListPage.assertModuleItemNotPublished(module.name, quiz.title) + moduleListPage.assertModuleItemIsDisplayed(testPage.title) + moduleListPage.assertModuleItemNotPublished(module.name, testPage.title) - Log.d(STEP_TAG, "Open the ${quiz.title} quiz module item and assert that the Quiz Details Page is displayed. Navigate back to Modules Page.") - modulesPage.clickOnModuleItem(quiz.title) - quizDetailsPage.assertPageObjects() - Espresso.pressBack() + Log.d(STEP_TAG, "Open the '${assignment.name}' assignment module item and assert that the Assignment Details Page is displayed") + moduleListPage.clickOnModuleItem(assignment.name) - Log.d(STEP_TAG, "Open the ${testPage.title} page module item and assert that the Page Details Page is displayed. Navigate back to Modules Page.") - modulesPage.clickOnModuleItem(testPage.title) - editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Test Page Text")) - Espresso.pressBack() + Log.d(STEP_TAG, "Assert that the published status of the '${assignment.name}' assignment became 'Unpublished' on the Assignment Details Page.") + assignmentDetailsPage.assertPublishedStatus(false) + + Log.d(STEP_TAG, "Open Edit Page of '${assignment.name}' assignment and publish it. Save the change.") + assignmentDetailsPage.openEditPage() + editAssignmentDetailsPage.clickPublishSwitch() + editAssignmentDetailsPage.saveAssignment() - Log.d(STEP_TAG, "Open the ${discussionTopic.title} discussion module item and assert that the Discussion Details Page is displayed.") - modulesPage.clickOnModuleItem(discussionTopic.title) - discussionsDetailsPage.assertPageObjects() + Log.d(STEP_TAG, "Assert that the published status of the '${assignment.name}' assignment became 'Published' on the Assignment Details Page (as well).") + assignmentDetailsPage.assertPublishedStatus(true) + + Log.d(STEP_TAG, "Navigate back to Module List Page and assert that the '${assignment.name}' assignment module item's status became 'Published'.") + Espresso.pressBack() + moduleListPage.assertModuleItemIsPublished(assignment.name) } private fun createModuleItem( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt index f8a5319490..51a70e4d93 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/AssignmentDetailsPage.kt @@ -17,6 +17,7 @@ package com.instructure.teacher.ui.pages import androidx.test.InstrumentationRegistry +import androidx.test.espresso.ViewInteraction import androidx.test.espresso.web.assertion.WebViewAssertions import androidx.test.espresso.web.sugar.Web import androidx.test.espresso.web.webdriver.DriverAtoms @@ -34,7 +35,7 @@ import org.hamcrest.Matchers * @constructor Create empty Assignment details page */ @Suppress("unused") -class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { +class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage(pageResId = R.id.assignmentDetailsPage) { private val backButton by OnViewWithContentDescription(androidx.appcompat.R.string.abc_action_bar_up_description,false) private val toolbarTitle by OnViewWithText(R.string.assignment_details) @@ -132,7 +133,7 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { * @param assignment */ fun assertAssignmentDetails(assignment: Assignment) { - assertAssignmentDetails(assignment.name!!, assignment.published) + assertAssignmentDetails(assignmentNameTextView, assignment.name!!, assignment.published) } /** @@ -141,7 +142,7 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { * @param assignment */ fun assertAssignmentDetails(assignment: AssignmentApiModel) { - assertAssignmentDetails(assignment.name, assignment.published) + assertAssignmentDetails(assignmentNameTextView, assignment.name, assignment.published) } /** @@ -333,13 +334,13 @@ class AssignmentDetailsPage : BasePage(pageResId = R.id.assignmentDetailsPage) { } /** - * Assert assignment details + * Assert module item details * - * @param assignmentName + * @param moduleItemName * @param published */ - private fun assertAssignmentDetails(assignmentName: String, published: Boolean) { - assignmentNameTextView.assertHasText(assignmentName) + private fun assertAssignmentDetails(viewInteraction: ViewInteraction, moduleItemName: String, published: Boolean) { + viewInteraction.assertHasText(moduleItemName) assertPublishedStatus(published) } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt index 65f2600f8d..472b6af6ba 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/DiscussionsDetailsPage.kt @@ -16,6 +16,7 @@ */ package com.instructure.teacher.ui.pages +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText import com.instructure.espresso.assertNotDisplayed @@ -28,7 +29,7 @@ import com.instructure.espresso.swipeDown import com.instructure.teacher.R import com.instructure.teacher.ui.utils.TypeInRCETextEditor -class DiscussionsDetailsPage : BasePage() { +class DiscussionsDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage() { /** * Asserts that the discussion has the specified [title]. diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt index b68f2d69f9..6f89f22fd8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/EditPageDetailsPage.kt @@ -9,9 +9,12 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.getText import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.checkToastText import com.instructure.canvas.espresso.withElementRepeat +import com.instructure.dataseeding.model.PageApiModel import com.instructure.espresso.ActivityHelper +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.click +import com.instructure.espresso.extractInnerTextById import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.replaceText @@ -27,7 +30,7 @@ import org.hamcrest.Matchers.containsString * * @constructor Creates an instance of `EditPageDetailsPage`. */ -class EditPageDetailsPage : BasePage() { +class EditPageDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage() { private val contentRceView by WaitForViewWithId(R.id.rce_webView) /** @@ -104,6 +107,16 @@ class EditPageDetailsPage : BasePage() { savePage() checkToastText(R.string.frontPageUnpublishedError, ActivityHelper.currentActivity()) } + + /** + * Assert that the page's body is equal to the expected + * + * @param page The page object to assert. + */ + fun assertPageDetails(page: PageApiModel) { + val innerText = extractInnerTextById(page.body, "header1") + runTextChecks(WebViewTextCheck(Locator.ID, "header1", innerText!!)) + } } data class WebViewTextCheck( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt index 19d6249ab0..7f683cdb4c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/QuizDetailsPage.kt @@ -16,8 +16,9 @@ package com.instructure.teacher.ui.pages import androidx.test.InstrumentationRegistry -import androidx.test.espresso.matcher.ViewMatchers.withId import com.instructure.canvasapi2.models.Quiz +import com.instructure.dataseeding.model.QuizApiModel +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.OnViewWithContentDescription import com.instructure.espresso.OnViewWithId import com.instructure.espresso.OnViewWithText @@ -34,6 +35,7 @@ import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.scrollTo import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withId import com.instructure.espresso.swipeDown import com.instructure.teacher.R @@ -46,7 +48,7 @@ import com.instructure.teacher.R * that can be accessed for performing assertions and interactions. The page has a specific resource ID * associated with it, which is R.id.quizDetailsPage. */ -class QuizDetailsPage : BasePage(pageResId = R.id.quizDetailsPage) { +class QuizDetailsPage(val moduleItemInteractions: ModuleItemInteractions) : BasePage(pageResId = R.id.quizDetailsPage) { private val backButton by OnViewWithContentDescription(R.string.abc_action_bar_up_description,false) private val toolbarTitle by OnViewWithText(R.string.quiz_details) @@ -113,8 +115,27 @@ class QuizDetailsPage : BasePage(pageResId = R.id.quizDetailsPage) { * @param quiz The Quiz object representing the quiz details. */ fun assertQuizDetails(quiz: Quiz) { - quizTitleTextView.assertHasText(quiz.title!!) - if (quiz.published) { + assertQuizDetails(quiz.title!!, quiz.published) + } + + /** + * Asserts the quiz details such as title and publish status. + * + * @param quiz The Quiz object representing the quiz details. + */ + fun assertQuizDetails(quiz: QuizApiModel) { + assertQuizDetails(quiz.title, quiz.published) + } + + /** + * Assert quiz details + * Private method used for overloading. + * @param quizTitle The quiz's title + * @param published The quiz's published status + */ + private fun assertQuizDetails(quizTitle: String, published: Boolean) { + quizTitleTextView.assertHasText(quizTitle) + if (published) { publishStatusTextView.assertHasText(R.string.published) } else { publishStatusTextView.assertHasText(R.string.not_published) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index dd1f97d460..be9031ebc5 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -25,6 +25,7 @@ import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule +import com.instructure.espresso.ModuleItemInteractions import com.instructure.espresso.Searchable import com.instructure.teacher.BuildConfig import com.instructure.teacher.R @@ -125,7 +126,7 @@ abstract class TeacherTest : CanvasTest() { val addMessagePage = AddMessagePage() val announcementsListPage = AnnouncementsListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assigneeListPage = AssigneeListPage() - val assignmentDetailsPage = AssignmentDetailsPage() + val assignmentDetailsPage = AssignmentDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val assignmentDueDatesPage = AssignmentDueDatesPage() val assignmentListPage = AssignmentListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assignmentSubmissionListPage = AssignmentSubmissionListPage() @@ -145,12 +146,12 @@ abstract class TeacherTest : CanvasTest() { val remoteConfigSettingsPage = RemoteConfigSettingsPage() val profileSettingsPage = ProfileSettingsPage() val editProfileSettingsPage = EditProfileSettingsPage() - val discussionsDetailsPage = DiscussionsDetailsPage() + val discussionsDetailsPage = DiscussionsDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val discussionsListPage = DiscussionsListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val editAnnouncementDetailsPage = EditAnnouncementDetailsPage() val editAssignmentDetailsPage = EditAssignmentDetailsPage() val editDiscussionsDetailsPage = EditDiscussionsDetailsPage() - val editPageDetailsPage = EditPageDetailsPage() + val editPageDetailsPage = EditPageDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val editQuizDetailsPage = EditQuizDetailsPage() val editSyllabusPage = EditSyllabusPage() val inboxMessagePage = InboxMessagePage() @@ -158,12 +159,12 @@ abstract class TeacherTest : CanvasTest() { val loginFindSchoolPage = LoginFindSchoolPage() val loginLandingPage = LoginLandingPage() val loginSignInPage = LoginSignInPage() - val modulesPage = ModulesPage() + val moduleListPage = ModulesPage() val navDrawerPage = NavDrawerPage() val notATeacherPage = NotATeacherPage() val pageListPage = PageListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val peopleListPage = PeopleListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) - val quizDetailsPage = QuizDetailsPage() + val quizDetailsPage = QuizDetailsPage(ModuleItemInteractions(R.id.moduleName, R.id.next, R.id.previous)) val quizListPage = QuizListPage(Searchable(R.id.search, R.id.search_src_text, R.id.clearButton, R.id.backButton)) val quizSubmissionListPage = QuizSubmissionListPage() val speedGraderCommentsPage = SpeedGraderCommentsPage() diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt index ccfbae6996..8bc6092a40 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/DiscussionApiModel.kt @@ -28,7 +28,9 @@ data class DiscussionApiModel ( @SerializedName("locked_for_user") val lockedForUser: Boolean, @SerializedName("locked") - val locked: Boolean + val locked: Boolean, + @SerializedName("published") + val published: Boolean? = true ) data class CreateDiscussionTopic( diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/ModuleItemInteractions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/ModuleItemInteractions.kt new file mode 100644 index 0000000000..e32d598311 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/ModuleItemInteractions.kt @@ -0,0 +1,80 @@ +package com.instructure.espresso + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.instructure.espresso.page.plus + +class ModuleItemInteractions(private val moduleNameId: Int? = null, private val nextArrowId: Int? = null, private val previousArrowId: Int? = null) { + + /** + * Assert module name displayed + * + * @param moduleName + */ + fun assertModuleNameDisplayed(moduleName: String) { + onView(moduleNameId?.let { withId(it) + ViewMatchers.withText(moduleName)}).assertDisplayed() + } + + + /** + * Click on next arrow to navigate to the next module item's details + * + */ + fun clickOnNextArrow() { + onView(nextArrowId?.let { withId(it) }).click() + } + + /** + * Click on previous arrow to navigate to the previous module item's details + * + */ + fun clickOnPreviousArrow() { + onView(previousArrowId?.let { withId(it) }).click() + } + + /** + * Assert previous arrow not displayed (e.g. invisible) + * + */ + fun assertPreviousArrowNotDisplayed() { + onView(previousArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.INVISIBLE))) + } + + /** + * Assert previous arrow displayed + * + */ + fun assertPreviousArrowDisplayed() { + onView(previousArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.VISIBLE))) + } + + /** + * Assert next arrow displayed + * + */ + fun assertNextArrowDisplayed() { + onView(nextArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.VISIBLE))) + } + + /** + * Assert next arrow not displayed (e.g. invisible) + * + */ + fun assertNextArrowNotDisplayed() { + onView(nextArrowId?.let { withId(it) }).check( + ViewAssertions.matches( + ViewMatchers.withEffectiveVisibility( + ViewMatchers.Visibility.INVISIBLE))) + } +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt index 2901820aae..42fe0e5770 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt @@ -91,4 +91,10 @@ fun retryWithIncreasingDelay( } } block() +} + +fun extractInnerTextById(html: String, id: String): String? { + val pattern = "<[^>]*?\\bid=\"$id\"[^>]*?>(.*?)]*?>".toRegex(RegexOption.DOT_MATCHES_ALL) + val matchResult = pattern.find(html) + return matchResult?.groupValues?.getOrNull(1) } \ No newline at end of file From d4cbe8ffc9d32ba026b68c881ae995199658a0e4 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Fri, 23 Feb 2024 13:01:35 +0100 Subject: [PATCH 32/52] [MBL-17279][Teacher] Module UI tests (#2350) Test plan: - refs: MBL-17279 affects: Teacher release note: none --- apps/teacher/build.gradle | 2 + .../teacher/ui/ModuleListPageTest.kt | 422 ++++++++++++++++++ .../ui/UpdateFilePermissionsPageTest.kt | 184 ++++++++ .../teacher/ui/e2e/ModulesE2ETest.kt | 8 +- .../teacher/ui/pages/ModulesPage.kt | 81 +++- .../teacher/ui/pages/ProgressPage.kt | 32 ++ .../ui/pages/UpdateFilePermissionsPage.kt | 147 ++++++ .../ui/renderTests/ModuleListRenderTest.kt | 44 +- .../renderTests/pages/ModuleListRenderPage.kt | 10 +- .../teacher/ui/utils/TeacherComposeTest.kt | 32 ++ .../teacher/ui/utils/TeacherTest.kt | 2 + .../modules/list/ui/ModuleListView.kt | 14 +- .../list/ui/binders/ModuleListItemBinder.kt | 20 +- .../list/ui/file/UpdateFileDialogFragment.kt | 5 +- .../layout/fragment_dialog_update_file.xml | 6 + .../dataseeding/util/Randomizer.kt | 2 + .../instructure/canvas/espresso/CanvasTest.kt | 2 +- .../canvas/espresso/mockCanvas/MockCanvas.kt | 20 +- .../mockCanvas/endpoints/ApiEndpoint.kt | 80 +++- .../mockCanvas/endpoints/CourseEndpoints.kt | 66 +++ .../espresso/mockCanvas/utils/PathUtils.kt | 1 + .../espresso/ViewInteractionExtensions.kt | 16 + .../matchers/WithDrawableViewMatcher.kt | 46 -- buildSrc/src/main/java/GlobalDependencies.kt | 2 + libs/pandares/src/main/res/values/strings.xml | 8 + libs/pandautils/build.gradle | 3 + .../pandautils/compose/ProgressScreenTest.kt | 130 ++++++ 27 files changed, 1283 insertions(+), 102 deletions(-) create mode 100644 apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt create mode 100644 apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt create mode 100644 apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt create mode 100644 apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/UpdateFilePermissionsPage.kt create mode 100644 apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt delete mode 100644 automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt create mode 100644 libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/ProgressScreenTest.kt diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index 2dd5bb1965..ee6e4fa51d 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -320,6 +320,8 @@ dependencies { implementation Libs.ROOM_COROUTINES testImplementation Libs.HAMCREST + + androidTestImplementation Libs.COMPOSE_UI_TEST } apply plugin: 'com.google.gms.google-services' diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt new file mode 100644 index 0000000000..26915f5dcc --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt @@ -0,0 +1,422 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.ui + +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addFileToCourse +import com.instructure.canvas.espresso.mockCanvas.addItemToModule +import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.Tab +import com.instructure.dataseeding.util.Randomizer +import com.instructure.teacher.R +import com.instructure.teacher.ui.utils.TeacherComposeTest +import com.instructure.teacher.ui.utils.openOverflowMenu +import com.instructure.teacher.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class ModuleListPageTest : TeacherComposeTest() { + + @Test + override fun displaysPageObjects() { + goToModulesPage() + modulesPage.assertPageObjects() + } + + @Test + fun assertDisplaysMenuItems() { + goToModulesPage() + openOverflowMenu() + modulesPage.assertToolbarMenuItems() + } + + @Test + fun assertDisplaysModuleMenuItems() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + + modulesPage.clickItemOverflow(module.name.orEmpty()) + modulesPage.assertModuleMenuItems() + } + + @Test + fun assertPublishedItemActions() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = true) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(assignment.name.orEmpty()) + modulesPage.assertOverflowItem(R.string.unpublish) + } + + @Test + fun assertUnpublishedItemActions() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = false) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(assignment.name.orEmpty()) + modulesPage.assertOverflowItem(R.string.publish) + } + + @Test + fun assertFileEditOpens() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!! + ) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + modulesPage.assertFileEditDialogVisible() + } + + @Test + fun publishModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = false) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(assignment.name.orEmpty()) + modulesPage.clickOnText(R.string.publishModuleItemAction) + modulesPage.clickOnText(R.string.publishDialogPositiveButton) + + modulesPage.assertSnackbarText(R.string.moduleItemPublished) + modulesPage.assertModuleItemIsPublished(assignment.name.orEmpty()) + } + + @Test + fun unpublishModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val assignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY) + ) + + data.addItemToModule(data.courses.values.first(), module.id, assignment, published = true) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(assignment.name.orEmpty()) + modulesPage.clickOnText(R.string.unpublishModuleItemAction) + modulesPage.clickOnText(R.string.unpublishDialogPositiveButton) + + modulesPage.assertSnackbarText(R.string.moduleItemUnpublished) + modulesPage.assertModuleItemNotPublished(assignment.name.orEmpty()) + } + + @Test + fun publishModuleOnly() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 1) + val unpublishedModule = data.courseModules.values.first().first { it.published == false } + val assignment = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModule.id, assignment, published = false) + modulesPage.refresh() + + modulesPage.clickItemOverflow(unpublishedModule.name.orEmpty()) + modulesPage.clickOnText(R.string.publishModuleOnly) + modulesPage.clickOnText(R.string.publishDialogPositiveButton) + + modulesPage.assertSnackbarText(R.string.onlyModulePublished) + modulesPage.assertModuleIsPublished(unpublishedModule.name.orEmpty()) + modulesPage.assertModuleItemNotPublished(assignment.name.orEmpty()) + } + + @Test + fun publishModuleAndItems() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 1) + val unpublishedModule = data.courseModules.values.first().first { it.published == false } + val assignment = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModule.id, assignment, published = false) + modulesPage.refresh() + + modulesPage.clickItemOverflow(unpublishedModule.name.orEmpty()) + modulesPage.clickOnText(R.string.publishModuleAndItems) + modulesPage.clickOnText(R.string.publishDialogPositiveButton) + + progressPage.clickDone() + + modulesPage.assertSnackbarText(R.string.moduleAndAllItemsPublished) + modulesPage.assertModuleIsPublished(unpublishedModule.name.orEmpty()) + modulesPage.assertModuleItemIsPublished(assignment.name.orEmpty()) + } + + @Test + fun unpublishModuleAndItems() { + val data = goToModulesPage(publishedModuleCount = 1, unpublishedModuleCount = 0) + val publishedModule = data.courseModules.values.first().first { it.published == true } + val assignment = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), publishedModule.id, assignment, published = true) + modulesPage.refresh() + + modulesPage.clickItemOverflow(publishedModule.name.orEmpty()) + modulesPage.clickOnText(R.string.unpublishModuleAndItems) + modulesPage.clickOnText(R.string.unpublishDialogPositiveButton) + + progressPage.clickDone() + + modulesPage.assertSnackbarText(R.string.moduleAndAllItemsUnpublished) + modulesPage.assertModuleNotPublished(publishedModule.name.orEmpty()) + modulesPage.assertModuleItemNotPublished(assignment.name.orEmpty()) + } + + @Test + fun publishModulesOnly() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 2) + val unpublishedModules = data.courseModules.values.first().filter { it.published == false } + val assignment1 = data.addAssignment(courseId = data.courses.values.first().id) + val assignment2 = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModules[0].id, assignment1, published = false) + data.addItemToModule(data.courses.values.first(), unpublishedModules[1].id, assignment2, published = false) + + modulesPage.refresh() + + openOverflowMenu() + modulesPage.clickOnText(R.string.publishModulesOnly) + modulesPage.clickOnText(R.string.publishDialogPositiveButton) + + progressPage.clickDone() + + modulesPage.assertSnackbarText(R.string.onlyModulesPublished) + modulesPage.assertModuleIsPublished(unpublishedModules[0].name.orEmpty()) + modulesPage.assertModuleIsPublished(unpublishedModules[1].name.orEmpty()) + modulesPage.assertModuleItemNotPublished(assignment1.name.orEmpty()) + modulesPage.assertModuleItemNotPublished(assignment2.name.orEmpty()) + } + + @Test + fun publishModulesAndItems() { + val data = goToModulesPage(publishedModuleCount = 0, unpublishedModuleCount = 2) + val unpublishedModules = data.courseModules.values.first().filter { it.published == false } + val assignment1 = data.addAssignment(courseId = data.courses.values.first().id) + val assignment2 = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModules[0].id, assignment1, published = false) + data.addItemToModule(data.courses.values.first(), unpublishedModules[1].id, assignment2, published = false) + + modulesPage.refresh() + + openOverflowMenu() + modulesPage.clickOnText(R.string.publishAllModulesAndItems) + modulesPage.clickOnText(R.string.publishDialogPositiveButton) + + progressPage.clickDone() + + modulesPage.assertSnackbarText(R.string.allModulesAndAllItemsPublished) + modulesPage.assertModuleIsPublished(unpublishedModules[0].name.orEmpty()) + modulesPage.assertModuleIsPublished(unpublishedModules[1].name.orEmpty()) + modulesPage.assertModuleItemIsPublished(assignment1.name.orEmpty()) + modulesPage.assertModuleItemIsPublished(assignment2.name.orEmpty()) + } + + @Test + fun unpublishModulesAndItems() { + val data = goToModulesPage(publishedModuleCount = 2, unpublishedModuleCount = 0) + val unpublishedModules = data.courseModules.values.first().filter { it.published == true } + val assignment1 = data.addAssignment(courseId = data.courses.values.first().id) + val assignment2 = data.addAssignment(courseId = data.courses.values.first().id) + + data.addItemToModule(data.courses.values.first(), unpublishedModules[0].id, assignment1, published = true) + data.addItemToModule(data.courses.values.first(), unpublishedModules[1].id, assignment2, published = true) + + modulesPage.refresh() + + openOverflowMenu() + modulesPage.clickOnText(R.string.unpublishAllModulesAndItems) + modulesPage.clickOnText(R.string.unpublishDialogPositiveButton) + + progressPage.clickDone() + + modulesPage.assertSnackbarText(R.string.allModulesAndAllItemsUnpublished) + modulesPage.assertModuleNotPublished(unpublishedModules[0].name.orEmpty()) + modulesPage.assertModuleNotPublished(unpublishedModules[1].name.orEmpty()) + modulesPage.assertModuleItemNotPublished(assignment1.name.orEmpty()) + modulesPage.assertModuleItemNotPublished(assignment2.name.orEmpty()) + } + + @Test + fun unpublishFileModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = true, + moduleContentDetails = ModuleContentDetails( + hidden = false, + locked = false + ) + ) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + updateFilePermissionsPage.swipeUpBottomSheet() + updateFilePermissionsPage.clickUnpublishRadioButton() + updateFilePermissionsPage.clickSaveButton() + + modulesPage.assertModuleItemNotPublished(fileFolder.displayName.orEmpty()) + } + + @Test + fun publishFileModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = false, + moduleContentDetails = ModuleContentDetails( + hidden = false, + locked = true + ) + ) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + updateFilePermissionsPage.swipeUpBottomSheet() + updateFilePermissionsPage.clickPublishRadioButton() + updateFilePermissionsPage.clickSaveButton() + + modulesPage.assertModuleItemIsPublished(fileFolder.displayName.orEmpty()) + } + + @Test + fun hideFileModuleItem() { + val data = goToModulesPage() + val module = data.courseModules.values.first().first() + val course = data.courses.values.first() + val fileId = data.addFileToCourse(course.id) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = true, + moduleContentDetails = ModuleContentDetails( + hidden = false, + locked = false + ) + ) + + modulesPage.refresh() + + modulesPage.clickItemOverflow(fileFolder.displayName.orEmpty()) + + updateFilePermissionsPage.swipeUpBottomSheet() + updateFilePermissionsPage.clickHideRadioButton() + updateFilePermissionsPage.clickSaveButton() + + modulesPage.assertModuleItemHidden(fileFolder.displayName.orEmpty()) + } + + private fun goToModulesPage(publishedModuleCount: Int = 1, unpublishedModuleCount: Int = 0): MockCanvas { + val data = MockCanvas.init(teacherCount = 1, courseCount = 1, favoriteCourseCount = 1) + val course = data.courses.values.first() + + data.addCoursePermissions( + course.id, + CanvasContextPermission() // Just need to have some sort of permissions object registered + ) + + val modulesTab = Tab(position = 2, label = "Modules", visibility = "public", tabId = Tab.MODULES_ID) + data.courseTabs[course.id]!! += modulesTab + + repeat(publishedModuleCount) { data.addModuleToCourse(course, Randomizer.randomModuleName(), published = true) } + repeat(unpublishedModuleCount) { + data.addModuleToCourse( + course, + Randomizer.randomModuleName(), + published = false + ) + } + + val teacher = data.teachers.first() + val token = data.tokenFor(teacher)!! + tokenLogin(data.domain, token, teacher) + + dashboardPage.openCourse(course) + courseBrowserPage.openModulesTab() + return data + } + +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt new file mode 100644 index 0000000000..ebcbea9592 --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.ui + +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addFileToCourse +import com.instructure.canvas.espresso.mockCanvas.addItemToModule +import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.Tab +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.dataseeding.util.Randomizer +import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.teacher.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test +import java.util.Calendar +import java.util.Date + +@HiltAndroidTest +class UpdateFilePermissionsPageTest : TeacherTest() { + + override fun displaysPageObjects() = Unit + + @Test + fun assertFilePublished() { + goToPage(fileAvailability = "published") + updateFilePermissionsPage.assertFilePublished() + } + + @Test + fun assertFileUnpublished() { + goToPage(fileAvailability = "unpublished") + updateFilePermissionsPage.assertFileUnpublished() + } + + @Test + fun assertFileHidden() { + goToPage(fileAvailability = "hidden") + updateFilePermissionsPage.assertFileHidden() + } + + @Test + fun assertFileScheduled() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertFileScheduled() + } + + @Test + fun assertFileVisibilityInherit() { + goToPage(fileVisibility = "inherit", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityInherit() + } + + @Test + fun assertFileVisibilityContext() { + goToPage(fileVisibility = "context", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityContext() + } + + @Test + fun assertFileVisibilityInstitution() { + goToPage(fileVisibility = "institution", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityInstitution() + } + + @Test + fun assertFileVisibilityPublic() { + goToPage(fileVisibility = "public", fileAvailability = "published") + updateFilePermissionsPage.assertFileVisibilityPublic() + } + + @Test + fun assertScheduleLayoutVisible() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertScheduleLayoutDisplayed() + } + + @Test + fun assertScheduleLayoutNotVisible() { + goToPage(fileAvailability = "published") + updateFilePermissionsPage.assertScheduleLayoutNotDisplayed() + } + + @Test + fun assertUnlockDate() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertUnlockDate(unlockDate) + } + + @Test + fun assertLockDate() { + val calendar = Calendar.getInstance() + val unlockDate = calendar.time + val lockDate = calendar.apply { add(Calendar.MONTH, 1) }.time + goToPage(fileAvailability = "scheduled", unlockDate = unlockDate, lockDate = lockDate) + updateFilePermissionsPage.assertLockDate(lockDate) + } + + @Test + fun assertVisibilityDisabledIfUnpublished() { + goToPage(fileVisibility = "public", fileAvailability = "unpublished") + updateFilePermissionsPage.assertVisibilityDisabled() + } + + @Test + fun assertVisibilityEnabled() { + goToPage(fileVisibility = "public", fileAvailability = "published") + updateFilePermissionsPage.assertVisibilityEnabled() + } + + private fun goToPage(fileVisibility: String = "inherit", fileAvailability: String = "published", unlockDate: Date? = null, lockDate: Date? = null) : MockCanvas { + val data = MockCanvas.init(teacherCount = 1, courseCount = 1, favoriteCourseCount = 1) + val course = data.courses.values.first() + + data.addCoursePermissions( + course.id, + CanvasContextPermission() // Just need to have some sort of permissions object registered + ) + + val modulesTab = Tab(position = 2, label = "Modules", visibility = "public", tabId = Tab.MODULES_ID) + data.courseTabs[course.id]!! += modulesTab + + data.addModuleToCourse(course, Randomizer.randomModuleName(), published = true) + + val fileId = data.addFileToCourse(course.id, visibilityLevel = fileVisibility) + val rootFolderId = data.courseRootFolders[course.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + + val module = data.courseModules.values.first().first() + + data.addItemToModule( + course = course, + moduleId = module.id, + item = fileFolder!!, + contentId = fileId, + published = fileAvailability == "published", + moduleContentDetails = ModuleContentDetails( + hidden = fileAvailability == "hidden", + locked = fileAvailability == "unpublished", + unlockAt = unlockDate?.toApiString(), + lockAt = lockDate?.toApiString() + ) + ) + + val teacher = data.teachers.first() + val token = data.tokenFor(teacher)!! + tokenLogin(data.domain, token, teacher) + + dashboardPage.openCourse(course) + courseBrowserPage.openModulesTab() + modulesPage.clickItemOverflow(fileFolder.name.orEmpty()) + updateFilePermissionsPage.swipeUpBottomSheet() + return data + } +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt index 4049eb69bd..65cc613ec7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt @@ -91,7 +91,7 @@ class ModulesE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that ${testPage.title} page is present as a module item, but it's not published.") modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemNotPublished(module.name, testPage.title) + modulesPage.assertModuleItemNotPublished(testPage.title) Log.d(PREPARATION_TAG,"Publish ${module.name} module via API.") ModulesApi.updateModule( @@ -137,11 +137,11 @@ class ModulesE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that ${assignment.name} assignment and ${quiz.title} quiz and ${testPage.title} page are present as module items, and they are NOT published since their module is unpublished.") modulesPage.assertModuleItemIsDisplayed(assignment.name) - modulesPage.assertModuleItemNotPublished(module.name, assignment.name) + modulesPage.assertModuleItemNotPublished(assignment.name) modulesPage.assertModuleItemIsDisplayed(quiz.title) - modulesPage.assertModuleItemNotPublished(module.name, quiz.title) + modulesPage.assertModuleItemNotPublished(quiz.title) modulesPage.assertModuleItemIsDisplayed(testPage.title) - modulesPage.assertModuleItemNotPublished(module.name, testPage.title) + modulesPage.assertModuleItemNotPublished(testPage.title) Log.d(STEP_TAG, "Open the ${assignment.name} assignment module item and assert that the Assignment Details Page is displayed. Navigate back to Modules Page.") modulesPage.clickOnModuleItem(assignment.name) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt index 50e83c8def..147213ebee 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt @@ -1,9 +1,11 @@ package com.instructure.teacher.ui.pages +import androidx.annotation.StringRes import androidx.test.espresso.matcher.ViewMatchers.hasSibling import androidx.test.espresso.matcher.ViewMatchers.withChild import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasContentDescription import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage @@ -12,7 +14,9 @@ import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withDescendant import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck import com.instructure.teacher.R @@ -42,6 +46,11 @@ class ModulesPage : BasePage() { onView(withId(R.id.publishedIcon)).assertNotDisplayed() } + fun assertModuleNotPublished(moduleTitle: String) { + onView(withId(R.id.unpublishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertDisplayed() + onView(withId(R.id.publishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertNotDisplayed() + } + /** * Asserts that the module is published. */ @@ -50,6 +59,11 @@ class ModulesPage : BasePage() { onView(withId(R.id.publishedIcon)).assertDisplayed() } + fun assertModuleIsPublished(moduleTitle: String) { + onView(withId(R.id.unpublishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertNotDisplayed() + onView(withId(R.id.publishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertDisplayed() + } + /** * Asserts that the module with the specified title is displayed. * @@ -90,21 +104,20 @@ class ModulesPage : BasePage() { * @param moduleItemName The name of the module item. */ fun assertModuleItemIsPublished(moduleItemName: String) { - val siblingChildMatcher = withChild(withId(R.id.moduleItemTitle) + withText(moduleItemName)) - onView(withId(R.id.moduleItemStatusIcon) + hasSibling(siblingChildMatcher)).assertDisplayed() - onView(withId(R.id.moduleItemUnpublishedIcon) + hasSibling(siblingChildMatcher)).assertNotDisplayed() + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_published + ) } /** * Asserts that the module item with the specified title is not published. * - * @param moduleTitle The title of the module. * @param moduleItemName The name of the module item. */ - fun assertModuleItemNotPublished(moduleTitle: String, moduleItemName: String) { - val siblingChildMatcher = withChild(withId(R.id.moduleItemTitle) + withText(moduleItemName)) - onView(withId(R.id.moduleItemUnpublishedIcon) + hasSibling(siblingChildMatcher)).assertDisplayed() - onView(withId(R.id.moduleItemStatusIcon) + hasSibling(siblingChildMatcher)).assertNotDisplayed() + fun assertModuleItemNotPublished(moduleItemName: String) { + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_unpublished + ) } /** @@ -121,7 +134,55 @@ class ModulesPage : BasePage() { * @param expectedCount The expected item count in the module. */ fun assertItemCountInModule(moduleTitle: String, expectedCount: Int) { - onView(withId(R.id.recyclerView) + withDescendant(withId(R.id.moduleName) + - withText(moduleTitle))).waitForCheck(RecyclerViewItemCountAssertion(expectedCount + 1)) // Have to increase by one because of the module title element itself. + onView( + withId(R.id.recyclerView) + withDescendant( + withId(R.id.moduleName) + + withText(moduleTitle) + ) + ).waitForCheck(RecyclerViewItemCountAssertion(expectedCount + 1)) // Have to increase by one because of the module title element itself. + } + + fun assertToolbarMenuItems() { + onView(withText(R.string.publishAllModulesAndItems)).assertDisplayed() + onView(withText(R.string.publishModulesOnly)).assertDisplayed() + onView(withText(R.string.unpublishAllModulesAndItems)).assertDisplayed() + } + + fun clickItemOverflow(itemName: String) { + onView(withParent(withChild(withText(itemName))) + withId(R.id.overflow)).scrollTo().click() + } + + fun assertModuleMenuItems() { + onView(withText(R.string.publishModuleAndItems)).assertDisplayed() + onView(withText(R.string.publishModuleOnly)).assertDisplayed() + onView(withText(R.string.unpublishModuleAndItems)).assertDisplayed() + } + + fun assertOverflowItem(@StringRes title: Int) { + onView(withText(title)).assertDisplayed() + } + + fun assertFileEditDialogVisible() { + onView(withText(R.string.edit_permissions)).assertDisplayed() + } + + fun clickOnText(@StringRes title: Int) { + onView(withText(title)).click() + } + + fun assertSnackbarText(@StringRes snackbarText: Int) { + onView(withId(com.google.android.material.R.id.snackbar_text) + withText(snackbarText)).assertDisplayed() + } + + fun assertModuleItemHidden(moduleItemName: String) { + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_hidden + ) + } + + fun assertModuleItemScheduled(moduleItemName: String) { + onView(withAncestor(withChild(withText(moduleItemName))) + withId(R.id.moduleItemStatusIcon)).assertHasContentDescription( + R.string.a11y_scheduled + ) } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt new file mode 100644 index 0000000000..3c4822617a --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.ui.pages + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.instructure.espresso.page.BasePage + +class ProgressPage(private val composeTestRule: ComposeTestRule) : BasePage() { + + fun clickDone() { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Done").performClick() + } +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/UpdateFilePermissionsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/UpdateFilePermissionsPage.kt new file mode 100644 index 0000000000..798d03d596 --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/UpdateFilePermissionsPage.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.ui.pages + +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.assertChecked +import com.instructure.espresso.assertDisabled +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertEnabled +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeUp +import com.instructure.teacher.R +import java.text.SimpleDateFormat +import java.util.Date + +class UpdateFilePermissionsPage : BasePage() { + + private val saveButton by OnViewWithId(R.id.updateButton) + private val publishRadioButton by OnViewWithId(R.id.publish) + private val unpublishRadioButton by OnViewWithId(R.id.unpublish) + private val hideRadioButton by OnViewWithId(R.id.hide) + private val scheduleRadioButton by OnViewWithId(R.id.schedule) + private val inheritRadioButton by OnViewWithId(R.id.visibilityInherit) + private val contextRadioButton by OnViewWithId(R.id.visibilityContext) + private val institutionRadioButton by OnViewWithId(R.id.visibilityInstitution) + private val publicRadioButton by OnViewWithId(R.id.visibilityPublic) + private val scheduleLayout by OnViewWithId(R.id.scheduleLayout) + private val availableFromDate by OnViewWithId(R.id.availableFromDate) + private val availableFromTime by OnViewWithId(R.id.availableFromTime) + private val availableUntilDate by OnViewWithId(R.id.availableUntilDate) + private val availableUntilTime by OnViewWithId(R.id.availableUntilTime) + + fun assertFilePublished() { + publishRadioButton.assertChecked() + } + + fun assertFileUnpublished() { + unpublishRadioButton.assertChecked() + } + + fun assertFileHidden() { + hideRadioButton.assertChecked() + } + + fun assertFileScheduled() { + scheduleRadioButton.assertChecked() + } + + fun assertFileVisibilityInherit() { + inheritRadioButton.assertChecked() + } + + fun assertFileVisibilityContext() { + contextRadioButton.assertChecked() + } + + fun assertFileVisibilityInstitution() { + institutionRadioButton.assertChecked() + } + + fun assertFileVisibilityPublic() { + publicRadioButton.assertChecked() + } + + fun clickSaveButton() { + saveButton.click() + } + + fun clickPublishRadioButton() { + waitForViewWithId(R.id.publish).click() + } + + fun clickUnpublishRadioButton() { + waitForViewWithId(R.id.unpublish).click() + } + + fun clickHideRadioButton() { + waitForViewWithId(R.id.hide).click() + } + + fun assertScheduleLayoutDisplayed() { + scheduleLayout.assertDisplayed() + } + + fun assertScheduleLayoutNotDisplayed() { + scheduleLayout.assertNotDisplayed() + } + + fun assertUnlockDate(unlockDate: Date) { + val dateString = SimpleDateFormat("MMM d, YYYY").format(unlockDate) + val timeString = SimpleDateFormat("h:mm a").format(unlockDate) + + waitForViewWithId(R.id.availableFromDate).scrollTo().assertDisplayed() + availableFromDate.assertHasText(dateString) + availableFromTime.assertHasText(timeString) + } + + fun assertLockDate(lockDate: Date) { + val dateString = SimpleDateFormat("MMM d, YYYY").format(lockDate) + val timeString = SimpleDateFormat("h:mm a").format(lockDate) + + waitForViewWithId(R.id.availableUntilDate).scrollTo().assertDisplayed() + availableUntilDate.assertHasText(dateString) + availableUntilTime.assertHasText(timeString) + } + + fun assertVisibilityDisabled() { + inheritRadioButton.assertDisabled() + contextRadioButton.assertDisabled() + institutionRadioButton.assertDisabled() + publicRadioButton.assertDisabled() + } + + fun assertVisibilityEnabled() { + inheritRadioButton.assertEnabled() + contextRadioButton.assertEnabled() + institutionRadioButton.assertEnabled() + publicRadioButton.assertEnabled() + } + + fun swipeUpBottomSheet() { + onViewWithText(R.string.edit_permissions).swipeUp() + Thread.sleep(1000) + } +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt index 9f08f3918b..31bf453bbf 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/ModuleListRenderTest.kt @@ -16,11 +16,12 @@ package com.instructure.teacher.ui.renderTests import android.graphics.Color -import android.os.Build import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isEnabled import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.utils.toApiString import com.instructure.espresso.assertCompletelyDisplayed import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText @@ -38,6 +39,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.hamcrest.CoreMatchers.not import org.junit.Before import org.junit.Test +import java.util.Date @HiltAndroidTest class ModuleListRenderTest : TeacherRenderTest() { @@ -160,7 +162,7 @@ class ModuleListRenderTest : TeacherRenderTest() { items = listOf(moduleItem) ) loadPageWithViewState(state) - page.assertStatusIcon(R.drawable.ic_complete_solid, R.color.textSuccess) + page.assertStatusIconContentDescription(R.string.a11y_published) } @Test @@ -172,7 +174,7 @@ class ModuleListRenderTest : TeacherRenderTest() { items = listOf(moduleItem) ) loadPageWithViewState(state) - page.assertStatusIcon(R.drawable.ic_no, R.color.textDark) + page.assertStatusIconContentDescription(R.string.a11y_unpublished) } @Test @@ -385,6 +387,42 @@ class ModuleListRenderTest : TeacherRenderTest() { page.moduleItemRoot.check(matches(not(isEnabled()))) } + @Test + fun displaysFileModuleItemHiddenIcon() { + val item = moduleItemTemplate.copy( + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File, + contentDetails = ModuleContentDetails( + hidden = true + ) + ) + val state = ModuleListViewState( + items = listOf(item) + ) + loadPageWithViewState(state) + page.moduleItemIcon.assertDisplayed() + page.assertStatusIconContentDescription(R.string.a11y_hidden) + } + + @Test + fun displaysFileModuleItemScheduledIcon() { + val item = moduleItemTemplate.copy( + iconResId = R.drawable.ic_attachment, + type = ModuleItem.Type.File, + contentDetails = ModuleContentDetails( + hidden = false, + locked = true, + unlockAt = Date().toApiString() + ) + ) + val state = ModuleListViewState( + items = listOf(item) + ) + loadPageWithViewState(state) + page.moduleItemIcon.assertDisplayed() + page.assertStatusIconContentDescription(R.string.a11y_scheduled) + } + private fun loadPageWithViewState( state: ModuleListViewState, course: Course = Course(name = "Test Course") diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt index 11570caefc..7fa38d027c 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/renderTests/pages/ModuleListRenderPage.kt @@ -15,20 +15,18 @@ */ package com.instructure.teacher.ui.renderTests.pages -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import com.instructure.espresso.OnViewWithId import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed -import com.instructure.espresso.matchers.WithDrawableViewMatcher import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.withAncestor -import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText import com.instructure.teacher.R import com.instructure.teacher.ui.utils.SwipeRefreshLayoutMatchers @@ -82,7 +80,7 @@ class ModuleListRenderPage : BasePage(R.id.moduleList) { moduleItemIndent.check(matches(ViewSizeMatcher.hasWidth(indent))) } - fun assertStatusIcon(@DrawableRes iconId: Int, @ColorRes tintId: Int) { - WithDrawableViewMatcher(iconId, tintId).matches(withId(R.id.moduleItemStatusIcon)) + fun assertStatusIconContentDescription(@StringRes contentDescription: Int) { + moduleItemStatusIcon.check(matches(withContentDescription(contentDescription))) } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt new file mode 100644 index 0000000000..10679bf125 --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.ui.utils + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.instructure.teacher.activities.LoginActivity +import com.instructure.teacher.ui.pages.ProgressPage +import org.junit.Rule + +abstract class TeacherComposeTest : TeacherTest() { + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + val progressPage = ProgressPage(composeTestRule) +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index dd1f97d460..cda5feaae3 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -84,6 +84,7 @@ import com.instructure.teacher.ui.pages.SpeedGraderQuizSubmissionPage import com.instructure.teacher.ui.pages.StudentContextPage import com.instructure.teacher.ui.pages.SyllabusPage import com.instructure.teacher.ui.pages.TodoPage +import com.instructure.teacher.ui.pages.UpdateFilePermissionsPage import com.instructure.teacher.ui.pages.WebViewLoginPage import dagger.hilt.android.testing.HiltAndroidRule import instructure.rceditor.RCETextEditor @@ -177,6 +178,7 @@ abstract class TeacherTest : CanvasTest() { val todoPage = TodoPage() val webViewLoginPage = WebViewLoginPage() val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) + val updateFilePermissionsPage = UpdateFilePermissionsPage() } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt index 7ea36151c0..ab97fbf527 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt @@ -77,7 +77,7 @@ class ModuleListView( showConfirmationDialog( R.string.publishDialogTitle, R.string.publishModuleDialogMessage, - R.string.publish, + R.string.publishDialogPositiveButton, R.string.cancel ) { consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, true)) @@ -88,7 +88,7 @@ class ModuleListView( showConfirmationDialog( R.string.publishDialogTitle, R.string.publishModuleAndItemsDialogMessage, - R.string.publish, + R.string.publishDialogPositiveButton, R.string.cancel ) { consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.PUBLISH, false)) @@ -99,7 +99,7 @@ class ModuleListView( showConfirmationDialog( R.string.unpublishDialogTitle, R.string.unpublishModuleAndItemsDialogMessage, - R.string.unpublish, + R.string.unpublishDialogPositiveButton, R.string.cancel ) { consumer?.accept(ModuleListEvent.BulkUpdateModule(moduleId, BulkModuleUpdateAction.UNPUBLISH, false)) @@ -119,7 +119,7 @@ class ModuleListView( val title = if (isPublished) R.string.publishDialogTitle else R.string.unpublishDialogTitle val message = if (isPublished) R.string.publishModuleItemDialogMessage else R.string.unpublishModuleItemDialogMessage - val positiveButton = if (isPublished) R.string.publish else R.string.unpublish + val positiveButton = if (isPublished) R.string.publishDialogPositiveButton else R.string.unpublishDialogPositiveButton showConfirmationDialog(title, message, positiveButton, R.string.cancel) { consumer?.accept(ModuleListEvent.UpdateModuleItem(itemId, isPublished)) @@ -140,7 +140,7 @@ class ModuleListView( showConfirmationDialog( R.string.publishDialogTitle, R.string.publishModulesAndItemsDialogMessage, - R.string.publish, + R.string.publishDialogPositiveButton, R.string.cancel ) { consumer?.accept( @@ -157,7 +157,7 @@ class ModuleListView( showConfirmationDialog( R.string.publishDialogTitle, R.string.publishModulesDialogMessage, - R.string.publish, + R.string.publishDialogPositiveButton, R.string.cancel ) { consumer?.accept(ModuleListEvent.BulkUpdateAllModules(BulkModuleUpdateAction.PUBLISH, true)) @@ -169,7 +169,7 @@ class ModuleListView( showConfirmationDialog( R.string.unpublishDialogTitle, R.string.unpublishModulesAndItemsDialogMessage, - R.string.unpublish, + R.string.unpublishDialogPositiveButton, R.string.cancel ) { consumer?.accept( diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt index 8b4b7d5532..d9f10c0484 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/binders/ModuleListItemBinder.kt @@ -20,6 +20,7 @@ import android.view.Gravity import android.view.View import androidx.annotation.ColorRes import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.appcompat.widget.PopupMenu import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem @@ -59,6 +60,7 @@ class ModuleListItemBinder : ListItemBinder { if (data.contentDetails?.hidden == true) { icon = R.drawable.ic_eye_off tint = R.color.textWarning + contentDescription = R.string.a11y_hidden } else if (data.contentDetails?.lockAt.isValid() || data.contentDetails?.unlockAt.isValid()) { icon = R.drawable.ic_calendar_month tint = R.color.textWarning + contentDescription = R.string.a11y_scheduled } else { icon = if (data.isPublished == true) R.drawable.ic_complete_solid else R.drawable.ic_no tint = if (data.isPublished == true) R.color.textSuccess else R.color.textDark + contentDescription = if (data.isPublished == true) R.string.a11y_published else R.string.a11y_unpublished } } else -> { icon = if (data.isPublished == true) R.drawable.ic_complete_solid else R.drawable.ic_no tint = if (data.isPublished == true) R.color.textSuccess else R.color.textDark + contentDescription = if (data.isPublished == true) R.string.a11y_published else R.string.a11y_unpublished } } - return StatusIcon(icon, tint) + return StatusIcon(icon, tint, contentDescription) } private fun showModuleItemActions( @@ -114,11 +121,11 @@ class ModuleListItemBinder : ListItemBinder menu.add(0, 0, 0, R.string.unpublish) - false -> menu.add(0, 1, 1, R.string.publish) + true -> menu.add(0, 0, 0, R.string.unpublishModuleItemAction) + false -> menu.add(0, 1, 1, R.string.publishModuleItemAction) else -> { - menu.add(0, 0, 0, R.string.unpublish) - menu.add(0, 1, 1, R.string.publish) + menu.add(0, 0, 0, R.string.unpublishModuleItemAction) + menu.add(0, 1, 1, R.string.publishModuleItemAction) } } @@ -145,5 +152,6 @@ class ModuleListItemBinder : ListItemBinder(com.google.android.material.R.id.design_bottom_sheet) - bottomSheet.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT val behavior = BottomSheetBehavior.from(bottomSheet) - behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.skipCollapsed = true + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.peekHeight = 0 + bottomSheet.parent.requestLayout() } } } diff --git a/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml b/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml index d8d1506ca8..dc6abd1318 100644 --- a/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml +++ b/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml @@ -35,6 +35,7 @@ @@ -102,6 +103,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:nestedScrollingEnabled="true" + android:fillViewport="true" android:visibility="@{viewModel.state instanceof ViewState.Success ? View.VISIBLE : View.GONE}"> + modules.any { module -> + module.items.any { it.contentId == pathVars.fileId } + } + } + + affectedCourseMap.forEach { (courseId, modules) -> + val updatedModules = modules.map { module -> + val updatedItems = module.items.filter { it.contentId == pathVars.fileId } + .associate { moduleItem -> + moduleItem.id to moduleItem.copy( + published = !updatedFile.isLocked, + moduleDetails = ModuleContentDetails( + lockAt = updatedFile.lockDate?.toString(), + unlockAt = updatedFile.unlockDate?.toString(), + locked = updatedFile.isLocked, + hidden = updatedFile.isHidden + ) + ) + } + module.copy(items = module.items.map { moduleItem -> updatedItems[moduleItem.id] ?: moduleItem }) + } + data.courseModules[courseId] = updatedModules.toMutableList() } + + request.successResponse(updatedFile) } + } - ) + } ) object CanvadocRedirectEndpoint : Endpoint( diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt index a9a0ed63f7..e8a662d622 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/endpoints/CourseEndpoints.kt @@ -21,6 +21,8 @@ import com.google.gson.Gson import com.instructure.canvas.espresso.mockCanvas.* import com.instructure.canvas.espresso.mockCanvas.utils.* import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.postmodels.BulkUpdateProgress +import com.instructure.canvasapi2.models.postmodels.BulkUpdateResponse import com.instructure.canvasapi2.models.postmodels.UpdateCourseWrapper import com.instructure.canvasapi2.utils.globalName import com.instructure.canvasapi2.utils.toApiString @@ -817,6 +819,35 @@ object CourseModuleListEndpoint : Endpoint( request.unauthorizedResponse() } } + PUT { + val moduleIds = request.url.queryParameterValues("module_ids[]").filterNotNull().map { it.toLong() } + val event = request.url.queryParameter("event") + val skipContentTags = request.url.queryParameter("skip_content_tags").toBoolean() + + val modules = data.courseModules[pathVars.courseId]?.filter { moduleIds.contains(it.id) } + + val updatedModules = modules?.map { + val updatedItems = if (skipContentTags) { + it.items + } else { + it.items.map { it.copy(published = event == "publish") } + } + it.copy( + published = event == "publish", + items = updatedItems + ) + } + + data.courseModules[pathVars.courseId]?.map { moduleObject -> + updatedModules?.find { updatedModuleObject -> + updatedModuleObject.id == moduleObject.id + } ?: moduleObject + }?.let { + data.courseModules[pathVars.courseId] = it.toMutableList() + } + + request.successResponse(BulkUpdateResponse(BulkUpdateProgress(Progress(1L, workflowState = "running")))) + } } ) @@ -865,7 +896,42 @@ object CourseModuleItemsListEndpoint : Endpoint( } else { request.unauthorizedResponse() } + } + PUT { + val isPublished = request.url.queryParameter("module_item[published]").toBoolean() + val moduleList = data.courseModules[pathVars.courseId] + val moduleObject = moduleList?.find { it.id == pathVars.moduleId } + val itemList = moduleObject?.items + val moduleItem = itemList?.find { it.id == pathVars.moduleItemId } + + if (moduleItem != null) { + val updatedItem = moduleItem.copy(published = isPublished) + + val updatedModule = moduleObject.copy( + items = itemList.map { + if (it.id == updatedItem.id) { + updatedItem + } else { + it + } + } + ) + + data.courseModules[pathVars.courseId]?.map { + if (it.id == updatedModule.id) { + updatedModule + } else { + it + } + }?.let { + data.courseModules[pathVars.courseId] = it.toMutableList() + } + + request.successResponse(updatedItem) + } else { + request.unauthorizedResponse() + } } }, response = { diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt index ff8beac66c..a46d2247bf 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/utils/PathUtils.kt @@ -48,6 +48,7 @@ class PathVars { var annotationid: String by map var bookmarkId: Long by map var enrollmentId: Long by map + var progressId: Long by map } /** diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt index 1e4c2d3ac8..f6e0332e27 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/ViewInteractionExtensions.kt @@ -154,3 +154,19 @@ fun ViewInteraction.waitForCheck(assertion: ViewAssertion) { } while (System.currentTimeMillis() < endTime) check(assertion) } + +fun ViewInteraction.assertChecked() { + check(ViewAssertions.matches(ViewMatchers.isChecked())) +} + +fun ViewInteraction.assertNotChecked() { + check(ViewAssertions.matches(ViewMatchers.isNotChecked())) +} + +fun ViewInteraction.assertEnabled() { + check(ViewAssertions.matches(ViewMatchers.isEnabled())) +} + +fun ViewInteraction.assertDisabled() { + check(ViewAssertions.matches(Matchers.not(ViewMatchers.isEnabled()))) +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt deleted file mode 100644 index 2b29b797f8..0000000000 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WithDrawableViewMatcher.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2024 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * - */ - -package com.instructure.espresso.matchers - -import android.widget.ImageView -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import androidx.appcompat.content.res.AppCompatResources -import org.hamcrest.TypeSafeMatcher - -class WithDrawableViewMatcher(@DrawableRes private val drawable: Int, @ColorRes private val tint: Int? = null) : TypeSafeMatcher() { - - override fun matchesSafely(view: ImageView): Boolean { - val drawableMatcher = view.drawable != null && view.drawable.constantState?.newDrawable()?.constantState?.hashCode() == AppCompatResources.getDrawable( - view.context, - drawable - )?.constantState?.hashCode() - - val tintMatcher = tint != null && view.imageTintList?.defaultColor == AppCompatResources.getColorStateList( - view.context, - tint - )?.defaultColor - - return drawableMatcher && (tintMatcher || tint == null) - } - - override fun describeTo(description: org.hamcrest.Description) { - description.appendText("with drawable and tint") - } -} \ No newline at end of file diff --git a/buildSrc/src/main/java/GlobalDependencies.kt b/buildSrc/src/main/java/GlobalDependencies.kt index 7bd1d86c1b..6abf8574d4 100644 --- a/buildSrc/src/main/java/GlobalDependencies.kt +++ b/buildSrc/src/main/java/GlobalDependencies.kt @@ -170,6 +170,8 @@ object Libs { const val COMPOSE_TOOLING = "androidx.compose.ui:ui-tooling" const val COMPOSE_UI = "androidx.compose.ui:ui-android:1.6.0" const val COMPOSE_VIEW_MODEL = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1" + const val COMPOSE_UI_TEST = "androidx.compose.ui:ui-test-junit4:1.6.1" + const val COMPOSE_UI_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest:1.6.1" } object Plugins { diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index bad4efc593..32702583ce 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1587,8 +1587,16 @@ Success! Update failed Update cancelled + Hidden + Scheduled + Published + Unpublished %.0f pt %.0f pts + Publish + Unpublish + Publish + Unpublish diff --git a/libs/pandautils/build.gradle b/libs/pandautils/build.gradle index 4bfea8cb84..bea7fc7f93 100644 --- a/libs/pandautils/build.gradle +++ b/libs/pandautils/build.gradle @@ -227,4 +227,7 @@ dependencies { androidTestImplementation Libs.ANDROIDX_TEST_JUNIT androidTestImplementation Libs.ANDROIDX_CORE_TESTING androidTestImplementation Libs.ROOM_TEST + + androidTestImplementation Libs.COMPOSE_UI_TEST + debugImplementation Libs.COMPOSE_UI_TEST_MANIFEST } diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/ProgressScreenTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/ProgressScreenTest.kt new file mode 100644 index 0000000000..e43574c14f --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/ProgressScreenTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.compose + +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.R +import com.instructure.pandautils.features.progress.ProgressState +import com.instructure.pandautils.features.progress.ProgressUiState +import com.instructure.pandautils.features.progress.composables.ProgressScreen +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProgressScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun assertRunningState() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.allModulesAndItems), + stringResource(id = R.string.publishing), + 10f, + "Bulk Update Note", + ProgressState.RUNNING + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("All Modules and Items").assertIsDisplayed() + composeTestRule.onNodeWithText("Publishing 10%").assertIsDisplayed() + composeTestRule.onNodeWithText("Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Bulk Update Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertIsDisplayed() + composeTestRule.onNodeWithText("Done").assertDoesNotExist() + } + + @Test + fun assertCompletedState() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.allModules), + stringResource(id = R.string.publishing), + 100f, + "Bulk Update Note", + ProgressState.COMPLETED + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("All Modules").assertIsDisplayed() + composeTestRule.onNodeWithText("Success!").assertIsDisplayed() + composeTestRule.onNodeWithText("Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Bulk Update Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Done").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertDoesNotExist() + } + + @Test + fun assertFailedState() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.selectedModulesAndItems), + stringResource(id = R.string.publishing), + 10f, + "Bulk Update Note", + ProgressState.FAILED + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("Selected Modules and Items").assertIsDisplayed() + composeTestRule.onNodeWithText("Update failed").assertIsDisplayed() + composeTestRule.onNodeWithText("Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Bulk Update Note").assertIsDisplayed() + composeTestRule.onNodeWithText("Done").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertDoesNotExist() + } + + @Test + fun assertNoteNotDisplayed() { + composeTestRule.setContent { + ProgressScreen( + progressUiState = ProgressUiState( + stringResource(id = R.string.allModulesAndItems), + stringResource(id = R.string.publishing), + 10f, + null, + ProgressState.RUNNING + ) + ) { + // No-op + } + } + + composeTestRule.onNodeWithText("Note").assertDoesNotExist() + } +} \ No newline at end of file From 4198d272e42473d1ca516662ed04e01e03c0114d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Fri, 23 Feb 2024 14:21:10 +0100 Subject: [PATCH 33/52] Update UpdateFilePermissionsPageTest.kt --- .../com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt index ebcbea9592..3fb6f84c2e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/UpdateFilePermissionsPageTest.kt @@ -177,7 +177,7 @@ class UpdateFilePermissionsPageTest : TeacherTest() { dashboardPage.openCourse(course) courseBrowserPage.openModulesTab() - modulesPage.clickItemOverflow(fileFolder.name.orEmpty()) + moduleListPage.clickItemOverflow(fileFolder.name.orEmpty()) updateFilePermissionsPage.swipeUpBottomSheet() return data } From ad91e46f3a5f0e2b31b25ee425504944afa5a0a0 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Fri, 23 Feb 2024 15:02:41 +0100 Subject: [PATCH 34/52] [MBL-17370][Student] - Implement Offline Announcements E2E test. (#2352) --- .../offline/ManageOfflineContentE2ETest.kt | 3 +- .../offline/OfflineAnnouncementsE2ETest.kt | 145 ++++++++++++++++++ .../offline/OfflineCourseBrowserE2ETest.kt | 3 +- .../ui/e2e/offline/OfflineDashboardE2ETest.kt | 2 +- .../ui/e2e/offline/OfflineFilesE2ETest.kt | 3 +- .../e2e/offline/OfflineLeftSideMenuE2ETest.kt | 3 +- .../ui/e2e/offline/OfflineLoginE2ETest.kt | 2 +- .../ui/e2e/offline/OfflinePeopleE2ETest.kt | 5 +- .../e2e/offline/OfflineSyncProgressE2ETest.kt | 3 +- .../e2e/offline/OfflineSyncSettingsE2ETest.kt | 3 +- .../student/ui/pages/DiscussionDetailsPage.kt | 8 +- 11 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index 81b89120c9..385ed4ad8b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -22,6 +22,7 @@ import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.waitForNetworkToGoOffline @@ -41,7 +42,7 @@ class ManageOfflineContentE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testManageOfflineContentE2ETest() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt new file mode 100644 index 0000000000..89a57aae64 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.student.ui.e2e.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Test +import java.lang.Thread.sleep + +@HiltAndroidTest +class OfflineAnnouncementsE2ETest : StudentTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ANNOUNCEMENTS, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflineAnnouncementsE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val announcement = data.announcementsList[0] + + val lockedAnnouncement = DiscussionTopicsApi.createAnnouncement(course.id, teacher.token, locked = true) + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + manageOfflineContentPage.expandCollapseItem(course.name) + Log.d(STEP_TAG, "Select the 'Announcements' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.changeItemSelectionState("Announcements") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") + dashboardPage.waitForOfflineSyncDashboardNotifications() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") + OfflineTestUtils.assertOfflineIndicator() + + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + + Log.d(STEP_TAG, "Select '${course.name}' course and open 'Announcements' menu.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectAnnouncements() + + Log.d(STEP_TAG,"Assert that '${announcement.title}' announcement is displayed.") + announcementListPage.assertTopicDisplayed(announcement.title) + + Log.d(STEP_TAG, "Assert that '${lockedAnnouncement.title}' announcement is really locked so that the 'locked' icon is displayed.") + announcementListPage.assertAnnouncementLocked(lockedAnnouncement.title) + + Log.d(STEP_TAG, "Select '${lockedAnnouncement.title}' announcement and assert if we are landing on the Discussion Details Page.") + announcementListPage.selectTopic(lockedAnnouncement.title) + discussionDetailsPage.assertTitleText(lockedAnnouncement.title) + + Log.d(STEP_TAG, "Assert that the 'Reply' button is not available on a locked announcement. Navigate back to Announcement List Page.") + discussionDetailsPage.assertReplyButtonNotDisplayed() + Espresso.pressBack() + + Log.d(STEP_TAG,"Select '${announcement.title}' announcement and assert if we are landing on the Discussion Details Page.") + announcementListPage.selectTopic(announcement.title) + discussionDetailsPage.assertTitleText(announcement.title) + + Log.d(STEP_TAG, "Click on the 'Reply' button and assert that the 'No Internet Connection' dialog has displayed. Dismiss the dialog.") + discussionDetailsPage.clickReply() + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + + Log.d(STEP_TAG,"Navigate back to Announcement List page. Click on Search button and type '${announcement.title}' to the search input field.") + Espresso.pressBack() + announcementListPage.searchable.clickOnSearchButton() + announcementListPage.searchable.typeToSearchBar(announcement.title) + + Log.d(STEP_TAG,"Assert that only the matching announcement is displayed on the Discussion List Page.") + announcementListPage.pullToUpdate() + announcementListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicNotDisplayed(lockedAnnouncement.title) + + Log.d(STEP_TAG,"Clear search input field value and assert if all the announcements are displayed again on the Discussion List Page.") + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) + + Log.d(STEP_TAG,"Type the '${announcement.title}' announcement's title as search value to the search input field. Assert the the '${announcement.title}' announcement, but only that displayed as result.") + announcementListPage.searchable.typeToSearchBar(announcement.title) + sleep(3000) //We need this wait here to let make sure the search process has finished. + announcementListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicNotDisplayed(lockedAnnouncement.title) + + Log.d(STEP_TAG,"Clear search input field value and assert if both the announcements are displayed again on the Announcement List Page.") + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + turnOnConnectionViaADB() + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt index 463100b18a..4b1a535161 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -21,6 +21,7 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils @@ -42,7 +43,7 @@ class OfflineCourseBrowserE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.COURSE, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.COURSE, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineCourseBrowserPageUnavailableE2E() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt index 8a2b22aa66..7e338d5078 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt @@ -44,7 +44,7 @@ class OfflineDashboardE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineDashboardE2E() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt index 3e312331c1..ffe221a31d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt @@ -22,6 +22,7 @@ import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.FileFolderApi @@ -44,7 +45,7 @@ class OfflineFilesE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.FILES, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.FILES, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineFilesE2E() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt index 3e12ff66f3..bb768b20eb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt @@ -20,6 +20,7 @@ import android.util.Log import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils @@ -42,7 +43,7 @@ class OfflineLeftSideMenuE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.LEFT_SIDE_MENU, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.LEFT_SIDE_MENU, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineLeftSideMenuUnavailableFunctionsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt index f3797ef48a..88c93fe92a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt @@ -43,7 +43,7 @@ class OfflineLoginE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.IMPORTANT, FeatureCategory.LOGIN, TestCategory.E2E, SecondaryFeatureCategory.CHANGE_USER) + @TestMetaData(Priority.IMPORTANT, FeatureCategory.LOGIN, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineChangeUserE2E() { Log.d(PREPARATION_TAG, "Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt index 3a83c15a91..c7e2f5991a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt @@ -21,6 +21,7 @@ import androidx.test.espresso.matcher.ViewMatchers import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils @@ -40,7 +41,7 @@ class OfflinePeopleE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.PEOPLE, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.PEOPLE, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflinePeopleE2E() { Log.d(PREPARATION_TAG,"Seeding data.") @@ -57,7 +58,7 @@ class OfflinePeopleE2ETest : StudentTest() { dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") manageOfflineContentPage.expandCollapseItem(course.name) - Log.d(STEP_TAG, "Select the entire '${course.name}' course for sync. Click on the 'Sync' button.") + Log.d(STEP_TAG, "Select the 'People' of '${course.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState("People") manageOfflineContentPage.clickOnSyncButtonAndConfirm() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt index bd4afc5a27..c5c60873b9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt @@ -22,6 +22,7 @@ import com.google.android.material.checkbox.MaterialCheckBox import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils @@ -41,7 +42,7 @@ class OfflineSyncProgressE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.SYNC_PROGRESS, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.SYNC_PROGRESS, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun testOfflineGlobalCourseSyncProgressE2E() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt index bd8e82b5a5..745a2c8fd7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt @@ -20,6 +20,7 @@ import android.util.Log import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.OfflineE2E import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.pandautils.R @@ -40,7 +41,7 @@ class OfflineSyncSettingsE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) fun offlineSyncSettingsE2ETest() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt index 364b5ac258..a7655e6675 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt @@ -91,7 +91,7 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { onView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)).scrollTo() } - private fun clickReply() { + fun clickReply() { replyButton.click() } @@ -118,11 +118,15 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun sendReply(replyMessage: String) { clickReply() waitForViewWithId(R.id.rce_webView).perform(TypeInRCETextEditor(replyMessage)) - onView(withId(R.id.menu_send)).click() + clickOnSendReplyButton() sleep(3000) // wait out the toast message } + private fun clickOnSendReplyButton() { + onView(withId(R.id.menu_send)).click() + } + fun assertReplyDisplayed(reply: DiscussionEntry, refreshesAllowed: Int = 0) { // Allow up to refreshesAllowed attempt/refresh cycles From 0c9233e1be5ec85233b8185c304765dbc6f5b333 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Mon, 26 Feb 2024 10:40:19 +0100 Subject: [PATCH 35/52] Fix breaking testPagesE2E. (#2355) --- .../com/instructure/teacher/ui/pages/CourseBrowserPage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt index 840968381c..89d6564c8f 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/CourseBrowserPage.kt @@ -37,6 +37,7 @@ import com.instructure.espresso.page.plus import com.instructure.espresso.page.waitForViewWithText import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeDown import com.instructure.espresso.waitForCheck import com.instructure.teacher.R @@ -119,8 +120,7 @@ class CourseBrowserPage : BasePage() { * Opens the pages tab in the course browser. */ fun openPagesTab() { - scrollDownToCourseBrowser(scrollPosition = magicNumberForScroll) - waitForViewWithText(R.string.tab_pages).click() + waitForViewWithText(R.string.tab_pages).scrollTo().click() } /** From 772f4fbe108a996dfec27ca45911bceebde54c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Mon, 26 Feb 2024 11:04:05 +0100 Subject: [PATCH 36/52] update flank devices --- apps/student/flank.yml | 2 +- apps/student/flank_e2e.yml | 2 +- apps/student/flank_e2e_offline.yml | 2 +- apps/student/flank_landscape.yml | 2 +- apps/student/flank_tablet.yml | 4 ++-- apps/teacher/flank.yml | 2 +- apps/teacher/flank_e2e.yml | 2 +- apps/teacher/flank_landscape.yml | 2 +- apps/teacher/flank_tablet.yml | 4 ++-- libs/pandautils/flank.yml | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/student/flank.yml b/apps/student/flank.yml index e6a449008d..284a451f9d 100644 --- a/apps/student/flank.yml +++ b/apps/student/flank.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e.yml b/apps/student/flank_e2e.yml index 3bf18a37ee..34ba1a2475 100644 --- a/apps/student/flank_e2e.yml +++ b/apps/student/flank_e2e.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e_offline.yml b/apps/student/flank_e2e_offline.yml index 4d76f14bd3..ea78fd2978 100644 --- a/apps/student/flank_e2e_offline.yml +++ b/apps/student/flank_e2e_offline.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: portrait diff --git a/apps/student/flank_landscape.yml b/apps/student/flank_landscape.yml index ebd8fc2896..7ca0f5ebe3 100644 --- a/apps/student/flank_landscape.yml +++ b/apps/student/flank_landscape.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubLandscape, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: landscape diff --git a/apps/student/flank_tablet.yml b/apps/student/flank_tablet.yml index ba6afecd57..aa26f53f04 100644 --- a/apps/student/flank_tablet.yml +++ b/apps/student/flank_tablet.yml @@ -15,11 +15,11 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubTablet, com.instructure.canvas.espresso.OfflineE2E device: - model: MediumTablet.arm - version: 29 + version: 32 locale: en_US orientation: landscape - model: MediumTablet.arm - version: 29 + version: 32 locale: en_US orientation: portrait diff --git a/apps/teacher/flank.yml b/apps/teacher/flank.yml index 97639ae19b..cebc4961c7 100644 --- a/apps/teacher/flank.yml +++ b/apps/teacher/flank.yml @@ -12,7 +12,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: portrait diff --git a/apps/teacher/flank_e2e.yml b/apps/teacher/flank_e2e.yml index 1664987d5c..0cc997560d 100644 --- a/apps/teacher/flank_e2e.yml +++ b/apps/teacher/flank_e2e.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: portrait diff --git a/apps/teacher/flank_landscape.yml b/apps/teacher/flank_landscape.yml index 3211cc4803..3ed2b170b1 100644 --- a/apps/teacher/flank_landscape.yml +++ b/apps/teacher/flank_landscape.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: landscape diff --git a/apps/teacher/flank_tablet.yml b/apps/teacher/flank_tablet.yml index 6ec715a404..a96ec8c99f 100644 --- a/apps/teacher/flank_tablet.yml +++ b/apps/teacher/flank_tablet.yml @@ -12,11 +12,11 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubTablet device: - model: MediumTablet.arm - version: 29 + version: 32 locale: en_US orientation: landscape - model: MediumTablet.arm - version: 29 + version: 32 locale: en_US orientation: portrait diff --git a/libs/pandautils/flank.yml b/libs/pandautils/flank.yml index 60bd72123a..61786f3b32 100644 --- a/libs/pandautils/flank.yml +++ b/libs/pandautils/flank.yml @@ -11,7 +11,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 29 + version: 32 locale: en_US orientation: portrait From 4dcf6939ced0987e3786dd057a33e1f1c75cc4c0 Mon Sep 17 00:00:00 2001 From: kdeakinstructure Date: Tue, 27 Feb 2024 10:48:37 +0100 Subject: [PATCH 37/52] Change to API lvl 33 --- apps/student/flank.yml | 2 +- apps/student/flank_e2e.yml | 2 +- apps/student/flank_e2e_offline.yml | 2 +- apps/student/flank_landscape.yml | 2 +- apps/student/flank_tablet.yml | 4 ++-- apps/teacher/flank.yml | 2 +- apps/teacher/flank_e2e.yml | 2 +- apps/teacher/flank_landscape.yml | 2 +- apps/teacher/flank_tablet.yml | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/student/flank.yml b/apps/student/flank.yml index 284a451f9d..7be7d0dba0 100644 --- a/apps/student/flank.yml +++ b/apps/student/flank.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 32 + version: 33 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e.yml b/apps/student/flank_e2e.yml index 34ba1a2475..da7f6948e7 100644 --- a/apps/student/flank_e2e.yml +++ b/apps/student/flank_e2e.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 32 + version: 33 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e_offline.yml b/apps/student/flank_e2e_offline.yml index ea78fd2978..6c2588bdf1 100644 --- a/apps/student/flank_e2e_offline.yml +++ b/apps/student/flank_e2e_offline.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 32 + version: 33 locale: en_US orientation: portrait diff --git a/apps/student/flank_landscape.yml b/apps/student/flank_landscape.yml index 7ca0f5ebe3..74fc28109b 100644 --- a/apps/student/flank_landscape.yml +++ b/apps/student/flank_landscape.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubLandscape, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 32 + version: 33 locale: en_US orientation: landscape diff --git a/apps/student/flank_tablet.yml b/apps/student/flank_tablet.yml index aa26f53f04..fb604ac67e 100644 --- a/apps/student/flank_tablet.yml +++ b/apps/student/flank_tablet.yml @@ -15,11 +15,11 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubTablet, com.instructure.canvas.espresso.OfflineE2E device: - model: MediumTablet.arm - version: 32 + version: 33 locale: en_US orientation: landscape - model: MediumTablet.arm - version: 32 + version: 33 locale: en_US orientation: portrait diff --git a/apps/teacher/flank.yml b/apps/teacher/flank.yml index cebc4961c7..64df93ec29 100644 --- a/apps/teacher/flank.yml +++ b/apps/teacher/flank.yml @@ -12,7 +12,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 32 + version: 33 locale: en_US orientation: portrait diff --git a/apps/teacher/flank_e2e.yml b/apps/teacher/flank_e2e.yml index 0cc997560d..4a6357ce3c 100644 --- a/apps/teacher/flank_e2e.yml +++ b/apps/teacher/flank_e2e.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 32 + version: 33 locale: en_US orientation: portrait diff --git a/apps/teacher/flank_landscape.yml b/apps/teacher/flank_landscape.yml index 3ed2b170b1..367af1ba72 100644 --- a/apps/teacher/flank_landscape.yml +++ b/apps/teacher/flank_landscape.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub device: - model: Pixel2.arm - version: 32 + version: 33 locale: en_US orientation: landscape diff --git a/apps/teacher/flank_tablet.yml b/apps/teacher/flank_tablet.yml index a96ec8c99f..5df93244f5 100644 --- a/apps/teacher/flank_tablet.yml +++ b/apps/teacher/flank_tablet.yml @@ -16,7 +16,7 @@ gcloud: locale: en_US orientation: landscape - model: MediumTablet.arm - version: 32 + version: 33 locale: en_US orientation: portrait From 2c4160d7f04a1ee27069c3aad9d4f0edf038dd00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Tue, 27 Feb 2024 12:57:58 +0100 Subject: [PATCH 38/52] Add plugin management --- apps/settings.gradle | 17 ++++++++-- apps/student/build.gradle | 2 ++ apps/student/flank.yml | 6 ++-- apps/student/flank_e2e.yml | 2 +- apps/student/flank_e2e_offline.yml | 2 +- apps/student/flank_landscape.yml | 2 +- apps/student/flank_tablet.yml | 4 +-- .../student/ui/utils/StudentTest.kt | 4 +++ apps/teacher/flank.yml | 5 ++- apps/teacher/flank_e2e.yml | 2 +- apps/teacher/flank_landscape.yml | 2 +- apps/teacher/flank_tablet.yml | 4 +-- .../teacher/ui/ModuleListPageTest.kt | 4 +-- .../teacher/ui/utils/TeacherComposeTest.kt | 32 ------------------- .../teacher/ui/utils/TeacherTest.kt | 6 ++++ libs/pandautils/flank.yml | 2 +- 16 files changed, 46 insertions(+), 50 deletions(-) delete mode 100644 apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt diff --git a/apps/settings.gradle b/apps/settings.gradle index 9fff40bf18..8486e179b4 100644 --- a/apps/settings.gradle +++ b/apps/settings.gradle @@ -1,7 +1,20 @@ +pluginManagement { + buildscript { + repositories { + mavenCentral() + maven { + url = uri("https://storage.googleapis.com/r8-releases/raw") + } + } + dependencies { + classpath("com.android.tools:r8:8.2.47") + } + } +} + /* Top-level project modules */ include ':student' include ':teacher' - /* Flutter embed modules */ setBinding(new Binding([gradle: this])) @@ -36,4 +49,4 @@ project(':pandautils').projectDir = new File(rootProject.projectDir, '/../libs/p project(':rceditor').projectDir = new File(rootProject.projectDir, '/../libs/rceditor') project(':recyclerview').projectDir = new File(rootProject.projectDir, '/../libs/recyclerview') project(':pandares').projectDir = new File(rootProject.projectDir, '/../libs/pandares') -project(':DocumentScanner').projectDir = new File(rootProject.projectDir, '/../libs/DocumentScanner') +project(':DocumentScanner').projectDir = new File(rootProject.projectDir, '/../libs/DocumentScanner') \ No newline at end of file diff --git a/apps/student/build.gradle b/apps/student/build.gradle index 669b4a7bb5..c487133e6d 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -359,6 +359,8 @@ dependencies { implementation Libs.ROOM_COROUTINES testImplementation Libs.HAMCREST + + androidTestImplementation Libs.COMPOSE_UI_TEST } // Comment out this line if the reporting logic starts going wonky. diff --git a/apps/student/flank.yml b/apps/student/flank.yml index 7be7d0dba0..45f1d8bccf 100644 --- a/apps/student/flank.yml +++ b/apps/student/flank.yml @@ -1,8 +1,8 @@ gcloud: project: delta-essence-114723 # Use the next two lines to run locally -# app: ./build/outputs/apk/qa/debug/student-qa-debug.apk -# test: ./build/outputs/apk/androidTest/qa/debug/student-qa-debug-androidTest.apk +# app: ./build/intermediates/apk/qa/debug/student-qa-debug.apk +# test: ./build/intermediates/apk/androidTest/qa/debug/student-qa-debug-androidTest.apk app: ./apps/student/build/outputs/apk/qa/debug/student-qa-debug.apk test: ./apps/student/build/outputs/apk/androidTest/qa/debug/student-qa-debug-androidTest.apk results-bucket: android-student @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 33 + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e.yml b/apps/student/flank_e2e.yml index da7f6948e7..3bf18a37ee 100644 --- a/apps/student/flank_e2e.yml +++ b/apps/student/flank_e2e.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 33 + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e_offline.yml b/apps/student/flank_e2e_offline.yml index 6c2588bdf1..4d76f14bd3 100644 --- a/apps/student/flank_e2e_offline.yml +++ b/apps/student/flank_e2e_offline.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 33 + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/flank_landscape.yml b/apps/student/flank_landscape.yml index 74fc28109b..ebd8fc2896 100644 --- a/apps/student/flank_landscape.yml +++ b/apps/student/flank_landscape.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubLandscape, com.instructure.canvas.espresso.OfflineE2E device: - model: Pixel2.arm - version: 33 + version: 29 locale: en_US orientation: landscape diff --git a/apps/student/flank_tablet.yml b/apps/student/flank_tablet.yml index fb604ac67e..ba6afecd57 100644 --- a/apps/student/flank_tablet.yml +++ b/apps/student/flank_tablet.yml @@ -15,11 +15,11 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubTablet, com.instructure.canvas.espresso.OfflineE2E device: - model: MediumTablet.arm - version: 33 + version: 29 locale: en_US orientation: landscape - model: MediumTablet.arm - version: 33 + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index e72cd5bd6b..e402cdea7a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -23,6 +23,7 @@ import android.net.Uri import android.os.Environment import android.util.Log import android.view.View +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.core.content.FileProvider import androidx.hilt.work.HiltWorkerFactory import androidx.test.espresso.Espresso @@ -126,6 +127,9 @@ abstract class StudentTest : CanvasTest() { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + // Sometimes activityRule.activity can get nulled out over time, probably as we // navigate away from the original login screen. Capture the activity here so // that we can reference it safely later. diff --git a/apps/teacher/flank.yml b/apps/teacher/flank.yml index 64df93ec29..eafa4e03e4 100644 --- a/apps/teacher/flank.yml +++ b/apps/teacher/flank.yml @@ -1,5 +1,8 @@ gcloud: project: delta-essence-114723 +# Use the next two lines to run locally +# app: ./build/intermediates/apk/qa/debug/teacher-qa-debug.apk +# test: ./build/intermediates/apk/androidTest/qa/debug/teacher-qa-debug-androidTest.apk app: ./apps/teacher/build/outputs/apk/qa/debug/teacher-qa-debug.apk test: ./apps/teacher/build/outputs/apk/androidTest/qa/debug/teacher-qa-debug-androidTest.apk results-bucket: android-teacher @@ -12,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 33 + version: 29 locale: en_US orientation: portrait diff --git a/apps/teacher/flank_e2e.yml b/apps/teacher/flank_e2e.yml index 4a6357ce3c..1664987d5c 100644 --- a/apps/teacher/flank_e2e.yml +++ b/apps/teacher/flank_e2e.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 33 + version: 29 locale: en_US orientation: portrait diff --git a/apps/teacher/flank_landscape.yml b/apps/teacher/flank_landscape.yml index 367af1ba72..3211cc4803 100644 --- a/apps/teacher/flank_landscape.yml +++ b/apps/teacher/flank_landscape.yml @@ -15,7 +15,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub device: - model: Pixel2.arm - version: 33 + version: 29 locale: en_US orientation: landscape diff --git a/apps/teacher/flank_tablet.yml b/apps/teacher/flank_tablet.yml index 5df93244f5..6ec715a404 100644 --- a/apps/teacher/flank_tablet.yml +++ b/apps/teacher/flank_tablet.yml @@ -12,11 +12,11 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubTablet device: - model: MediumTablet.arm - version: 32 + version: 29 locale: en_US orientation: landscape - model: MediumTablet.arm - version: 33 + version: 29 locale: en_US orientation: portrait diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt index deb9cc4ff0..ecf8ffb232 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt @@ -31,14 +31,14 @@ import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.util.Randomizer import com.instructure.teacher.R -import com.instructure.teacher.ui.utils.TeacherComposeTest +import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.openOverflowMenu import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest -class ModuleListPageTest : TeacherComposeTest() { +class ModuleListPageTest : TeacherTest() { @Test override fun displaysPageObjects() { diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt deleted file mode 100644 index 10679bf125..0000000000 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2024 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * - */ - -package com.instructure.teacher.ui.utils - -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import com.instructure.teacher.activities.LoginActivity -import com.instructure.teacher.ui.pages.ProgressPage -import org.junit.Rule - -abstract class TeacherComposeTest : TeacherTest() { - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - - val progressPage = ProgressPage(composeTestRule) -} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index e1d239030b..8c8dafe04b 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -19,6 +19,7 @@ package com.instructure.teacher.ui.utils import android.app.Activity import android.util.Log import android.view.View +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.hilt.work.HiltWorkerFactory import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction @@ -72,6 +73,7 @@ import com.instructure.teacher.ui.pages.PeopleListPage import com.instructure.teacher.ui.pages.PersonContextPage import com.instructure.teacher.ui.pages.PostSettingsPage import com.instructure.teacher.ui.pages.ProfileSettingsPage +import com.instructure.teacher.ui.pages.ProgressPage import com.instructure.teacher.ui.pages.QuizDetailsPage import com.instructure.teacher.ui.pages.QuizListPage import com.instructure.teacher.ui.pages.QuizSubmissionListPage @@ -107,6 +109,9 @@ abstract class TeacherTest : CanvasTest() { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + @Before fun baseSetup() { try { @@ -180,6 +185,7 @@ abstract class TeacherTest : CanvasTest() { val webViewLoginPage = WebViewLoginPage() val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) val updateFilePermissionsPage = UpdateFilePermissionsPage() + val progressPage = ProgressPage(composeTestRule) } diff --git a/libs/pandautils/flank.yml b/libs/pandautils/flank.yml index 61786f3b32..60bd72123a 100644 --- a/libs/pandautils/flank.yml +++ b/libs/pandautils/flank.yml @@ -11,7 +11,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: Pixel2.arm - version: 32 + version: 29 locale: en_US orientation: portrait From 065213fc7d349b3d746b7772184daafe7921642a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Tue, 27 Feb 2024 16:35:25 +0100 Subject: [PATCH 39/52] fix tests --- .../student/ui/utils/StudentComposeTest.kt | 29 +++++++++++++++++ .../student/ui/utils/StudentTest.kt | 3 -- .../teacher/ui/ModuleListPageTest.kt | 3 +- .../teacher/ui/utils/TeacherComposeTest.kt | 32 +++++++++++++++++++ .../teacher/ui/utils/TeacherTest.kt | 4 --- 5 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt create mode 100644 apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt new file mode 100644 index 0000000000..078ec98f70 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.student.ui.utils + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.instructure.student.activity.LoginActivity +import org.junit.Rule + +abstract class StudentComposeTest : StudentTest() { + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index e402cdea7a..8ba9cf009d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -127,9 +127,6 @@ abstract class StudentTest : CanvasTest() { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - // Sometimes activityRule.activity can get nulled out over time, probably as we // navigate away from the original login screen. Capture the activity here so // that we can reference it safely later. diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt index ecf8ffb232..aa902d12ad 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/ModuleListPageTest.kt @@ -31,6 +31,7 @@ import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.util.Randomizer import com.instructure.teacher.R +import com.instructure.teacher.ui.utils.TeacherComposeTest import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.openOverflowMenu import com.instructure.teacher.ui.utils.tokenLogin @@ -38,7 +39,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest -class ModuleListPageTest : TeacherTest() { +class ModuleListPageTest : TeacherComposeTest() { @Test override fun displaysPageObjects() { diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt new file mode 100644 index 0000000000..10679bf125 --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.teacher.ui.utils + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.instructure.teacher.activities.LoginActivity +import com.instructure.teacher.ui.pages.ProgressPage +import org.junit.Rule + +abstract class TeacherComposeTest : TeacherTest() { + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + val progressPage = ProgressPage(composeTestRule) +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index 8c8dafe04b..f67ee6e79d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -109,9 +109,6 @@ abstract class TeacherTest : CanvasTest() { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - @Before fun baseSetup() { try { @@ -185,7 +182,6 @@ abstract class TeacherTest : CanvasTest() { val webViewLoginPage = WebViewLoginPage() val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) val updateFilePermissionsPage = UpdateFilePermissionsPage() - val progressPage = ProgressPage(composeTestRule) } From 5f150452a381d7560fe1a05ca77d4f9a96558c0b Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:55:54 +0100 Subject: [PATCH 40/52] [Teacher] Bulk module update CRs (#2358) refs: - affects: Teacher release note: none --- .../src/main/res/layout/fragment_dialog_update_file.xml | 3 ++- libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml b/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml index dc6abd1318..03074bbaa0 100644 --- a/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml +++ b/apps/teacher/src/main/res/layout/fragment_dialog_update_file.xml @@ -102,8 +102,8 @@ android:id="@+id/scrollView" android:layout_width="match_parent" android:layout_height="match_parent" - android:nestedScrollingEnabled="true" android:fillViewport="true" + android:nestedScrollingEnabled="true" android:visibility="@{viewModel.state instanceof ViewState.Success ? View.VISIBLE : View.GONE}"> diff --git a/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml b/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml index f5d7aafd76..b4dc54eafe 100644 --- a/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml +++ b/libs/pandautils/src/main/res/drawable/bg_rounded_rectangle.xml @@ -18,7 +18,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> - + From f3fa20952e33d939f92f732e89ef0568d8ac3b4d Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 29 Feb 2024 13:58:14 +0100 Subject: [PATCH 41/52] [MBL-17392][Student][Teacher] - Resurrect code coverage (#2359) --- apps/student/flank_coverage.yml | 6 ++--- apps/student/flank_e2e_coverage.yml | 8 +++---- .../ShareExtensionInteractionTest.kt | 2 ++ .../interaction/UserFilesInteractionTest.kt | 14 +++++++++--- .../student/ui/pages/FileUploadPage.kt | 5 +---- apps/teacher/flank_coverage.yml | 8 +++---- apps/teacher/flank_e2e_coverage.yml | 6 ++--- .../canvas/espresso/StubCoverageAnnotation.kt | 22 +++++++++++++++++++ 8 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/StubCoverageAnnotation.kt diff --git a/apps/student/flank_coverage.yml b/apps/student/flank_coverage.yml index 67858de14e..3af12c3e0b 100644 --- a/apps/student/flank_coverage.yml +++ b/apps/student/flank_coverage.yml @@ -19,10 +19,10 @@ gcloud: directories-to-pull: - /sdcard/ test-targets: - - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub + - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.OfflineE2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: NexusLowRes - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e_coverage.yml b/apps/student/flank_e2e_coverage.yml index ef7cccbfd2..55a9ea0a1b 100644 --- a/apps/student/flank_e2e_coverage.yml +++ b/apps/student/flank_e2e_coverage.yml @@ -19,11 +19,11 @@ gcloud: directories-to-pull: - /sdcard/ test-targets: - - annotation com.instructure.canvas.espresso.E2E - - notAnnotation com.instructure.canvas.espresso.Stub + - annotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.OfflineE2E + - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: Nexus6P - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt index 13ab1f1da3..87e4338e7b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt @@ -24,6 +24,7 @@ import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import com.instructure.canvas.espresso.Stub +import com.instructure.canvas.espresso.StubCoverage import com.instructure.canvas.espresso.StubTablet import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment @@ -233,6 +234,7 @@ class ShareExtensionInteractionTest : StudentTest() { } @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") fun testFileAssignmentSubmission() { val data = createMockData() val student = data.students[0] diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt index 2e579c8a33..b5493d3b83 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/UserFilesInteractionTest.kt @@ -26,14 +26,16 @@ import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasType +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import com.instructure.canvas.espresso.Stub -import com.instructure.canvas.espresso.mockCanvas.MockCanvas -import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.Stub +import com.instructure.canvas.espresso.StubCoverage import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.pandautils.utils.Const import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin @@ -91,6 +93,7 @@ class UserFilesInteractionTest : StudentTest() { // Should be able to upload a file from the user's device // Mocks the result from the expected intent, then uploads it. @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.INTERACTION) fun testUpload_deviceFile() { goToFilePicker() @@ -120,6 +123,7 @@ class UserFilesInteractionTest : StudentTest() { // Should be able to upload a file from the camera // Mocks the result from the expected intent, then uploads it. @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.INTERACTION) fun testUpload_fileFromCamera() { @@ -161,6 +165,7 @@ class UserFilesInteractionTest : StudentTest() { // Should be able to upload a file from the user's photo gallery // Mocks the result from the expected intent, then uploads it. @Test + @StubCoverage("Cannot init FileUploadWorker and OfflineSyncWorker") @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.INTERACTION) fun testUpload_gallery() { goToFilePicker() @@ -222,6 +227,9 @@ class UserFilesInteractionTest : StudentTest() { // Set up some rudimentary mock data, navigate to the file list page, then // initiate a file upload private fun goToFilePicker() : MockCanvas { + + File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "file_upload").deleteRecursively() + val data = MockCanvas.init( courseCount = 1, favoriteCourseCount = 1, diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt index 158d895e8b..752a4875cc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileUploadPage.kt @@ -16,11 +16,9 @@ */ package com.instructure.student.ui.pages -import android.widget.Button import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.withId import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.espresso.OnViewWithId @@ -34,7 +32,6 @@ import com.instructure.espresso.page.withDescendant import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.student.R -import org.hamcrest.core.AllOf.allOf class FileUploadPage : BasePage() { private val cameraButton by OnViewWithId(R.id.fromCamera) @@ -56,7 +53,7 @@ class FileUploadPage : BasePage() { } fun clickUpload() { - onView(allOf(isAssignableFrom(Button::class.java), withText(R.string.upload))).click() + onView(withText(R.string.upload)).click() } fun clickTurnIn() { diff --git a/apps/teacher/flank_coverage.yml b/apps/teacher/flank_coverage.yml index c23ecac71e..3d23a78f48 100644 --- a/apps/teacher/flank_coverage.yml +++ b/apps/teacher/flank_coverage.yml @@ -19,15 +19,15 @@ gcloud: directories-to-pull: - /sdcard/ test-targets: - - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub + - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: NexusLowRes - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait flank: - testShards: 16 + testShards: 10 testRuns: 1 files-to-download: - .*\.ec$ diff --git a/apps/teacher/flank_e2e_coverage.yml b/apps/teacher/flank_e2e_coverage.yml index eea5ec934e..af3fe15179 100644 --- a/apps/teacher/flank_e2e_coverage.yml +++ b/apps/teacher/flank_e2e_coverage.yml @@ -20,10 +20,10 @@ gcloud: - /sdcard/ test-targets: - annotation com.instructure.canvas.espresso.E2E - - notAnnotation com.instructure.canvas.espresso.Stub + - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.StubCoverage device: - - model: Nexus6P - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/StubCoverageAnnotation.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/StubCoverageAnnotation.kt new file mode 100644 index 0000000000..66d7c43636 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/StubCoverageAnnotation.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 - 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 . + */ + +package com.instructure.canvas.espresso + +// When applied to a test method, denotes that the test is stubbed out from the coverage workflow. +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class StubCoverage(val description: String = "") \ No newline at end of file From e5f62cc979e4b6930e5fe1cc636572592a770b04 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 29 Feb 2024 13:58:31 +0100 Subject: [PATCH 42/52] Put back CollaborationsE2ETest to nightly. (Remove @KnownBug since the bug has been fixed) (#2357) --- .../student/ui/e2e/CollaborationsE2ETest.kt | 26 +++++++------------ .../student/ui/pages/CollaborationsPage.kt | 6 ++--- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt index 69fd95887e..6b22a2228c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/CollaborationsE2ETest.kt @@ -3,7 +3,6 @@ package com.instructure.student.ui.e2e import android.util.Log import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.KnownBug import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData @@ -15,20 +14,15 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test -/** - * Very basic test to verify that the collaborations web page shows up correctly. - * We make no attempt to actually start a collaboration. - * This test could break if changes are made to the web page that we bring up. - */ @HiltAndroidTest class CollaborationsE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @E2E @Test - @KnownBug("https://instructure.atlassian.net/browse/VICE-3157") @TestMetaData(Priority.MANDATORY, FeatureCategory.COLLABORATIONS, TestCategory.E2E) fun testCollaborationsE2E() { @@ -37,24 +31,24 @@ class CollaborationsE2ETest: StudentTest() { val student = data.studentsList[0] val course = data.coursesList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Navigate to ${course.name} course's Collaborations Page.") + Log.d(STEP_TAG,"Navigate to '${course.name}' course's Collaborations Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectCollaborations() Log.d(STEP_TAG,"Verify that various elements of the web page are present.") CollaborationsPage.assertCurrentCollaborationsHeaderPresent() - //On some screen size, this spinner does not displayed at all, instead of it, - //there is a button on the top-right corner with the 'Start a new Collaboration' text - //and clicking on it will 'expand' and display this spinner. - //However, there is a bug (see link in this @KnownBug annotation) which is about the button not displayed on some screen size - //So this test will breaks until it this ticket will be fixed. + Log.d(STEP_TAG, "Assert that the 'Start a New Collaboration' button is displayed.") CollaborationsPage.assertStartANewCollaborationPresent() - CollaborationsPage.assertGoogleDocsChoicePresent() - CollaborationsPage.assertGoogleDocsExplanationPresent() + + Log.d(STEP_TAG, "Assert that within the selector, the 'Google Docs' has been selected as the default value.") + CollaborationsPage.assertGoogleDocsChoicePresentAsDefaultOption() + + Log.d(STEP_TAG, "Assert that the warning section (under the selector) of Google Docs has been displayed.") + CollaborationsPage.assertGoogleDocsWarningDescriptionPresent() } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt index d7dd3b1541..4d40a43b7d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CollaborationsPage.kt @@ -47,18 +47,18 @@ object CollaborationsPage { .checkRepeat(webMatches(getText(), containsString("Start a New Collaboration") ), 30) } - fun assertGoogleDocsChoicePresent() { + fun assertGoogleDocsChoicePresentAsDefaultOption() { Web.onWebView(Matchers.allOf(withId(R.id.contentWebView), isDisplayed())) .withElement(DriverAtoms.findElement(Locator.ID, "collaboration_collaboration_type")) .perform(DriverAtoms.webScrollIntoView()) .check(webMatches(getText(), containsString("Google Docs") )) } - fun assertGoogleDocsExplanationPresent() { + fun assertGoogleDocsWarningDescriptionPresent() { Web.onWebView(Matchers.allOf(withId(R.id.contentWebView), isDisplayed())) .withElement(DriverAtoms.findElement(Locator.ID, "google_docs_description")) .perform(DriverAtoms.webScrollIntoView()) - .check(webMatches(getText(), containsString("Google Docs is a great place to collaborate") )) + .check(webMatches(getText(), containsString("Warning:") )) } } \ No newline at end of file From 79abfb05961b5b357bec5714235e87f3a88c8737 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Mon, 4 Mar 2024 09:37:54 +0100 Subject: [PATCH 43/52] [MBL-17416][Student][Teacher] Fix conflicting dependencies. #2365 refs: MBL-17416 affects: Student, Teacher release note: none --- buildSrc/src/main/java/GlobalDependencies.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/buildSrc/src/main/java/GlobalDependencies.kt b/buildSrc/src/main/java/GlobalDependencies.kt index 6abf8574d4..7b9cbc0fe9 100644 --- a/buildSrc/src/main/java/GlobalDependencies.kt +++ b/buildSrc/src/main/java/GlobalDependencies.kt @@ -168,10 +168,10 @@ object Libs { const val COMPOSE_MATERIAL = "androidx.compose.material:material" const val COMPOSE_PREVIEW = "androidx.compose.ui:ui-tooling-preview" const val COMPOSE_TOOLING = "androidx.compose.ui:ui-tooling" - const val COMPOSE_UI = "androidx.compose.ui:ui-android:1.6.0" + const val COMPOSE_UI = "androidx.compose.ui:ui-android" const val COMPOSE_VIEW_MODEL = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1" - const val COMPOSE_UI_TEST = "androidx.compose.ui:ui-test-junit4:1.6.1" - const val COMPOSE_UI_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest:1.6.1" + const val COMPOSE_UI_TEST = "androidx.compose.ui:ui-test-junit4:1.5.4" + const val COMPOSE_UI_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest" } object Plugins { From c33c473a48efd1f5f66cf8ea1a03f49f9ca26090 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Mon, 4 Mar 2024 10:54:59 +0100 Subject: [PATCH 44/52] [MBL-16672][Teacher] - Simplify API calls in Teacher E2E tests (#2356) --- .../teacher/ui/e2e/AnnouncementsE2ETest.kt | 10 +- .../teacher/ui/e2e/AssignmentE2ETest.kt | 77 +--------- .../teacher/ui/e2e/CommentLibraryE2ETest.kt | 33 ++--- .../teacher/ui/e2e/CourseSettingsE2ETest.kt | 8 +- .../teacher/ui/e2e/DashboardE2ETest.kt | 5 +- .../teacher/ui/e2e/DiscussionsE2ETest.kt | 7 +- .../teacher/ui/e2e/FilesE2ETest.kt | 96 +++---------- .../teacher/ui/e2e/InboxE2ETest.kt | 54 +++---- .../teacher/ui/e2e/LoginE2ETest.kt | 27 ++-- .../teacher/ui/e2e/ModulesE2ETest.kt | 133 +++--------------- .../teacher/ui/e2e/PagesE2ETest.kt | 98 ++++++------- .../teacher/ui/e2e/PeopleE2ETest.kt | 41 ++---- .../instructure/teacher/ui/e2e/QuizE2ETest.kt | 44 ++---- .../teacher/ui/e2e/SettingsE2ETest.kt | 31 ++-- .../teacher/ui/e2e/SpeedGraderE2ETest.kt | 45 ++---- .../teacher/ui/e2e/SyllabusE2ETest.kt | 54 +++---- .../instructure/teacher/ui/e2e/TodoE2ETest.kt | 53 ++----- 17 files changed, 231 insertions(+), 585 deletions(-) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt index 93d4fe5944..d4581c848a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AnnouncementsE2ETest.kt @@ -29,11 +29,7 @@ import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test -/** - * Announcements e2e test - * - * @constructor Create empty Announcements e2e test - */ + @HiltAndroidTest class AnnouncementsE2ETest : TeacherTest() { @@ -44,10 +40,6 @@ class AnnouncementsE2ETest : TeacherTest() { //Because of naming conventions, we are using 'announcementDetailsPage' naming in this class to make the code more readable and straightforward. private val announcementDetailsPage = discussionsDetailsPage - /** - * Test announcements e2e - * - */ @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ANNOUNCEMENTS, TestCategory.E2E) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt index 08521aef09..b11e0feab6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/AssignmentE2ETest.kt @@ -25,10 +25,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.AttachmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.FileUploadType import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType @@ -181,8 +177,8 @@ class AssignmentE2ETest : TeacherTest() { assignmentDetailsPage.assertNotSubmitted(1,3) assignmentDetailsPage.assertNeedsGrading(2,3) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${gradedStudent.name} student.") - gradeSubmission(teacher, course, assignment, gradedStudent) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${gradedStudent.name}' student.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment[0].id, gradedStudent.id, postedGrade = "15") Log.d(STEP_TAG,"Refresh the page. Assert that the number of 'Graded' is increased and the number of 'Not Submitted' and 'Needs Grading' are decreased.") assignmentDetailsPage.refresh() @@ -360,7 +356,7 @@ class AssignmentE2ETest : TeacherTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG, "Seed a text assignment/file/submission.") - val assignment = createAssignment(course, teacher) + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), allowedExtensions = listOf("txt")) Log.d(PREPARATION_TAG, "Seed a text file.") val submissionUploadInfo = uploadTextFile( @@ -370,8 +366,8 @@ class AssignmentE2ETest : TeacherTest() { fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION ) - Log.d(PREPARATION_TAG, "Submit the ${assignment.name} assignment.") - submitCourseAssignment(course, assignment, submissionUploadInfo, student) + Log.d(PREPARATION_TAG, "Submit the '${assignment.name}' assignment.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, submissionType = SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(submissionUploadInfo.id)) Log.d(PREPARATION_TAG,"Seed a comment attachment upload.") val commentUploadInfo = uploadTextFile( @@ -381,7 +377,8 @@ class AssignmentE2ETest : TeacherTest() { fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + Log.d(PREPARATION_TAG, "Comment a text file as a teacher to the '${student.name}' student's submission of the '${assignment.name}' assignment.") + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, fileIds = mutableListOf(commentUploadInfo.id)) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(teacher) @@ -403,64 +400,4 @@ class AssignmentE2ETest : TeacherTest() { assignmentSubmissionListPage.assertCommentAttachmentDisplayedCommon(commentUploadInfo.fileName, student.shortName) } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: List, - gradedStudent: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment[0].id, - studentId = gradedStudent.id, - postedGrade = "15", - excused = false - ) - } - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = false, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - allowedExtensions = listOf("txt"), - teacherToken = teacher.token - ) - ) - } - - private fun submitCourseAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - submissionUploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(submissionUploadInfo.id), - studentToken = student.token - ) - } - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } - } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt index f44d8e23d7..80870d73a1 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CommentLibraryE2ETest.kt @@ -27,7 +27,7 @@ import com.instructure.dataseeding.api.CommentLibraryApi import com.instructure.dataseeding.api.SubmissionsApi import com.instructure.dataseeding.api.UserApi import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.GradingType +import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.model.UserSettingsApiModel import com.instructure.dataseeding.util.days @@ -56,8 +56,8 @@ class CommentLibraryE2ETest : TeacherTest() { val student = data.studentsList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Preparing assignment and submit that with the student. Enable comment library in user settings.") - val testAssignment = prepareData(course.id, student.token, teacher.token, teacher.id) + Log.d(PREPARATION_TAG,"Make an assignment with a submission for the '${course.name}' course and '${student.name}' student. Set the 'Show suggestions when typing' setting to see the comment library itself.") + val testAssignment = prepareSettingsAndMakeAssignmentWithSubmission(course, student.token, teacher.token, teacher.id) Log.d(PREPARATION_TAG,"Generate comments for comment library.") val testComment = "Test Comment" @@ -76,7 +76,7 @@ class CommentLibraryE2ETest : TeacherTest() { speedGraderPage.selectCommentsTab() val testText = "another" - Log.d(STEP_TAG,"Type $testText word and check if there is only one matching suggestion visible.") + Log.d(STEP_TAG,"Type '$testText' word and check if there is only one matching suggestion visible.") speedGraderCommentsPage.typeComment(testText) commentLibraryPage.assertPageObjects() commentLibraryPage.assertSuggestionsCount(1) @@ -89,7 +89,7 @@ class CommentLibraryE2ETest : TeacherTest() { commentLibraryPage.assertSuggestionsCount(2) val testText2 = "test" - Log.d(STEP_TAG,"Type $testText2 word and check if there are two matching suggestion visible.") + Log.d(STEP_TAG,"Type '$testText2' word and check if there are two matching suggestion visible.") commentLibraryPage.closeCommentLibrary() speedGraderCommentsPage.typeComment(testText2) commentLibraryPage.assertPageObjects() @@ -117,30 +117,15 @@ class CommentLibraryE2ETest : TeacherTest() { commentLibraryPage.assertEmptyViewVisible() } - private fun prepareData( - courseId: Long, + private fun prepareSettingsAndMakeAssignmentWithSubmission( + course: CourseApiModel, studentToken: String, teacherToken: String, teacherId: Long ): AssignmentApiModel { - val testAssignment = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacherToken, - pointsPossible = 25.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_TEXT_ENTRY, - courseId = courseId, - assignmentId = testAssignment.id, - fileIds = emptyList().toMutableList(), - studentToken = studentToken - ) + val testAssignment = AssignmentsApi.createAssignment(course.id, teacherToken, pointsPossible = 25.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + SubmissionsApi.submitCourseAssignment(course.id, studentToken, testAssignment.id, submissionType = SubmissionType.ONLINE_TEXT_ENTRY) val request = UserSettingsApiModel( manualMarkAsRead = false, diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt index ba5c4d92bc..3fdd3691c6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/CourseSettingsE2ETest.kt @@ -48,10 +48,10 @@ class CourseSettingsE2ETest : TeacherTest() { val firstCourse = data.coursesList[0] val secondCourse = data.coursesList[1] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) - Log.d(STEP_TAG, "Open ${firstCourse.name} course and click on Course Settings button.") + Log.d(STEP_TAG, "Open '${firstCourse.name}' course and click on Course Settings button.") dashboardPage.waitForRender() dashboardPage.openCourse(firstCourse) courseBrowserPage.clickSettingsButton() @@ -63,7 +63,7 @@ class CourseSettingsE2ETest : TeacherTest() { courseSettingsPage.assertHomePageChanged(newCourseHomePage) val newCourseName = "New Course Name" - Log.d(STEP_TAG, "Click on 'Course Name' menu and edit course's name to be $newCourseName. Assert that the course's name has been changed.") + Log.d(STEP_TAG, "Click on 'Course Name' menu and edit course's name to be '$newCourseName'. Assert that the course's name has been changed.") courseSettingsPage.clickCourseName() courseSettingsPage.editCourseName(newCourseName) courseSettingsPage.assertCourseNameChanged(newCourseName) @@ -78,7 +78,7 @@ class CourseSettingsE2ETest : TeacherTest() { dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(newCourseName) - Log.d(STEP_TAG, "Open ${secondCourse.name} course and click on Course Settings button.") + Log.d(STEP_TAG, "Open '${secondCourse.name}' course and click on Course Settings button.") dashboardPage.waitForRender() dashboardPage.openCourse(secondCourse) courseBrowserPage.clickSettingsButton() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt index f0899b4832..c135b476ad 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DashboardE2ETest.kt @@ -86,7 +86,7 @@ class DashboardE2ETest : TeacherTest() { dashboardPage.assertDisplaysCourse(course2) dashboardPage.assertCourseNotDisplayed(course1) - Log.d(STEP_TAG,"Opens ${course2.name} course and assert if Course Details Page has been opened. Navigate back to Dashboard Page.") + Log.d(STEP_TAG,"Opens '${course2.name}' course and assert if Course Details Page has been opened. Navigate back to Dashboard Page.") dashboardPage.assertOpensCourse(course2) Espresso.pressBack() @@ -146,11 +146,12 @@ class DashboardE2ETest : TeacherTest() { @Test @TestMetaData(Priority.NICE_TO_HAVE, FeatureCategory.DASHBOARD, TestCategory.E2E) fun testHelpMenuE2E() { + Log.d(PREPARATION_TAG,"Seeding data.") val data = seedData(teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG,"Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG,"Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt index b81217bb91..3792c0fc5a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/DiscussionsE2ETest.kt @@ -31,6 +31,7 @@ import org.junit.Test @HiltAndroidTest class DiscussionsE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -47,11 +48,11 @@ class DiscussionsE2ETest : TeacherTest() { val discussion = data.discussionsList[0] val discussion2 = data.discussionsList[1] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course.") + Log.d(STEP_TAG,"Open '${course.name}' course.") dashboardPage.openCourse(course.name) courseBrowserPage.waitForRender() @@ -90,7 +91,7 @@ class DiscussionsE2ETest : TeacherTest() { discussionsListPage.assertGroupDisplayed("Pinned") discussionsListPage.assertDiscussionInGroup("Pinned", discussion2.title) - Log.d(STEP_TAG, "Assert that both of the discussions, '${discussion.title}' and '${discussion2.title}' discusssions are displayed.") + Log.d(STEP_TAG, "Assert that both of the discussions, '${discussion.title}' and '${discussion2.title}' discussions are displayed.") discussionsListPage.assertHasDiscussion(newTitle) discussionsListPage.assertHasDiscussion(discussion2) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt index 942c32b4da..168d5053f9 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/FilesE2ETest.kt @@ -26,18 +26,12 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.DiscussionEntry import com.instructure.canvasapi2.utils.weave.awaitApiResponse import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.AttachmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.model.DiscussionApiModel import com.instructure.dataseeding.model.FileUploadType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.Randomizer @@ -53,6 +47,7 @@ import java.io.FileWriter @HiltAndroidTest class FilesE2ETest: TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -69,7 +64,7 @@ class FilesE2ETest: TeacherTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG, "Seed a text assignment/file/submission.") - val assignment = createAssignment(course, teacher) + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), allowedExtensions = listOf("txt")) Log.d(PREPARATION_TAG, "Seed a text file.") val submissionUploadInfo = uploadTextFile( @@ -79,8 +74,8 @@ class FilesE2ETest: TeacherTest() { fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION ) - Log.d(PREPARATION_TAG, "Submit the ${assignment.name} assignment.") - submitCourseAssignment(course, assignment, submissionUploadInfo, student) + Log.d(PREPARATION_TAG, "Submit the '${assignment.name}' assignment.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, submissionType = SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(submissionUploadInfo.id)) Log.d(PREPARATION_TAG,"Seed a comment attachment upload.") val commentUploadInfo = uploadTextFile( @@ -90,10 +85,11 @@ class FilesE2ETest: TeacherTest() { fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + Log.d(PREPARATION_TAG, "Comment a text file as a teacher to the '${student.name}' student's submission of the '${assignment.name}' assignment.") + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, fileIds = mutableListOf(commentUploadInfo.id)) Log.d(PREPARATION_TAG,"Seed a discussion topic. Will add a reply with attachment below.") - val discussionTopic = createDiscussion(course, student) + val discussionTopic= DiscussionTopicsApi.createDiscussion(course.id, student.token) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(teacher) @@ -113,7 +109,7 @@ class FilesE2ETest: TeacherTest() { Log.d(PREPARATION_TAG,"Use real API (rather than seeding) to create a reply to our discussion that contains an attachment.") tryWeave { - awaitApiResponse { + awaitApiResponse { DiscussionManager.postToDiscussionTopic( canvasContext = CanvasContext.emptyCourseContext(id = course.id), topicId = discussionTopic.id, @@ -132,26 +128,26 @@ class FilesE2ETest: TeacherTest() { Log.d(STEP_TAG,"Assert that there is a directory called 'unfiled' is displayed.") fileListPage.assertItemDisplayed("unfiled") // Our discussion attachment goes under "unfiled" - Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.selectItem("unfiled") fileListPage.assertItemDisplayed(discussionAttachmentFile.name) Log.d(STEP_TAG,"Navigate back to the Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Assignments Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Assignments Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openAssignmentsTab() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment and navigate to Submissions Page.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment and navigate to Submissions Page.") assignmentListPage.clickAssignment(assignment) assignmentDetailsPage.openSubmissionsPage() - Log.d(STEP_TAG,"Click on ${student.name} student's submission and navigate to Files Tab.") + Log.d(STEP_TAG,"Click on '${student.name}' student's submission and navigate to Files Tab.") assignmentSubmissionListPage.clickSubmission(student) speedGraderPage.selectFilesTab(1) - Log.d(STEP_TAG,"Assert that ${submissionUploadInfo.fileName} file. Navigate to Comments Tab and ${commentUploadInfo.fileName} comment attachment is displayed.") + Log.d(STEP_TAG,"Assert that '${submissionUploadInfo.fileName}' file. Navigate to Comments Tab and '${commentUploadInfo.fileName}' comment attachment is displayed.") assignmentSubmissionListPage.assertFileDisplayed(submissionUploadInfo.fileName) speedGraderPage.selectCommentsTab() assignmentSubmissionListPage.assertCommentAttachmentDisplayedCommon(commentUploadInfo.fileName, student.shortName) @@ -178,25 +174,25 @@ class FilesE2ETest: TeacherTest() { fileListPage.searchable.pressSearchBackButton() fileListPage.assertFileListCount(1) - Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.selectItem("unfiled") fileListPage.assertItemDisplayed(discussionAttachmentFile.name) - Log.d(STEP_TAG,"Select ${discussionAttachmentFile.name} file.") + Log.d(STEP_TAG,"Select '${discussionAttachmentFile.name}' file.") fileListPage.selectItem(discussionAttachmentFile.name) val newFileName = "newFileName.txt" - Log.d(STEP_TAG,"Rename ${discussionAttachmentFile.name} file to: $newFileName.") + Log.d(STEP_TAG,"Rename '${discussionAttachmentFile.name}' file to: '$newFileName'.") fileListPage.renameFile(newFileName) Log.d(STEP_TAG,"Navigate back to File List Page.") Espresso.pressBack() fileListPage.assertPageObjects() - Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: $newFileName.") + Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: '$newFileName'.") fileListPage.assertItemDisplayed(newFileName) - Log.d(STEP_TAG,"Delete $newFileName file.") + Log.d(STEP_TAG,"Delete '$newFileName' file.") fileListPage.deleteFile(newFileName) fileListPage.assertPageObjects() @@ -209,7 +205,7 @@ class FilesE2ETest: TeacherTest() { fileListPage.createFolder(newFolderName) fileListPage.assertItemDisplayed(newFolderName) - Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${newFolderName}', the file's name to the search input field.") + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '$newFolderName', the file's name to the search input field.") fileListPage.searchable.clickOnSearchButton() fileListPage.searchable.typeToSearchBar(newFolderName) @@ -222,58 +218,4 @@ class FilesE2ETest: TeacherTest() { fileListPage.assertItemNotDisplayed(newFolderName) } - private fun createDiscussion( - course: CourseApiModel, - student: CanvasUserApiModel - ): DiscussionApiModel { - return DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = student.token - ) - } - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } - - private fun submitCourseAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - submissionUploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(submissionUploadInfo.id), - studentToken = student.token - ) - } - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = false, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - allowedExtensions = listOf("txt"), - teacherToken = teacher.token - ) - ) - } - } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt index 4c74dc3ff7..2ae6c04d5f 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt @@ -23,15 +23,16 @@ import org.junit.Test @HiltAndroidTest class InboxE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit - @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) fun testInboxMessageComposeReplyAndOptionMenuActionsE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 2, teachers = 1, courses = 1) val teacher = data.teachersList[0] @@ -41,10 +42,10 @@ class InboxE2ETest : TeacherTest() { val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) val group = GroupsApi.createGroup(groupCategory.id, teacher.token) - Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} student to the group: ${group.name}.") + Log.d(PREPARATION_TAG, "Create group membership for '${student1.name}' student to the group: '${group.name}'.") GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) @@ -80,11 +81,11 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG,"Add a new conversation message manually via UI. Click on 'New Message' ('+') button.") inboxPage.clickAddMessageFAB() - Log.d(STEP_TAG,"Select ${course.name} from course spinner.Click on the '+' icon next to the recipients input field. Select the two students: ${student1.name} and ${student2.name}. Click on 'Done'.") + Log.d(STEP_TAG,"Select '${course.name}' from course spinner. Click on the '+' icon next to the recipients input field. Select the two students: '${student1.name}' and '${student2.name}'. Click on 'Done'.") addNewMessage(course,data.studentsList) val subject = "Hello there" - Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject. Add some message text and click on 'Send' (aka. 'Arrow') button.") + Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: '$subject'. Add some message text and click on 'Send' (aka. 'Arrow') button.") addMessagePage.composeMessageWithSubject(subject, "General Kenobi") addMessagePage.clickSendButton() @@ -94,7 +95,7 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that the previously sent conversation is displayed.") inboxPage.assertHasConversation() - Log.d(STEP_TAG,"Click on $subject conversation.") + Log.d(STEP_TAG,"Click on '$subject' conversation.") inboxPage.clickConversation(subject) val replyMessageTwo = "Test Reply 2" @@ -171,10 +172,10 @@ class InboxE2ETest : TeacherTest() { val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) val group = GroupsApi.createGroup(groupCategory.id, teacher.token) - Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} student to the group: ${group.name}.") + Log.d(PREPARATION_TAG, "Create group membership for '${student1.name}' student to the group: '${group.name}'.") GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) @@ -184,30 +185,17 @@ class InboxE2ETest : TeacherTest() { inboxPage.assertInboxEmpty() Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") - val seedConversation = ConversationsApi.createConversation( - token = student1.token, - recipients = listOf(teacher.id.toString()) - ) + val seedConversation = ConversationsApi.createConversation(token = student1.token, recipients = listOf(teacher.id.toString())) Log.d(STEP_TAG, "Refresh the page. Assert that the conversation displayed as unread.") inboxPage.refresh() inboxPage.assertThereIsAnUnreadMessage(true) Log.d(PREPARATION_TAG, "Seed another Inbox conversation via API.") - val seedConversation2 = ConversationsApi.createConversation( - token = student1.token, - recipients = listOf(teacher.id.toString()), - subject = "Second conversation", - body = "Second body" - ) + val seedConversation2 = ConversationsApi.createConversation(token = student1.token, recipients = listOf(teacher.id.toString()), subject = "Second conversation", body = "Second body") Log.d(PREPARATION_TAG, "Seed a third Inbox conversation via API.") - val seedConversation3 = ConversationsApi.createConversation( - token = student2.token, - recipients = listOf(teacher.id.toString()), - subject = "Third conversation", - body = "Third body" - ) + val seedConversation3 = ConversationsApi.createConversation(token = student2.token, recipients = listOf(teacher.id.toString()), subject = "Third conversation", body = "Third body") Log.d(STEP_TAG,"Refresh the page. Filter the Inbox by selecting 'Inbox' category from the spinner on Inbox Page. Assert that the '${seedConversation[0]}' conversation is displayed. Assert that the conversation is unread yet.") inboxPage.refresh() @@ -234,11 +222,11 @@ class InboxE2ETest : TeacherTest() { inboxPage.clickUnArchive() inboxPage.assertConversationNotDisplayed(seedConversation2[0].subject) - Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seedConversation2[0].subject} conversation is displayed.") + Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that '${seedConversation2[0].subject}' conversation is displayed.") inboxPage.filterMessageScope("Inbox") inboxPage.assertConversationDisplayed(seedConversation2[0].subject) - Log.d(STEP_TAG, "Select both of the conversations (${seedConversation[0].subject} and ${seedConversation2[0].subject} and star them." + + Log.d(STEP_TAG, "Select both of the conversations '${seedConversation[0].subject}' and '${seedConversation2[0].subject}' and star them." + "Assert that both of the has been starred and the selected number of conversations on the toolbar shows 2") inboxPage.selectConversations(listOf(seedConversation2[0].subject, seedConversation3[0].subject)) inboxPage.assertSelectedConversationNumber("2") @@ -303,6 +291,7 @@ class InboxE2ETest : TeacherTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) fun testInboxSwipeGesturesE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 2, teachers = 1, courses = 1) val teacher = data.teachersList[0] @@ -312,10 +301,10 @@ class InboxE2ETest : TeacherTest() { val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) val group = GroupsApi.createGroup(groupCategory.id, teacher.token) - Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} student to the group: ${group.name}.") + Log.d(PREPARATION_TAG, "Create group membership for '${student1.name}' student to the group: '${group.name}'.") GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) @@ -325,10 +314,7 @@ class InboxE2ETest : TeacherTest() { inboxPage.assertInboxEmpty() Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") - val seedConversation = ConversationsApi.createConversation( - token = student1.token, - recipients = listOf(teacher.id.toString()) - ) + ConversationsApi.createConversation(token = student1.token, recipients = listOf(teacher.id.toString())) Log.d(STEP_TAG,"Refresh the page. Assert that the previously seeded Inbox conversation is displayed. Assert that the message is unread yet.") inboxPage.refresh() @@ -454,10 +440,10 @@ class InboxE2ETest : TeacherTest() { val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) val group = GroupsApi.createGroup(groupCategory.id, teacher.token) - Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} student to the group: ${group.name}.") + Log.d(PREPARATION_TAG, "Create group membership for '${student1.name}' student to the group: '${group.name}'.") GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt index 9b6ff40f1c..1c7ffa3be7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/LoginE2ETest.kt @@ -33,6 +33,7 @@ import org.junit.Test @HiltAndroidTest class LoginE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -48,19 +49,19 @@ class LoginE2ETest : TeacherTest() { val teacher2 = data.teachersList[1] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher1.name}, login id: ${teacher1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher1.name}', login id: '${teacher1.loginId}'.") loginWithUser(teacher1) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertSuccessfulLogin(teacher1) - Log.d(STEP_TAG,"Validate ${teacher1.name} user's role as a Teacher.") + Log.d(STEP_TAG,"Validate '${teacher1.name}' user's role as a Teacher.") validateUserRole(teacher1, course, "Teacher") - Log.d(STEP_TAG,"Log out with ${teacher1.name} student.") + Log.d(STEP_TAG,"Log out with '${teacher1.name}' student.") leftSideNavigationDrawerPage.logout() - Log.d(STEP_TAG, "Login with user: ${teacher2.name}, login id: ${teacher2.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher2.name}', login id: '${teacher2.loginId}'.") loginWithUser(teacher2, true) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -72,7 +73,7 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that the previously logins has been displayed.") loginLandingPage.assertDisplaysPreviousLogins() - Log.d(STEP_TAG, "Login with user: ${teacher1.name}, login id: ${teacher1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher1.name}', login id: '${teacher1.loginId}'.") loginWithUser(teacher1, true) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -84,7 +85,7 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that the previously logins has been displayed.") loginLandingPage.assertDisplaysPreviousLogins() - Log.d(STEP_TAG,"Login with the previous user, ${teacher2.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG,"Login with the previous user, '${teacher2.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(teacher2) Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -106,7 +107,7 @@ class LoginE2ETest : TeacherTest() { val student = data.studentsList[0] val parent = parentData.parentsList[0] - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") loginWithUser(student) Log.d(STEP_TAG,"Assert that the user has been landed on 'Not a teacher?' Page.") @@ -118,7 +119,7 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert the Teacher app's Login Landing Page's screen is displayed.") loginLandingPage.assertPageObjects() - Log.d(STEP_TAG, "Login with user: ${parent.name}, login id: ${parent.loginId}.") + Log.d(STEP_TAG, "Login with user: '${parent.name}', login id: '${parent.loginId}'.") loginWithUser(parent, true) Log.d(STEP_TAG,"Assert that the user has been landed on 'Not a teacher?' Page.") @@ -141,16 +142,16 @@ class LoginE2ETest : TeacherTest() { val teacher1 = data.teachersList[0] val teacher2 = data.teachersList[1] - Log.d(STEP_TAG, "Login with user: ${teacher1.name}, login id: ${teacher1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher1.name}', login id: '${teacher1.loginId}'.") loginWithUser(teacher1) Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertSuccessfulLogin(teacher1) - Log.d(STEP_TAG, "Log out with ${teacher1.name} student.") + Log.d(STEP_TAG, "Log out with '${teacher1.name}' student.") leftSideNavigationDrawerPage.logout() - Log.d(STEP_TAG, "Login with user: ${teacher2.name}, login id: ${teacher2.loginId}, via the last saved school's button.") + Log.d(STEP_TAG, "Login with user: '${teacher2.name}', login id: '${teacher2.loginId}', via the last saved school's button.") loginWithLastSavedSchool(teacher2) Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -171,13 +172,13 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG, "Click 'Find My School' button.") loginLandingPage.clickFindMySchoolButton() - Log.d(STEP_TAG,"Enter domain: $DOMAIN.instructure.com.") + Log.d(STEP_TAG,"Enter domain: '$DOMAIN.instructure.com'.") loginFindSchoolPage.enterDomain(DOMAIN) Log.d(STEP_TAG,"Click on 'Next' button on the Toolbar.") loginFindSchoolPage.clickToolbarNextMenuItem() - Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials ($INVALID_USERNAME, $INVALID_PASSWORD)." + + Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials: '$INVALID_USERNAME', '$INVALID_PASSWORD'." + "Assert that the invalid credentials error message is displayed.") loginSignInPage.loginAs(INVALID_USERNAME, INVALID_PASSWORD) loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt index 56fe5e720b..8bdc2a1413 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt @@ -12,13 +12,7 @@ import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.api.ModulesApi import com.instructure.dataseeding.api.PagesApi import com.instructure.dataseeding.api.QuizzesApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.model.ModuleApiModel import com.instructure.dataseeding.model.ModuleItemTypes -import com.instructure.dataseeding.model.PageApiModel -import com.instructure.dataseeding.model.QuizApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -58,29 +52,31 @@ class ModulesE2ETest : TeacherTest() { moduleListPage.assertEmptyView() Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") - val assignment = createAssignment(course, teacher) + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601) Log.d(PREPARATION_TAG,"Seeding quiz for '${course.name}' course.") - val quiz = createQuiz(course, teacher) + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, withDescription = true, dueAt = 3.days.fromNow.iso8601) Log.d(PREPARATION_TAG,"Create an unpublished page for course: '${course.name}'.") - val testPage = createCoursePage(course, teacher, published = false, frontPage = false, body = "

Test Page Text

") + val testPage = PagesApi.createCoursePage(course.id, teacher.token, published = false, body = "

Test Page Text

") Log.d(PREPARATION_TAG,"Create a discussion topic for '${course.name}' course.") - val discussionTopic = createDiscussion(course, teacher) + val discussionTopic = DiscussionTopicsApi.createDiscussion(courseId = course.id, token = teacher.token) Log.d(PREPARATION_TAG,"Seeding a module for '${course.name}' course. It starts as unpublished.") - val module = createModule(course, teacher) + val module = ModulesApi.createModule(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Associate '${assignment.name}' assignment (and the quiz within it) with module: '${module.id}'.") - createModuleItem(course, module, teacher, assignment.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment.id.toString()) - createModuleItem(course, module, teacher, quiz.title, ModuleItemTypes.QUIZ.stringVal, quiz.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${assignment.name}' assignment with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = assignment.name, moduleItemType = ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment.id.toString()) + + Log.d(PREPARATION_TAG,"Associate '${quiz.title}' quiz with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = quiz.title, moduleItemType = ModuleItemTypes.QUIZ.stringVal, contentId = quiz.id.toString()) Log.d(PREPARATION_TAG,"Associate '${testPage.title}' page with module: '${module.id}'.") - createModuleItem(course, module, teacher, testPage.title, ModuleItemTypes.PAGE.stringVal, null, pageUrl = testPage.url) + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = testPage.title, moduleItemType = ModuleItemTypes.PAGE.stringVal, contentId = null, pageUrl = testPage.url) Log.d(PREPARATION_TAG,"Associate '${discussionTopic.title}' discussion with module: '${module.id}'.") - createModuleItem(course, module, teacher, discussionTopic.title, ModuleItemTypes.DISCUSSION.stringVal, discussionTopic.id.toString()) + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = discussionTopic.title, moduleItemType = ModuleItemTypes.DISCUSSION.stringVal, contentId = discussionTopic.id.toString()) Log.d(STEP_TAG,"Refresh the page. Assert that '${module.name}' module is displayed and it is unpublished by default.") moduleListPage.refresh() @@ -92,12 +88,7 @@ class ModulesE2ETest : TeacherTest() { moduleListPage.assertModuleItemNotPublished(testPage.title) Log.d(PREPARATION_TAG,"Publish '${module.name}' module via API.") - ModulesApi.updateModule( - courseId = course.id, - moduleId = module.id, - published = true, - teacherToken = teacher.token - ) + ModulesApi.updateModule(courseId = course.id, moduleId = module.id, published = true, teacherToken = teacher.token) Log.d(STEP_TAG,"Refresh the page. Assert that '${module.name}' module is displayed and it is published.") moduleListPage.refresh() @@ -169,17 +160,12 @@ class ModulesE2ETest : TeacherTest() { Espresso.pressBack() Log.d(PREPARATION_TAG,"Unpublish ${module.name} module via API.") - ModulesApi.updateModule( - courseId = course.id, - moduleId = module.id, - published = false, - teacherToken = teacher.token - ) - - Log.d(STEP_TAG, "Refresh the Modules Page.") + ModulesApi.updateModule(courseId = course.id, moduleId = module.id, published = false, teacherToken = teacher.token) + + Log.d(STEP_TAG, "Refresh the Module List Page.") moduleListPage.refresh() - Log.d(STEP_TAG,"Assert that ${assignment.name} assignment and ${quiz.title} quiz and ${testPage.title} page are present as module items, and they are NOT published since their module is unpublished.") + Log.d(STEP_TAG,"Assert that '${assignment.name}' assignment and '${quiz.title}' quiz and '${testPage.title}' page are present as module items, and they are NOT published since their module is unpublished.") moduleListPage.assertModuleItemIsDisplayed(assignment.name) moduleListPage.assertModuleItemNotPublished(assignment.name) moduleListPage.assertModuleItemIsDisplayed(quiz.title) @@ -206,89 +192,4 @@ class ModulesE2ETest : TeacherTest() { moduleListPage.assertModuleItemIsPublished(assignment.name) } - private fun createModuleItem( - course: CourseApiModel, - module: ModuleApiModel, - teacher: CanvasUserApiModel, - title: String, - moduleItemType: String, - contentId: String?, - pageUrl: String? = null - ) { - ModulesApi.createModuleItem( - courseId = course.id, - moduleId = module.id, - teacherToken = teacher.token, - moduleItemTitle = title, - moduleItemType = moduleItemType, - contentId = contentId, - pageUrl = pageUrl - ) - } - - private fun createModule( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): ModuleApiModel { - return ModulesApi.createModule( - courseId = course.id, - teacherToken = teacher.token, - unlockAt = null - ) - } - - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): QuizApiModel { - return QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - dueAt = 3.days.fromNow.iso8601, - token = teacher.token, - published = true - ) - ) - } - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = true, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } - - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel, - published: Boolean = true, - frontPage: Boolean = false, - body: String = EMPTY_STRING - ): PageApiModel { - return PagesApi.createCoursePage( - courseId = course.id, - published = published, - frontPage = frontPage, - token = teacher.token, - body = body - ) - } - - private fun createDiscussion( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = teacher.token - ) - } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt index eb42f20fbd..4535f058d9 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PagesE2ETest.kt @@ -9,9 +9,6 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.PagesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.model.PageApiModel import com.instructure.teacher.ui.pages.WebViewTextCheck import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.seedData @@ -21,6 +18,7 @@ import org.junit.Test @HiltAndroidTest class PagesE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -35,87 +33,87 @@ class PagesE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Create an unpublished page for course: ${course.name}.") - val testPage1 = createCoursePage(course, teacher, published = false, frontPage = false, body = "

Unpublished Page Text

") + Log.d(PREPARATION_TAG,"Create an unpublished page for course: '${course.name}'.") + val unpublishedPage = PagesApi.createCoursePage(course.id, teacher.token, published = false, frontPage = false, body = "

Unpublished Page Text

") - Log.d(PREPARATION_TAG,"Create a published page for course: ${course.name}.") - val testPage2 = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") + Log.d(PREPARATION_TAG,"Create a published page for course: '${course.name}'.") + val publishedPage = PagesApi.createCoursePage(course.id, teacher.token, published = true, frontPage = false, body = "

Regular Page Text

") - Log.d(PREPARATION_TAG,"Create a front page for course: ${course.name}.") - val testPage3 = createCoursePage(course, teacher, published = true, frontPage = true, body = "

Front Page Text

") + Log.d(PREPARATION_TAG,"Create a front page for course: '${course.name}'.") + val frontPage = PagesApi.createCoursePage(course.id, teacher.token, published = true, frontPage = true, body = "

Front Page Text

") - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Pages Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Pages Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openPagesTab() - Log.d(STEP_TAG,"Assert that ${testPage1.title} page is displayed and it is really unpublished.") - pageListPage.assertPageDisplayed(testPage1.title) - pageListPage.assertPageIsUnpublished(testPage1.title) + Log.d(STEP_TAG,"Assert that '${unpublishedPage.title}' page is displayed and it is really unpublished.") + pageListPage.assertPageDisplayed(unpublishedPage.title) + pageListPage.assertPageIsUnpublished(unpublishedPage.title) - Log.d(STEP_TAG,"Assert that ${testPage2.title} page is displayed and it is really published.") - pageListPage.assertPageDisplayed(testPage2.title) - pageListPage.assertPageIsPublished(testPage2.title) + Log.d(STEP_TAG,"Assert that '${publishedPage.title}' page is displayed and it is really published.") + pageListPage.assertPageDisplayed(publishedPage.title) + pageListPage.assertPageIsPublished(publishedPage.title) - Log.d(STEP_TAG,"Assert that ${testPage3.title} page is displayed and it is really a front page and published.") - pageListPage.assertPageDisplayed(testPage3.title) - pageListPage.assertPageIsPublished(testPage3.title) - pageListPage.assertFrontPageDisplayed(testPage3.title) + Log.d(STEP_TAG,"Assert that '${frontPage.title}' page is displayed and it is really a front page and published.") + pageListPage.assertPageDisplayed(frontPage.title) + pageListPage.assertPageIsPublished(frontPage.title) + pageListPage.assertFrontPageDisplayed(frontPage.title) - Log.d(STEP_TAG,"Open ${testPage2.title} page. Assert that it is really a regular published page via web view assertions.") - pageListPage.openPage(testPage2.title) + Log.d(STEP_TAG,"Open '${publishedPage.title}' page. Assert that it is really a regular published page via web view assertions.") + pageListPage.openPage(publishedPage.title) editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Regular Page Text")) Log.d(STEP_TAG,"Navigate back to Pages page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open ${testPage3.title} page. Assert that it is really a front (published) page via web view assertions.") - pageListPage.openPage(testPage3.title) + Log.d(STEP_TAG,"Open '${frontPage.title}' page. Assert that it is really a front (published) page via web view assertions.") + pageListPage.openPage(frontPage.title) editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text")) Log.d(STEP_TAG,"Navigate back to Pages page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open ${testPage1.title} page. Assert that it is really an unpublished page via web view assertions.") - pageListPage.openPage(testPage1.title) + Log.d(STEP_TAG,"Open '${unpublishedPage.title}' page. Assert that it is really an unpublished page via web view assertions.") + pageListPage.openPage(unpublishedPage.title) editPageDetailsPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Unpublished Page Text")) Espresso.pressBack() val editedUnpublishedPageName = "Page still unpublished" - Log.d(STEP_TAG,"Open and edit the ${testPage1.title} page and set $editedUnpublishedPageName page name as new value. Click on 'Save' and navigate back.") - pageListPage.openPage(testPage1.title) + Log.d(STEP_TAG,"Open and edit the '${unpublishedPage.title}' page and set '$editedUnpublishedPageName' page name as new value. Click on 'Save' and navigate back.") + pageListPage.openPage(unpublishedPage.title) editPageDetailsPage.openEdit() editPageDetailsPage.editPageName(editedUnpublishedPageName) editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that the page name has been changed to $editedUnpublishedPageName.") + Log.d(STEP_TAG,"Assert that the page name has been changed to '$editedUnpublishedPageName'.") pageListPage.assertPageIsUnpublished(editedUnpublishedPageName) - Log.d(STEP_TAG,"Open ${testPage2.title} page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") - pageListPage.openPage(testPage2.title) + Log.d(STEP_TAG,"Open '${publishedPage.title}' page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") + pageListPage.openPage(publishedPage.title) editPageDetailsPage.openEdit() editPageDetailsPage.toggleFrontPage() editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that ${testPage2.title} is displayed as a front page.") - pageListPage.assertFrontPageDisplayed(testPage2.title) + Log.d(STEP_TAG,"Assert that '${publishedPage.title}' is displayed as a front page.") + pageListPage.assertFrontPageDisplayed(publishedPage.title) - Log.d(STEP_TAG,"Open $editedUnpublishedPageName page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") + Log.d(STEP_TAG,"Open '$editedUnpublishedPageName' page and Edit it. Set it as a front page and click on 'Save'. Navigate back.") pageListPage.openPage(editedUnpublishedPageName) editPageDetailsPage.openEdit() editPageDetailsPage.togglePublished() editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that $testPage2 is published.") - pageListPage.assertPageIsPublished(testPage2.title) + Log.d(STEP_TAG,"Assert that '$publishedPage' is published.") + pageListPage.assertPageIsPublished(publishedPage.title) - Log.d(STEP_TAG,"Open ${testPage3.title} page and Edit it. Unpublish it and remove 'Front page' from it.") - pageListPage.openPage(testPage3.title) + Log.d(STEP_TAG,"Open '${frontPage.title}' page and Edit it. Unpublish it and remove 'Front page' from it.") + pageListPage.openPage(frontPage.title) editPageDetailsPage.openEdit() editPageDetailsPage.togglePublished() editPageDetailsPage.toggleFrontPage() @@ -123,13 +121,13 @@ class PagesE2ETest : TeacherTest() { Log.d(STEP_TAG,"Assert that a front page cannot be unpublished.") editPageDetailsPage.unableToSaveUnpublishedFrontPage() - Log.d(STEP_TAG,"Publish ${testPage3.title} page again. Click on 'Save' and navigate back-") + Log.d(STEP_TAG,"Publish '${frontPage.title}' page again. Click on 'Save' and navigate back-") editPageDetailsPage.togglePublished() editPageDetailsPage.savePage() Espresso.pressBack() - Log.d(STEP_TAG,"Assert that ${testPage2.title} is displayed as a front page.") - pageListPage.assertFrontPageDisplayed(testPage2.title) + Log.d(STEP_TAG,"Assert that '${publishedPage.title}' is displayed as a front page.") + pageListPage.assertFrontPageDisplayed(publishedPage.title) Log.d(STEP_TAG,"Click on '+' icon on the UI to create a new page.") pageListPage.clickOnCreateNewPage() @@ -159,20 +157,4 @@ class PagesE2ETest : TeacherTest() { pageListPage.assertPageCount(4) } - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel, - published: Boolean = true, - frontPage: Boolean = false, - body: String = EMPTY_STRING - ): PageApiModel { - return PagesApi.createCoursePage( - courseId = course.id, - published = published, - frontPage = frontPage, - token = teacher.token, - body = body - ) - } - } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt index 98272c9829..f5b64c3b61 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/PeopleE2ETest.kt @@ -25,9 +25,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.GroupsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -46,6 +43,7 @@ import java.lang.Thread.sleep @HiltAndroidTest class PeopleE2ETest: TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -69,13 +67,13 @@ class PeopleE2ETest: TeacherTest() { val group = GroupsApi.createGroup(groupCategory.id, teacher.token) val group2 = GroupsApi.createGroup(groupCategory2.id, teacher.token) - Log.d(PREPARATION_TAG,"Create group membership for ${gradedStudent.name} student to '${group.name}' group.") + Log.d(PREPARATION_TAG,"Create group membership for '${gradedStudent.name}' student to '${group.name}' group.") GroupsApi.createGroupMembership(group.id, gradedStudent.id, teacher.token) - Log.d(PREPARATION_TAG,"Create group membership for ${notGradedStudent.name} student to '${group2.name}' group.") + Log.d(PREPARATION_TAG,"Create group membership for '${notGradedStudent.name}' student to '${group2.name}' group.") GroupsApi.createGroupMembership(group2.id, notGradedStudent.id, teacher.token) - Log.d(PREPARATION_TAG,"Seed a 'Text Entry' assignment for course: ${course.name}.") + Log.d(PREPARATION_TAG,"Seed a 'Text Entry' assignment for course: '${course.name}'.") val assignments = seedAssignments( courseId = course.id, dueAt = 1.days.fromNow.iso8601, @@ -84,7 +82,7 @@ class PeopleE2ETest: TeacherTest() { pointsPossible = 10.0 ) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignments[0].name} assignment.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignments[0].name}' assignment.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, @@ -95,13 +93,13 @@ class PeopleE2ETest: TeacherTest() { studentToken = gradedStudent.token ) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${assignments[0].name} assignment.") - gradeSubmission(teacher, course, assignments, gradedStudent) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignments[0].name}' assignment.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignments[0].id, gradedStudent.id, postedGrade = "10") - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to People Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to People Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openPeopleTab() @@ -118,7 +116,7 @@ class PeopleE2ETest: TeacherTest() { personContextPage.assertDisplaysCourseInfo(course) personContextPage.assertSectionNameView(PersonContextPage.UserRole.OBSERVER) - Log.d(STEP_TAG,"Navigate back and click on ${notGradedStudent.name} student and assert that the NOT GRADED student course info and the corresponding section name is displayed are displayed properly on Context Page.") + Log.d(STEP_TAG,"Navigate back and click on '${notGradedStudent.name}' student and assert that the NOT GRADED student course info and the corresponding section name is displayed are displayed properly on Context Page.") Espresso.pressBack() peopleListPage.assertPersonRole(notGradedStudent.name, PeopleListPage.UserRole.STUDENT) peopleListPage.clickPerson(notGradedStudent) @@ -128,7 +126,7 @@ class PeopleE2ETest: TeacherTest() { studentContextPage.assertStudentGrade("--") studentContextPage.assertStudentSubmission("--") - Log.d(STEP_TAG,"Navigate back and click on ${gradedStudent.name} student." + + Log.d(STEP_TAG,"Navigate back and click on '${gradedStudent.name}' student." + "Assert that '${gradedStudent.name}' graded student's info," + "and the '${course.name}' course's info are displayed properly on the Context Page.") Espresso.pressBack() @@ -145,7 +143,7 @@ class PeopleE2ETest: TeacherTest() { studentContextPage.clickOnNewMessageButton() val subject = "Test Subject" - Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: $subject. Add some message text and click on 'Send' (aka. 'Arrow') button.") + Log.d(STEP_TAG,"Fill in the 'Subject' field with the value: '$subject'. Add some message text and click on 'Send' (aka. 'Arrow') button.") addMessagePage.composeMessageWithSubject(subject, "This a test message from student context page.") addMessagePage.clickSendButton() @@ -216,19 +214,4 @@ class PeopleE2ETest: TeacherTest() { inboxPage.assertHasConversation() } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignments: List, - gradedStudent: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignments[0].id, - studentId = gradedStudent.id, - postedGrade = "10", - excused = false - ) - } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt index 66298f5d5f..2864daabc4 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/QuizE2ETest.kt @@ -36,6 +36,7 @@ import org.junit.Test @HiltAndroidTest class QuizE2ETest: TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -51,63 +52,48 @@ class QuizE2ETest: TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Quizzes Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Quizzes Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openQuizzesTab() Log.d(STEP_TAG,"Assert that there is no quiz displayed on the page.") quizListPage.assertDisplaysNoQuizzesView() - Log.d(PREPARATION_TAG,"Seed a quiz for the ${course.name} course. Also, seed a question into the quiz and publish it.") - val testQuizList = seedQuizzes( - courseId = course.id, - withDescription = true, - dueAt = 3.days.fromNow.iso8601, - teacherToken = teacher.token, - published = false - ) - - seedQuizQuestion( - courseId = course.id, - quizId = testQuizList.quizList[0].id, - teacherToken = teacher.token - ) - - Log.d(STEP_TAG,"Refresh the page. Assert that the quiz is there and click on the previously seeded quiz: ${testQuizList.quizList[0].title}.") + Log.d(PREPARATION_TAG,"Seed a quiz for the '${course.name}' course. Also, seed a question into the quiz and publish it.") + val testQuizList = seedQuizzes(courseId = course.id, withDescription = true, dueAt = 3.days.fromNow.iso8601, teacherToken = teacher.token, published = false) + seedQuizQuestion(courseId = course.id, quizId = testQuizList.quizList[0].id, teacherToken = teacher.token) + + Log.d(STEP_TAG,"Refresh the page. Assert that the quiz is there and click on the previously seeded quiz: '${testQuizList.quizList[0].title}'.") quizListPage.refresh() quizListPage.clickQuiz(testQuizList.quizList[0].title) - Log.d(STEP_TAG,"Assert that ${testQuizList.quizList[0].title} quiz is 'Not Submitted' and it is unpublished.") + Log.d(STEP_TAG,"Assert that '${testQuizList.quizList[0].title}' quiz is 'Not Submitted' and it is unpublished.") quizDetailsPage.assertNotSubmitted() quizDetailsPage.assertQuizUnpublished() val newQuizTitle = "This is a new quiz" - Log.d(STEP_TAG,"Open 'Edit' page and edit the ${testQuizList.quizList[0].title} quiz's title to: $newQuizTitle.") + Log.d(STEP_TAG,"Open 'Edit' page and edit the '${testQuizList.quizList[0].title}' quiz's title to: '$newQuizTitle'.") quizDetailsPage.openEditPage() editQuizDetailsPage.editQuizTitle(newQuizTitle) - Log.d(STEP_TAG,"Assert that the quiz name has been changed to: $newQuizTitle.") + Log.d(STEP_TAG,"Assert that the quiz name has been changed to: '$newQuizTitle'.") quizDetailsPage.assertQuizNameChanged(newQuizTitle) - Log.d(STEP_TAG,"Open 'Edit' page and switch on the 'Published' checkbox, so publish the $newQuizTitle quiz. Click on 'Save'.") + Log.d(STEP_TAG,"Open 'Edit' page and switch on the 'Published' checkbox, so publish the '$newQuizTitle' quiz. Click on 'Save'.") quizDetailsPage.openEditPage() editQuizDetailsPage.switchPublish() editQuizDetailsPage.saveQuiz() - Log.d(STEP_TAG,"Refresh the page. Assert that $newQuizTitle quiz has been unpublished.") + Log.d(STEP_TAG,"Refresh the page. Assert that '$newQuizTitle' quiz has been unpublished.") quizDetailsPage.refresh() quizDetailsPage.assertQuizPublished() - Log.d(PREPARATION_TAG,"Submit the ${testQuizList.quizList[0].title} quiz.") - seedQuizSubmission( - courseId = course.id, - quizId = testQuizList.quizList[0].id, - studentToken = student.token - ) + Log.d(PREPARATION_TAG,"Submit the '${testQuizList.quizList[0].title}' quiz.") + seedQuizSubmission(courseId = course.id, quizId = testQuizList.quizList[0].id, studentToken = student.token) Log.d(STEP_TAG,"Refresh the page. Assert that it needs grading because of the previous submission.") quizListPage.refresh() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt index d7eeea79ac..dad19d8bac 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SettingsE2ETest.kt @@ -48,7 +48,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -64,11 +64,11 @@ class SettingsE2ETest : TeacherTest() { profileSettingsPage.clickEditPencilIcon() val newUserName = "John Doe" - Log.d(STEP_TAG, "Edit username to: $newUserName. Click on 'Save' button.") + Log.d(STEP_TAG, "Edit username to: '$newUserName'. Click on 'Save' button.") editProfileSettingsPage.editUserName(newUserName) editProfileSettingsPage.clickOnSave() - Log.d(STEP_TAG, "Assert that the username has been changed to $newUserName on the Profile Settings Page.") + Log.d(STEP_TAG, "Assert that the username has been changed to '$newUserName' on the Profile Settings Page.") try { Log.d(STEP_TAG, "Check if the user has landed on Settings Page. If yes, navigate back to Profile Settings Page.") //Sometimes in Bitrise it's working different than locally, because in Bitrise sometimes the user has been navigated to Settings Page after saving a new name, @@ -94,13 +94,13 @@ class SettingsE2ETest : TeacherTest() { Log.d(STEP_TAG, "Press back button (without saving). The goal is to navigate back to the Profile Settings Page.") Espresso.pressBack() - Log.d(STEP_TAG, "Assert that the username value remained $newUserName.") + Log.d(STEP_TAG, "Assert that the username value remained '$newUserName'.") profileSettingsPage.assertUserNameIs(newUserName) } catch (e: NoMatchingViewException) { Log.d(STEP_TAG, "Press back button (without saving). The goal is to navigate back to the Profile Settings Page.") Espresso.pressBack() - Log.d(STEP_TAG, "Assert that the username value remained $newUserName.") + Log.d(STEP_TAG, "Assert that the username value remained '$newUserName'.") profileSettingsPage.assertUserNameIs(newUserName) } } @@ -109,12 +109,13 @@ class SettingsE2ETest : TeacherTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.SETTINGS, TestCategory.E2E) fun testDarkModeE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -134,7 +135,7 @@ class SettingsE2ETest : TeacherTest() { Espresso.pressBack() dashboardPage.assertCourseLabelTextColor("#FFFFFFFF") - Log.d(STEP_TAG,"Select ${course.name} course and assert on the Course Browser Page that the tabs has the proper text color (which is used in Dark mode).") + Log.d(STEP_TAG,"Select '${course.name}' course and assert on the Course Browser Page that the tabs has the proper text color (which is used in Dark mode).") dashboardPage.openCourse(course.name) courseBrowserPage.assertTabLabelTextColor("Announcements","#FFFFFFFF") courseBrowserPage.assertTabLabelTextColor("Assignments","#FFFFFFFF") @@ -163,7 +164,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -185,7 +186,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -197,13 +198,13 @@ class SettingsE2ETest : TeacherTest() { settingsPage.openAboutPage() aboutPage.assertPageObjects() - Log.d(STEP_TAG,"Check that domain is equal to: ${teacher.domain} (teacher's domain).") + Log.d(STEP_TAG,"Check that domain is equal to: '${teacher.domain}' (teacher's domain).") aboutPage.domainIs(teacher.domain) - Log.d(STEP_TAG,"Check that Login ID is equal to: ${teacher.loginId} (teacher's Login ID).") + Log.d(STEP_TAG,"Check that Login ID is equal to: '${teacher.loginId}' (teacher's Login ID).") aboutPage.loginIdIs(teacher.loginId) - Log.d(STEP_TAG,"Check that e-mail is equal to: ${teacher.loginId} (teacher's Login ID).") + Log.d(STEP_TAG,"Check that e-mail is equal to: '${teacher.loginId}' (teacher's Login ID).") aboutPage.emailIs(teacher.loginId) Log.d(STEP_TAG,"Assert that the Instructure company logo has been displayed on the About page.") @@ -219,7 +220,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -244,7 +245,7 @@ class SettingsE2ETest : TeacherTest() { val data = seedData(students = 1, teachers = 1, courses = 1) val teacher = data.teachersList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -253,7 +254,7 @@ class SettingsE2ETest : TeacherTest() { Log.d(PREPARATION_TAG,"Capture the initial remote config values.") val initialValues = mutableMapOf() - RemoteConfigParam.values().forEach {param -> initialValues.put(param.rc_name, RemoteConfigUtils.getString(param))} + RemoteConfigParam.values().forEach { param -> initialValues[param.rc_name] = RemoteConfigUtils.getString(param) } Log.d(STEP_TAG,"Navigate to Remote Config Params Page.") settingsPage.openRemoteConfigParamsPage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt index acf857613d..8231b11cb8 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt @@ -30,9 +30,6 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -49,6 +46,7 @@ import org.junit.Test @HiltAndroidTest class SpeedGraderE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -66,7 +64,7 @@ class SpeedGraderE2ETest : TeacherTest() { val gradedStudent = data.studentsList[1] val noSubStudent = data.studentsList[2] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") val assignment = seedAssignments( courseId = course.id, dueAt = 1.days.fromNow.iso8601, @@ -75,7 +73,7 @@ class SpeedGraderE2ETest : TeacherTest() { pointsPossible = 15.0 ) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignment[0].name} assignment with ${student.name} student.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignment[0].name}' assignment with '${student.name}' student.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, @@ -86,7 +84,7 @@ class SpeedGraderE2ETest : TeacherTest() { studentToken = student.token ) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignment[0].name} assignment with ${gradedStudent.name} student.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignment[0].name}' assignment with '${gradedStudent.name}' student.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, @@ -97,32 +95,32 @@ class SpeedGraderE2ETest : TeacherTest() { studentToken = gradedStudent.token ) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${gradedStudent.name} student.") - gradeSubmission(teacher, course, assignment, gradedStudent) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${gradedStudent.name}' student.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment[0].id, gradedStudent.id, postedGrade = "15") - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Assignments Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Assignments Page.") dashboardPage.openCourse(course) courseBrowserPage.openAssignmentsTab() - Log.d(STEP_TAG,"Click on ${assignment[0].name} assignment and assert that that there is one 'Needs Grading' submission (for ${noSubStudent.name} student) and one 'Not Submitted' submission (for ${student.name} student. ") + Log.d(STEP_TAG,"Click on '${assignment[0].name}' assignment and assert that that there is one 'Needs Grading' submission for '${noSubStudent.name}' student and one 'Not Submitted' submission for '${student.name}' student.") assignmentListPage.clickAssignment(assignment[0]) assignmentDetailsPage.assertNeedsGrading(actual = 1, outOf = 3) assignmentDetailsPage.assertNotSubmitted(actual = 1, outOf = 3) - Log.d(STEP_TAG,"Open 'Not Submitted' submissions and assert that the submission of ${noSubStudent.name} student is displayed. Navigate back.") + Log.d(STEP_TAG,"Open 'Not Submitted' submissions and assert that the submission of '${noSubStudent.name}' student is displayed. Navigate back.") assignmentDetailsPage.openNotSubmittedSubmissions() assignmentSubmissionListPage.assertHasStudentSubmission(noSubStudent) Espresso.pressBack() - Log.d(STEP_TAG,"Open 'Graded' submissions and assert that the submission of ${gradedStudent.name} student is displayed. Navigate back.") + Log.d(STEP_TAG,"Open 'Graded' submissions and assert that the submission of '${gradedStudent.name}' student is displayed. Navigate back.") assignmentDetailsPage.openGradedSubmissions() assignmentSubmissionListPage.assertHasStudentSubmission(gradedStudent) Espresso.pressBack() - Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of ${student.name} student is displayed.") + Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of '${student.name}' student is displayed.") assignmentDetailsPage.openSubmissionsPage() assignmentSubmissionListPage.clickSubmission(student) speedGraderPage.assertDisplaysTextSubmissionViewWithStudentName(student.name) @@ -132,7 +130,7 @@ class SpeedGraderE2ETest : TeacherTest() { speedGraderGradePage.openGradeDialog() val grade = "10" - Log.d(STEP_TAG,"Enter $grade as the new grade and assert that it has applied. Navigate back and refresh the page.") + Log.d(STEP_TAG,"Enter '$grade' as the new grade and assert that it has applied. Navigate back and refresh the page.") speedGraderGradePage.enterNewGrade(grade) speedGraderGradePage.assertHasGrade(grade) Espresso.pressBack() @@ -165,7 +163,7 @@ class SpeedGraderE2ETest : TeacherTest() { Log.d(STEP_TAG, "Navigate back assignment's details page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of ${student.name} student is displayed.") + Log.d(STEP_TAG,"Open (all) submissions and assert that the submission of '${student.name}' student is displayed.") assignmentDetailsPage.openSubmissionsPage() Log.d(STEP_TAG, "Click on 'Post Policies' (eye) icon.") @@ -191,19 +189,4 @@ class SpeedGraderE2ETest : TeacherTest() { assignmentSubmissionListPage.assertGradesHidden(student.name) } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: List, - gradedStudent: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment[0].id, - studentId = gradedStudent.id, - postedGrade = "15", - excused = false - ) - } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt index e64467cbb9..c5e7d3772e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SyllabusE2ETest.kt @@ -2,20 +2,25 @@ package com.instructure.teacher.ui.e2e import android.util.Log import com.instructure.canvas.espresso.E2E -import com.instructure.dataseeding.model.SubmissionType -import com.instructure.dataseeding.util.days -import com.instructure.dataseeding.util.fromNow -import com.instructure.dataseeding.util.iso8601 import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData -import com.instructure.teacher.ui.utils.* +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.iso8601 +import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.teacher.ui.utils.seedAssignments +import com.instructure.teacher.ui.utils.seedData +import com.instructure.teacher.ui.utils.seedQuizzes +import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest class SyllabusE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -30,54 +35,41 @@ class SyllabusE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Open ${course.name} course and navigate to Syllabus Page.") + Log.d(STEP_TAG,"Open '${course.name}' course and navigate to Syllabus Page.") dashboardPage.openCourse(course.name) courseBrowserPage.openSyllabus() Log.d(STEP_TAG,"Assert that empty view is displayed.") syllabusPage.assertEmptyView() - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val assignment = seedAssignments( - courseId = course.id, - dueAt = 1.days.fromNow.iso8601, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - pointsPossible = 15.0, - withDescription = true - ) - - Log.d(PREPARATION_TAG,"Seed a quiz for the ${course.name} course.") - val quiz = seedQuizzes( - courseId = course.id, - withDescription = true, - published = true, - teacherToken = teacher.token, - dueAt = 1.days.fromNow.iso8601 - ) - - Log.d(STEP_TAG,"Refresh the Syllabus page and assert that the ${assignment[0].name} assignment and ${quiz.quizList[0].title} quiz are displayed as syllabus items.") + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignment = seedAssignments(courseId = course.id, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), teacherToken = teacher.token, pointsPossible = 15.0, withDescription = true) + + Log.d(PREPARATION_TAG,"Seed a quiz for the '${course.name}' course.") + val quiz = seedQuizzes(courseId = course.id, withDescription = true, published = true, teacherToken = teacher.token, dueAt = 1.days.fromNow.iso8601) + + Log.d(STEP_TAG,"Refresh the Syllabus page and assert that the '${assignment[0].name}' assignment and '${quiz.quizList[0].title}' quiz are displayed as syllabus items.") syllabusPage.refresh() syllabusPage.assertItemDisplayed(assignment[0].name) syllabusPage.assertItemDisplayed(quiz.quizList[0].title) Log.d(STEP_TAG,"Refresh the Syllabus page. Click on 'Pencil' (aka. 'Edit') icon.") - syllabusPage.refresh() syllabusPage.openEditSyllabus() var syllabusBody = "Syllabus Body" - Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: $syllabusBody. Click on 'Save'.") + + Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: '$syllabusBody'. Click on 'Save'.") editSyllabusPage.editSyllabusBody(syllabusBody) editSyllabusPage.saveSyllabusEdit() Log.d(STEP_TAG,"Assert that the previously made modifications has been applied on the syllabus.") syllabusPage.assertDisplaysSyllabus(syllabusBody = syllabusBody, shouldDisplayTabs = true) - Log.d(STEP_TAG,"Select 'Summary' Tab and assert that the ${assignment[0].name} assignment and ${quiz.quizList[0].title} quiz are displayed.") + Log.d(STEP_TAG,"Select 'Summary' Tab and assert that the '${assignment[0].name}' assignment and '${quiz.quizList[0].title}' quiz are displayed.") syllabusPage.selectSummaryTab() syllabusPage.assertItemDisplayed(assignment[0].name) syllabusPage.assertItemDisplayed(quiz.quizList[0].title) @@ -87,7 +79,7 @@ class SyllabusE2ETest : TeacherTest() { syllabusBody = "Edited Syllabus Body" syllabusPage.openEditSyllabus() - Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: $syllabusBody. Toggle 'Show course summary'. Click on 'Save'.") + Log.d(STEP_TAG,"Edit syllabus description (aka. 'Syllabus Body') by adding new value to it: '$syllabusBody'. Toggle 'Show course summary'. Click on 'Save'.") editSyllabusPage.editSyllabusBody(syllabusBody) editSyllabusPage.editSyllabusToggleShowSummary() editSyllabusPage.saveSyllabusEdit() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt index bd6f0e0e61..40f3116be6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/TodoE2ETest.kt @@ -18,18 +18,15 @@ package com.instructure.teacher.ui.e2e import android.util.Log import com.instructure.canvas.espresso.E2E +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.canvas.espresso.FeatureCategory -import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.TestCategory -import com.instructure.canvas.espresso.TestMetaData import com.instructure.teacher.ui.utils.TeacherTest import com.instructure.teacher.ui.utils.seedAssignmentSubmission import com.instructure.teacher.ui.utils.seedAssignments @@ -40,6 +37,7 @@ import org.junit.Test @HiltAndroidTest class TodoE2ETest : TeacherTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -55,27 +53,17 @@ class TodoE2ETest : TeacherTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val assignments = seedAssignments( - courseId = course.id, - dueAt = 1.days.fromNow.iso8601, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - pointsPossible = 15.0 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignments = seedAssignments(courseId = course.id, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), teacherToken = teacher.token, pointsPossible = 15.0) - Log.d(PREPARATION_TAG,"Seed a submission for ${assignments[0].name} assignment with ${student.name} student.") + Log.d(PREPARATION_TAG,"Seed a submission for '${assignments[0].name}' assignment with '${student.name}' student.") seedAssignmentSubmission( submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY - )), - assignmentId = assignments[0].id, - courseId = course.id, - studentToken = student.token - ) + )), assignmentId = assignments[0].id, courseId = course.id, studentToken = student.token) - Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) dashboardPage.waitForRender() @@ -83,33 +71,18 @@ class TodoE2ETest : TeacherTest() { dashboardPage.openTodo() todoPage.waitForRender() - Log.d(STEP_TAG,"Assert that the previously seeded ${assignments[0].name} assignment is displayed as a To Do element for the ${course.name} course." + + Log.d(STEP_TAG,"Assert that the previously seeded '${assignments[0].name}' assignment is displayed as a To Do element for the '${course.name}' course." + "Assert that the '1 Needs Grading' text is under the corresponding assignment's details, and assert that the To Do element count is 1.") todoPage.assertTodoElementDetailsDisplayed(course.name) todoPage.assertNeedsGradingCountOfTodoElement(assignments[0].name, 1) todoPage.assertTodoElementCount(1) - Log.d(PREPARATION_TAG,"Grade the previously seeded submission for ${student.name} student.") - gradeSubmission(teacher, course, assignments, student) + Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${student.name}' student.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignments[0].id, student.id, postedGrade = "15") Log.d(STEP_TAG,"Refresh the To Do Page. Assert that the empty view is displayed so that the To Do has disappeared because it has been graded.") todoPage.refresh() todoPage.assertEmptyView() } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: List, - student: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment[0].id, - studentId = student.id, - postedGrade = "15", - excused = false - ) - } } \ No newline at end of file From 4ab65af64a7bada8c2152113a8587827f01ac242 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Mon, 4 Mar 2024 13:30:51 +0100 Subject: [PATCH 45/52] [MBL-17419][Student] - Fix breaking Offline E2E tests (#2366) * Fix breaking Offline E2E tests. refs: MBL-17419 affects: Student release note: none * Fix more offline e2e tests. * Refactor Offline Sync Progress E2E test. Fix AllCourses E2E test * Extend sync content to let the waitForView have enough time to find the dashboard notification. * Increase testShards to 10 in Offline E2E workflow. * Add one more course to sync to extend dashboard notification appearance on Dashboard. * Change waitForView to sleep and onView (check(matches(..)) evaluation lasts too long on CI so the view disappear until the evaluation finished). --- apps/student/flank_e2e_offline.yml | 2 +- .../offline/ManageOfflineContentE2ETest.kt | 7 +-- .../e2e/offline/OfflineAllCoursesE2ETest.kt | 8 ++-- .../offline/OfflineAnnouncementsE2ETest.kt | 12 ++--- .../offline/OfflineCourseBrowserE2ETest.kt | 44 ++++++++----------- .../ui/e2e/offline/OfflineDashboardE2ETest.kt | 20 ++++----- .../ui/e2e/offline/OfflineFilesE2ETest.kt | 7 +-- .../e2e/offline/OfflineLeftSideMenuE2ETest.kt | 4 +- .../ui/e2e/offline/OfflineLoginE2ETest.kt | 4 +- .../ui/e2e/offline/OfflinePagesE2ETest.kt | 7 +-- .../ui/e2e/offline/OfflinePeopleE2ETest.kt | 12 ++--- .../e2e/offline/OfflineSyncProgressE2ETest.kt | 41 ++++++++++++----- .../e2e/offline/OfflineSyncSettingsE2ETest.kt | 6 +-- .../student/ui/pages/DashboardPage.kt | 5 ++- .../ui/pages/offline/SyncProgressPage.kt | 32 ++++++++++++++ .../student/ui/utils/StudentTest.kt | 1 - 16 files changed, 127 insertions(+), 85 deletions(-) diff --git a/apps/student/flank_e2e_offline.yml b/apps/student/flank_e2e_offline.yml index 4d76f14bd3..0f091632dd 100644 --- a/apps/student/flank_e2e_offline.yml +++ b/apps/student/flank_e2e_offline.yml @@ -21,6 +21,6 @@ gcloud: orientation: portrait flank: - testShards: 1 + testShards: 10 testRuns: 1 diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index 385ed4ad8b..7f324a2cdc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -51,7 +51,7 @@ class ManageOfflineContentE2ETest : StudentTest() { val course1 = data.coursesList[0] val course2 = data.coursesList[1] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -195,8 +195,9 @@ class ManageOfflineContentE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on the 'Sync' button and confirm sync.") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course2.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt index 8ce02e5c4b..cb99b21f54 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt @@ -53,7 +53,7 @@ class OfflineAllCoursesE2ETest : StudentTest() { val course2 = data.coursesList[1] val course3 = data.coursesList[2] - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -79,15 +79,15 @@ class OfflineAllCoursesE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course2.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon is displayed on the synced (and favorited) course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered, and assert that '${course1.name}' is the only course which is displayed on the offline mode Dashboard Page.") - dashboardPage.waitForRender() dashboardPage.assertDisplaysCourse(course1) dashboardPage.assertCourseNotDisplayed(course2) dashboardPage.assertCourseNotDisplayed(course3) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt index 89a57aae64..77c31f5877 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAnnouncementsE2ETest.kt @@ -55,20 +55,23 @@ class OfflineAnnouncementsE2ETest : StudentTest() { val lockedAnnouncement = DiscussionTopicsApi.createAnnouncement(course.id, teacher.token, locked = true) - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + Log.d(STEP_TAG, "Expand '${course.name}' course.") manageOfflineContentPage.expandCollapseItem(course.name) + Log.d(STEP_TAG, "Select the 'Announcements' of '${course.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState("Announcements") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() @@ -80,9 +83,6 @@ class OfflineAnnouncementsE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") OfflineTestUtils.assertOfflineIndicator() - Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") - dashboardPage.assertCourseOfflineSyncIconVisible(course.name) - Log.d(STEP_TAG, "Select '${course.name}' course and open 'Announcements' menu.") dashboardPage.selectCourse(course) courseBrowserPage.selectAnnouncements() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt index 4b1a535161..017eed51df 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -32,7 +32,6 @@ import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.After import org.junit.Test -import java.lang.Thread.sleep @HiltAndroidTest class OfflineCourseBrowserE2ETest : StudentTest() { @@ -49,23 +48,23 @@ class OfflineCourseBrowserE2ETest : StudentTest() { Log.d(PREPARATION_TAG,"Seeding data.") val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) val student = data.studentsList[0] - val course1 = data.coursesList[0] + val course = data.coursesList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.openGlobalManageOfflineContentPage() - Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") - Log.d(STEP_TAG, "Expand '${course1.name}' course. Select only the 'Announcements' of the '${course1.name}' course. Click on the 'Sync' button and confirm the sync process.") - manageOfflineContentPage.expandCollapseItem(course1.name) + Log.d(STEP_TAG, "Expand '${course.name}' course. Select only the 'Announcements' of the '${course.name}' course. Click on the 'Sync' button and confirm the sync process.") + manageOfflineContentPage.expandCollapseItem(course.name) manageOfflineContentPage.changeItemSelectionState("Announcements") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() @@ -74,8 +73,8 @@ class OfflineCourseBrowserE2ETest : StudentTest() { Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() - Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") - dashboardPage.selectCourse(course1) + Log.d(STEP_TAG, "Select '${course.name}' course and open 'Announcements' menu.") + dashboardPage.selectCourse(course) Log.d(STEP_TAG, "Assert that only the 'Announcements' tab is enabled because it is the only one which has been synced, and assert that all the other, previously synced tabs are disabled, because they weren't synced now.") var enabledTabs = arrayOf("Announcements") @@ -91,27 +90,22 @@ class OfflineCourseBrowserE2ETest : StudentTest() { Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.openGlobalManageOfflineContentPage() - Log.d(STEP_TAG, "Deselect the entire '${course1.name}' course for sync.") - manageOfflineContentPage.changeItemSelectionState(course1.name) + Log.d(STEP_TAG, "Deselect the entire '${course.name}' course for sync.") + manageOfflineContentPage.changeItemSelectionState(course.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") - sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. - device.waitForIdle() - device.waitForWindowUpdate(null, 10000) - dashboardPage.selectCourse(course1) + Log.d(STEP_TAG, "Select '${course.name}' course and open 'Announcements' menu.") + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Select '${course.name}' course.") + dashboardPage.selectCourse(course) Log.d(STEP_TAG, "Assert that the 'Google Drive' and 'Collaborations' tabs are disabled because they aren't supported in offline mode, but the rest of the tabs are enabled because the whole course has been synced.") enabledTabs = arrayOf("Announcements", "Discussions", "Grades", "People", "Syllabus", "BigBlueButton") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt index 7e338d5078..c49034b82d 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt @@ -54,7 +54,7 @@ class OfflineDashboardE2ETest : StudentTest() { val course2 = data.coursesList[1] val testAnnouncement = data.announcementsList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -68,8 +68,9 @@ class OfflineDashboardE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course1.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() @@ -117,18 +118,13 @@ class OfflineDashboardE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState(course.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") - dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") - dashboardPage.waitForSyncProgressStartingNotification() - dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() - device.waitForIdle() + waitForNetworkToGoOffline(device) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt index ffe221a31d..1670437cc1 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineFilesE2ETest.kt @@ -65,7 +65,7 @@ class OfflineFilesE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Create a (text) file within the '${courseTestFolder.name}' folder of the '${course.name}' course.") val courseTestFolderTextFile = uploadTextFile(courseTestFolder.id, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -80,8 +80,9 @@ class OfflineFilesE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState("Files") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt index bb768b20eb..84bcbdd3be 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt @@ -47,10 +47,10 @@ class OfflineLeftSideMenuE2ETest : StudentTest() { fun testOfflineLeftSideMenuUnavailableFunctionsE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) + val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) val student = data.studentsList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt index 88c93fe92a..810839655b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLoginE2ETest.kt @@ -51,7 +51,7 @@ class OfflineLoginE2ETest : StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] - Log.d(STEP_TAG, "Login with user: ${student1.name}, login id: ${student1.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student1.name}', login id: '${student1.loginId}'.") loginWithUser(student1) dashboardPage.waitForRender() @@ -61,7 +61,7 @@ class OfflineLoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Login with user: ${student2.name}, login id: ${student2.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student2.name}', login id: '${student2.loginId}'.") loginWithUser(student2, true) Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt index 4a36f758cc..c2a949ce28 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePagesE2ETest.kt @@ -68,7 +68,7 @@ class OfflinePagesE2ETest : StudentTest() { Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for '${course.name}' course.") val pagePublishedFront = PagesApi.createCoursePage(course.id, teacher.token, frontPage = true, editingRoles = "public", body = "

Front Page Text

") - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -83,8 +83,9 @@ class OfflinePagesE2ETest : StudentTest() { manageOfflineContentPage.changeItemSelectionState("Pages") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt index c7e2f5991a..a48ff20b91 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflinePeopleE2ETest.kt @@ -50,20 +50,23 @@ class OfflinePeopleE2ETest : StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + Log.d(STEP_TAG, "Expand '${course.name}' course.") manageOfflineContentPage.expandCollapseItem(course.name) + Log.d(STEP_TAG, "Select the 'People' of '${course.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState("People") manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' and 'Syncing Offline Content' dashboard notifications to be displayed, and then to disappear.") - dashboardPage.waitForOfflineSyncDashboardNotifications() + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() @@ -75,9 +78,6 @@ class OfflinePeopleE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") OfflineTestUtils.assertOfflineIndicator() - Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") - dashboardPage.assertCourseOfflineSyncIconVisible(course.name) - Log.d(STEP_TAG, "Select '${course.name}' course and open 'People' menu.") dashboardPage.selectCourse(course) courseBrowserPage.selectPeople() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt index c5c60873b9..9bb2940fa9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt @@ -46,13 +46,15 @@ class OfflineSyncProgressE2ETest : StudentTest() { fun testOfflineGlobalCourseSyncProgressE2E() { Log.d(PREPARATION_TAG,"Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) + val data = seedData(students = 1, teachers = 1, courses = 4, announcements = 3, discussions = 5, syllabusBody = "Syllabus body") val student = data.studentsList[0] val course1 = data.coursesList[0] val course2 = data.coursesList[1] + val course3 = data.coursesList[2] + val course4 = data.coursesList[3] val testAnnouncement = data.announcementsList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -64,27 +66,40 @@ class OfflineSyncProgressE2ETest : StudentTest() { Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState(course1.name) + manageOfflineContentPage.changeItemSelectionState(course2.name) + manageOfflineContentPage.changeItemSelectionState(course3.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") + Log.d(STEP_TAG, "Wait for the Dashboard to be rendered.") dashboardPage.waitForRender() - dashboardPage.waitForSyncProgressDownloadStartedNotification() - dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() - Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and click on it to enter the Sync Progress Page.") - dashboardPage.waitForSyncProgressStartingNotification() + Log.d(STEP_TAG, "Click on the Dashboard notification to open the Sync Progress Page.") dashboardPage.clickOnSyncProgressNotification() - Log.d(STEP_TAG, "Assert that the Sync Progress has started.") - syncProgressPage.waitForDownloadStarting() - Log.d(STEP_TAG, "Assert that the Sync Progress has been successful (so to have the success title and the course success indicator).") syncProgressPage.assertDownloadProgressSuccessDetails() syncProgressPage.assertCourseSyncedSuccessfully(course1.name) + syncProgressPage.assertCourseSyncedSuccessfully(course2.name) + syncProgressPage.assertCourseSyncedSuccessfully(course3.name) + + Log.d(STEP_TAG, "Get the sum of '${course1.name}', '${course2.name}' and '${course3.name}' courses' sizes and assert that the sum number is displayed under the progress bar.") + val sumOfSyncedCourseSizes = syncProgressPage.getCourseSize(course1.name) + syncProgressPage.getCourseSize(course2.name) + syncProgressPage.getCourseSize(course3.name) + syncProgressPage.assertSumOfCourseSizes(sumOfSyncedCourseSizes) + + Log.d(STEP_TAG, "Expand '${course1.name}' course and assert a few tabs (for example) to ensure they synced well and the success indicator is displayed in their rows.") + syncProgressPage.expandCollapseCourse(course1.name) + syncProgressPage.assertCourseTabSynced("Syllabus") + syncProgressPage.assertCourseTabSynced("Announcements") + syncProgressPage.assertCourseTabSynced("Grades") + device.waitForIdle() Log.d(STEP_TAG, "Navigate back to Dashboard Page and wait for it to be rendered.") Espresso.pressBack() + Log.d(STEP_TAG, "Assert that the offline sync icon is displayed in online mode on the synced courses' course cards.") + dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) + dashboardPage.assertCourseOfflineSyncIconVisible(course2.name) + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") turnOffConnectionViaADB() OfflineTestUtils.waitForNetworkToGoOffline(device) @@ -95,9 +110,11 @@ class OfflineSyncProgressE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") OfflineTestUtils.assertOfflineIndicator() - Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced courses' course card.") dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) - dashboardPage.assertCourseOfflineSyncIconGone(course2.name) + dashboardPage.assertCourseOfflineSyncIconVisible(course2.name) + dashboardPage.assertCourseOfflineSyncIconVisible(course3.name) + dashboardPage.assertCourseOfflineSyncIconGone(course4.name) Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") dashboardPage.selectCourse(course1) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt index 745a2c8fd7..6efa56db19 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt @@ -48,7 +48,7 @@ class OfflineSyncSettingsE2ETest : StudentTest() { val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) val student = data.studentsList[0] - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -112,13 +112,13 @@ class OfflineSyncSettingsE2ETest : StudentTest() { Log.d(STEP_TAG, "Click 'Find My School' button.") loginLandingPage.clickFindMySchoolButton() - Log.d(STEP_TAG, "Enter domain: ${student.domain}.") + Log.d(STEP_TAG, "Enter domain: '${student.domain}'.") loginFindSchoolPage.enterDomain(student.domain) Log.d(STEP_TAG, "Click on 'Next' button on the Toolbar.") loginFindSchoolPage.clickToolbarNextMenuItem() - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") loginSignInPage.loginAs(student) dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index 91e3e38702..4ede8e10ec 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -27,7 +27,6 @@ import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.scrollRecyclerView @@ -43,6 +42,7 @@ import com.instructure.espresso.page.* import com.instructure.student.R import com.instructure.student.ui.utils.ViewUtils import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.anyOf import org.hamcrest.CoreMatchers.containsString import org.hamcrest.Matcher import org.hamcrest.Matchers @@ -395,7 +395,8 @@ class DashboardPage : BasePage(R.id.dashboardPage) { //OfflineMethod fun clickOnSyncProgressNotification() { - waitForView(ViewMatchers.withText(com.instructure.pandautils.R.string.syncProgress_syncingOfflineContent)).click() + Thread.sleep(2500) + onView(anyOf(withText(R.string.syncProgress_syncQueued),withText(R.string.syncProgress_downloadStarting), withText(R.string.syncProgress_syncingOfflineContent))).click() } //OfflineMethod diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt index f49c368035..dfb0cd3307 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/SyncProgressPage.kt @@ -17,12 +17,15 @@ package com.instructure.student.ui.pages.offline +import android.widget.TextView import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasSibling import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.assertContainsText import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertVisibility +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus @@ -32,6 +35,7 @@ import com.instructure.espresso.page.withId import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.pandautils.R +import com.instructure.student.ui.utils.getView class SyncProgressPage : BasePage(R.id.syncProgressPage) { @@ -55,4 +59,32 @@ class SyncProgressPage : BasePage(R.id.syncProgressPage) { onView(withId(R.id.courseName) + withText(courseName) + withAncestor(R.id.syncProgressPage)).assertDisplayed() onView(withId(R.id.successIndicator) + withParent(withId(R.id.actionContainer) + hasSibling(withId(R.id.courseName) + withText(courseName)))).assertVisibility(ViewMatchers.Visibility.VISIBLE) } + + fun expandCollapseCourse(courseName: String) { + onView(withId(R.id.toggleButton) + hasSibling(withId(R.id.courseName) + withText(courseName))).click() + } + + fun assertCourseTabSynced(tabName: String) { + onView(withId(R.id.successIndicator) + withParent(withId(R.id.actionContainer) + hasSibling(withId(R.id.tabTitle) + withText(tabName)))).assertVisibility(ViewMatchers.Visibility.VISIBLE) + } + + fun getCourseSize(courseName: String): Int { + val courseSizeView = onView(withId(R.id.courseSize) + hasSibling(withId(R.id.courseName) + withText(courseName))) + val courseSizeText = (courseSizeView.getView() as TextView).text.toString() + return courseSizeText.split(" ")[0].toInt() + } + + fun assertSumOfCourseSizes(expectedSize: Int) { + if(expectedSize > 999) { + val convertedSumSize = convertKiloBytesToMegaBytes(expectedSize) + onView(withId(R.id.downloadProgressText)).assertContainsText(convertedSumSize.toString()) + } + else { + onView(withId(R.id.downloadProgressText)).assertContainsText(expectedSize.toString()) + } + } + + private fun convertKiloBytesToMegaBytes(kilobytes: Int): Double { + return kilobytes / 1000.0 + } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index 8ba9cf009d..e72cd5bd6b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -23,7 +23,6 @@ import android.net.Uri import android.os.Environment import android.util.Log import android.view.View -import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.core.content.FileProvider import androidx.hilt.work.HiltWorkerFactory import androidx.test.espresso.Espresso From 193d9a79581a1367ad4fd6307b974180fbac76e3 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:38:53 +0100 Subject: [PATCH 46/52] [MBL-16824][Student][Teacher]Fix InAppUpdate tests (#2369) --- .../student/ui/interaction/InAppUpdateInteractionTest.kt | 5 ++++- .../java/com/instructure/teacher/ui/InAppUpdatePageTest.kt | 3 +-- .../java/com/instructure/pandautils/update/UpdateManager.kt | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt index 98af62da5a..489c813e44 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt @@ -207,6 +207,7 @@ class InAppUpdateInteractionTest : StudentTest() { @Test fun showImmediateFlow() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(4) @@ -270,8 +271,8 @@ class InAppUpdateInteractionTest : StudentTest() { } @Test - @Stub("Unstable, there is a ticket to fix this") fun flexibleUpdateCompletesIfAppRestarts() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(2) @@ -288,6 +289,7 @@ class InAppUpdateInteractionTest : StudentTest() { @Test fun immediateUpdateCompletion() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(4) @@ -307,6 +309,7 @@ class InAppUpdateInteractionTest : StudentTest() { @Test fun hideImmediateUpdateFlowIfUserCancels() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(4) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt index 781de656c7..dcf6a78b7e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/InAppUpdatePageTest.kt @@ -238,7 +238,6 @@ class InAppUpdatePageTest : TeacherTest() { } @Test - @Stub("Stubbed because on API lvl 29 device the notification will remain opened even though we push the back button at the end. Should be investigated and make some workaround once.") fun showNotificationOnFlexibleDownloadFinish() { updatePrefs.clearPrefs() val expectedTitle = context.getString(R.string.appUpdateReadyTitle) @@ -270,8 +269,8 @@ class InAppUpdatePageTest : TeacherTest() { } @Test - @Stub(description = "https://instructure.atlassian.net/browse/MBL-16824") fun flexibleUpdateCompletesIfAppRestarts() { + updatePrefs.clearPrefs() with(appUpdateManager) { setUpdateAvailable(400) setUpdatePriority(2) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt index ca17a1a4e0..2bd2898b0e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/update/UpdateManager.kt @@ -76,7 +76,9 @@ class UpdateManager(private val appUpdateManager: AppUpdateManager, .build(), IMMEDIATE_UPDATE_REQUEST_CODE ) - } else if (appUpdateInfo.updatePriority() >= FLEXIBLE_THRESHOLD && appUpdateInfo.clientVersionStalenessDays() ?: 0 >= DAYS_FOR_FLEXIBLE_UPDATE) { + } else if (appUpdateInfo.updatePriority() >= FLEXIBLE_THRESHOLD && (appUpdateInfo.clientVersionStalenessDays() + ?: 0) >= DAYS_FOR_FLEXIBLE_UPDATE + ) { val listener = InstallStateUpdatedListener { if (it.installStatus() == InstallStatus.DOWNLOADED) { registerNotificationChannel(activity) From 44e3075f0ac3cbda34bd4007e192fadfb238f06d Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 5 Mar 2024 18:48:46 +0100 Subject: [PATCH 47/52] [MBL-17280][Teacher] - Implement Bulk Update Modules E2E Test (#2368) * Implement Bulk Module Update E2E test. Add and refactor some methods in (Composable) ProgressPage. refs: MBL-17280 affects: Teacher release note: none * Set back uninntentional commit on retry count. * Resolve merge conflicts. * Wait for window update and device to idle. --- .../teacher/ui/e2e/ModulesE2ETest.kt | 288 +++++++++++++++++- .../teacher/ui/pages/ModulesPage.kt | 8 - .../teacher/ui/pages/ProgressPage.kt | 17 ++ .../teacher/ui/utils/TeacherTest.kt | 6 +- 4 files changed, 305 insertions(+), 14 deletions(-) diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt index 8bdc2a1413..b0aaaa1a5a 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/ModulesE2ETest.kt @@ -17,14 +17,17 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.teacher.R +import com.instructure.teacher.ui.utils.TeacherComposeTest +import com.instructure.teacher.ui.utils.openOverflowMenu import com.instructure.teacher.ui.utils.seedData import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest -class ModulesE2ETest : TeacherTest() { +class ModulesE2ETest : TeacherComposeTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -81,7 +84,7 @@ class ModulesE2ETest : TeacherTest() { Log.d(STEP_TAG,"Refresh the page. Assert that '${module.name}' module is displayed and it is unpublished by default.") moduleListPage.refresh() moduleListPage.assertModuleIsDisplayed(module.name) - moduleListPage.assertModuleNotPublished() + moduleListPage.assertModuleNotPublished(module.name) Log.d(STEP_TAG,"Assert that '${testPage.title}' page is present as a module item, but it's not published.") moduleListPage.assertModuleItemIsDisplayed(testPage.title) @@ -159,7 +162,7 @@ class ModulesE2ETest : TeacherTest() { Log.d(STEP_TAG, "Navigate back to Module List Page.") Espresso.pressBack() - Log.d(PREPARATION_TAG,"Unpublish ${module.name} module via API.") + Log.d(PREPARATION_TAG,"Unpublish '${module.name}' module via API.") ModulesApi.updateModule(courseId = course.id, moduleId = module.id, published = false, teacherToken = teacher.token) Log.d(STEP_TAG, "Refresh the Module List Page.") @@ -192,4 +195,281 @@ class ModulesE2ETest : TeacherTest() { moduleListPage.assertModuleItemIsPublished(assignment.name) } + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.E2E) + fun testBulkUpdateModulesE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG, "Seeding another 'Text Entry' assignment for '${course.name}' course.") + val assignment2 = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), dueAt = 1.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG, "Seeding quiz for '${course.name}' course.") + val quiz = QuizzesApi.createQuiz(course.id, teacher.token, withDescription = true, dueAt = 3.days.fromNow.iso8601) + + Log.d(PREPARATION_TAG, "Create an unpublished page for course: '${course.name}'.") + val testPage = PagesApi.createCoursePage(course.id, teacher.token, published = false, body = "

Test Page Text

") + + Log.d(PREPARATION_TAG, "Create another unpublished page for course: '${course.name}'.") + val testPage2 = PagesApi.createCoursePage(course.id, teacher.token, published = true, frontPage = false, body = "

This is another test page

") + + Log.d(PREPARATION_TAG, "Create a discussion topic for '${course.name}' course.") + val discussionTopic = DiscussionTopicsApi.createDiscussion(courseId = course.id, token = teacher.token) + + Log.d(PREPARATION_TAG, "Seeding a module for '${course.name}' course. It starts as unpublished.") + val module = ModulesApi.createModule(course.id, teacher.token) + + Log.d(PREPARATION_TAG, "Seeding another module for '${course.name}' course. It starts as unpublished.") + val module2 = ModulesApi.createModule(course.id, teacher.token) + + Log.d(PREPARATION_TAG,"Associate '${assignment.name}' assignment with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = assignment.name, moduleItemType = ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment.id.toString()) + + Log.d(PREPARATION_TAG,"Associate '${quiz.title}' quiz with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = quiz.title, moduleItemType = ModuleItemTypes.QUIZ.stringVal, contentId = quiz.id.toString()) + + Log.d(PREPARATION_TAG,"Associate '${testPage.title}' page with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = testPage.title, moduleItemType = ModuleItemTypes.PAGE.stringVal, contentId = null, pageUrl = testPage.url) + + Log.d(PREPARATION_TAG,"Associate '${discussionTopic.title}' discussion with module: '${module.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module.id, moduleItemTitle = discussionTopic.title, moduleItemType = ModuleItemTypes.DISCUSSION.stringVal, contentId = discussionTopic.id.toString()) + + Log.d(PREPARATION_TAG, "Associate '${assignment2.name}' assignment with module: '${module2.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id , assignment2.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment2.id.toString()) + + Log.d(PREPARATION_TAG, "Associate '${testPage2.title}' page with module: '${module2.id}'.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, testPage2.title, ModuleItemTypes.PAGE.stringVal, null, pageUrl = testPage2.url) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'. Assert that '${course.name}' course is displayed on the Dashboard.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to Modules Page.") + dashboardPage.openCourse(course.name) + courseBrowserPage.openModulesTab() + + Log.d(STEP_TAG, "Assert that '${module.name}' and '${module2.name}' modules are displayed and they are unpublished by default. Assert that the '${testPage.title}' page module item is not published and the other module items are published in '${module.name}' module.") + moduleListPage.assertModuleIsDisplayed(module.name) + moduleListPage.assertModuleNotPublished(module.name) + moduleListPage.assertModuleIsDisplayed(module2.name) + moduleListPage.assertModuleNotPublished(module2.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsPublished(quiz.title) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + + //Upper layer - All Modules and Items + Log.d(STEP_TAG, "Open Module List Page overflow menu and assert that the corresponding menu items are displayed.") + openOverflowMenu() + moduleListPage.assertToolbarMenuItems() + + Log.d(STEP_TAG, "Click on 'Publish all Modules and Items' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishAllModulesAndItems) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'All Modules and Items' is displayed as title and the corresponding note also displayed on the Progress Page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.allModulesAndItems) + progressPage.assertProgressPageNote(R.string.moduleBulkUpdateNote) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became published.") + moduleListPage.assertSnackbarText(R.string.allModulesAndAllItemsPublished) + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsPublished(quiz.title) + moduleListPage.assertModuleItemIsPublished(testPage.title) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Assert that '${module2.name}' module and all of it's items became published.") + moduleListPage.assertModuleIsPublished(module2.name) + moduleListPage.assertModuleItemIsPublished(assignment2.name) + moduleListPage.assertModuleItemIsPublished(testPage2.title) + + Log.d(STEP_TAG, "Open Module List Page overflow menu") + openOverflowMenu() + + Log.d(STEP_TAG, "Click on 'Unpublish all Modules and Items' and confirm it via the unpublish dialog.") + moduleListPage.clickOnText(R.string.unpublishAllModulesAndItems) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'All Modules and Items' is displayed as title on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.allModulesAndItems) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became unpublished.") + moduleListPage.assertSnackbarText(R.string.allModulesAndAllItemsUnpublished) + moduleListPage.assertModuleNotPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Assert that '${module2.name}' module and all of it's items became unpublished.") + moduleListPage.assertModuleNotPublished(module2.name) + moduleListPage.assertModuleItemNotPublished(assignment2.name) + moduleListPage.assertModuleItemNotPublished(testPage2.title) + + Log.d(STEP_TAG, "Open Module List Page overflow menu") + openOverflowMenu() + + Log.d(STEP_TAG, "Click on 'Publish Modules only' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishModulesOnly) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'All Modules' title is displayed on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.allModules) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and only the '${module.name}' module became published, but it's items remaining unpublished.") + moduleListPage.assertSnackbarText(R.string.onlyModulesPublished) + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Assert that '${module2.name}' module became published but all of it's items are remaining unpublished.") + moduleListPage.assertModuleIsPublished(module2.name) + moduleListPage.assertModuleItemNotPublished(assignment2.name) + moduleListPage.assertModuleItemNotPublished(testPage2.title) + + //Middle layer - One Module and Items + + Log.d(STEP_TAG, "Click on '${module.name}' module overflow and assert that the corresponding menu items are displayed.") + moduleListPage.clickItemOverflow(module.name) + moduleListPage.assertModuleMenuItems() + + Log.d(STEP_TAG, "Click on 'Publish Module and all Items' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishModuleAndItems) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Selected Modules and Items' is displayed as title on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.selectedModulesAndItems) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became published.") + moduleListPage.assertSnackbarText(R.string.moduleAndAllItemsPublished) + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemIsPublished(assignment.name) + moduleListPage.assertModuleItemIsPublished(quiz.title) + moduleListPage.assertModuleItemIsPublished(testPage.title) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Click on '${module.name}' module overflow.") + moduleListPage.clickItemOverflow(module.name) + + Log.d(STEP_TAG, "Click on 'Unpublish Module and all Items' and confirm it via the unpublish dialog.") + moduleListPage.clickOnText(R.string.unpublishModuleAndItems) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Selected Modules and Items' is displayed as title on the Progress page. Click on 'Done' on the Progress Page once it finished.") + progressPage.assertProgressPageTitle(R.string.selectedModulesAndItems) + progressPage.clickDone() + + Log.d(STEP_TAG, "Assert that the proper snack bar text is displayed and the '${module.name}' module and all of it's items became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleAndAllItemsUnpublished) + moduleListPage.assertModuleNotPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Click on '${module.name}' module overflow.") + moduleListPage.clickItemOverflow(module.name) + + Log.d(STEP_TAG, "Click on 'Publish Module only' and confirm it via the publish dialog.") + moduleListPage.clickOnText(R.string.publishModuleOnly) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + device.waitForWindowUpdate(null, 3000) + device.waitForIdle() + + Log.d(STEP_TAG, "Assert that only the '${module.name}' module became published, but it's items remaining unpublished.") + moduleListPage.assertModuleIsPublished(module.name) + moduleListPage.assertModuleItemNotPublished(assignment.name) + moduleListPage.assertModuleItemNotPublished(quiz.title) + moduleListPage.assertModuleItemNotPublished(testPage.title) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + + //Bottom layer - One module item + + Log.d(STEP_TAG, "Click on '${assignment.name}' assignment's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(assignment.name) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${assignment.name}' assignment became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(assignment.name) + + Log.d(STEP_TAG, "Click on '${quiz.title}' quiz's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(quiz.title) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${quiz.title}' quiz became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(quiz.title) + + Log.d(STEP_TAG, "Click on '${testPage.title}' page's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(testPage.title) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${testPage.title}' page module item became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(assignment.name) + + Log.d(STEP_TAG, "Click on '${discussionTopic.title}' discussion topic's overflow menu and publish it. Confirm the publish via the publish dialog.") + moduleListPage.clickItemOverflow(discussionTopic.title) + moduleListPage.clickOnText(R.string.publishModuleItemAction) + moduleListPage.clickOnText(R.string.publishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item published' snack bar has displayed and the '${discussionTopic.title}' discussion topic became published.") + moduleListPage.assertSnackbarText(R.string.moduleItemPublished) + moduleListPage.assertModuleItemIsPublished(discussionTopic.title) + + Log.d(STEP_TAG, "Click on '${assignment.name}' assignment's overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(assignment.name) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${assignment.name}' assignment became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(assignment.name) + + Log.d(STEP_TAG, "Click on '${quiz.title}' quiz's overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(quiz.title) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${quiz.title}' quiz became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(quiz.title) + + Log.d(STEP_TAG, "Click on '${testPage.title}' page overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(testPage.title) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${testPage.title}' page module item became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(testPage.title) + + Log.d(STEP_TAG, "Click on '${discussionTopic.title}' discussion topic's overflow menu and unpublish it. Confirm the unpublish via the unpublish dialog.") + moduleListPage.clickItemOverflow(discussionTopic.title) + moduleListPage.clickOnText(R.string.unpublishModuleItemAction) + moduleListPage.clickOnText(R.string.unpublishDialogPositiveButton) + + Log.d(STEP_TAG, "Assert that the 'Item unpublished' snack bar has displayed and the '${discussionTopic.title}' discussion topic became unpublished.") + moduleListPage.assertSnackbarText(R.string.moduleItemUnpublished) + moduleListPage.assertModuleItemNotPublished(discussionTopic.title) + } + } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt index 147213ebee..d872a7ad6d 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ModulesPage.kt @@ -38,14 +38,6 @@ class ModulesPage : BasePage() { onView(allOf(withId(R.id.moduleListEmptyView), withAncestor(R.id.moduleList))).assertDisplayed() } - /** - * Asserts that the module is not published. - */ - fun assertModuleNotPublished() { - onView(withId(R.id.unpublishedIcon)).assertDisplayed() - onView(withId(R.id.publishedIcon)).assertNotDisplayed() - } - fun assertModuleNotPublished(moduleTitle: String) { onView(withId(R.id.unpublishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertDisplayed() onView(withId(R.id.publishedIcon) + hasSibling(withId(R.id.moduleName) + withText(moduleTitle))).assertNotDisplayed() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt index 3c4822617a..9a952b34aa 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/ProgressPage.kt @@ -18,15 +18,32 @@ package com.instructure.teacher.ui.pages +import androidx.annotation.StringRes +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.getStringFromResource +@OptIn(ExperimentalTestApi::class) class ProgressPage(private val composeTestRule: ComposeTestRule) : BasePage() { fun clickDone() { composeTestRule.waitForIdle() + composeTestRule.waitUntilExactlyOneExists(hasText("Done"), 10000) composeTestRule.onNodeWithText("Done").performClick() } + + fun assertProgressPageTitle(@StringRes title: Int) { + composeTestRule.waitUntilExactlyOneExists(hasText(getStringFromResource(title)), 10000) + composeTestRule.onNodeWithText(getStringFromResource(title)).assertIsDisplayed() + } + + fun assertProgressPageNote(@StringRes note: Int) { + composeTestRule.waitUntilExactlyOneExists(hasText(getStringFromResource(note)), 10000) + composeTestRule.onNodeWithText(getStringFromResource(note)).assertIsDisplayed() + } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt index f67ee6e79d..1c15a9bc52 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherTest.kt @@ -19,11 +19,12 @@ package com.instructure.teacher.ui.utils import android.app.Activity import android.util.Log import android.view.View -import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.hilt.work.HiltWorkerFactory import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule import com.instructure.espresso.ModuleItemInteractions @@ -73,7 +74,6 @@ import com.instructure.teacher.ui.pages.PeopleListPage import com.instructure.teacher.ui.pages.PersonContextPage import com.instructure.teacher.ui.pages.PostSettingsPage import com.instructure.teacher.ui.pages.ProfileSettingsPage -import com.instructure.teacher.ui.pages.ProgressPage import com.instructure.teacher.ui.pages.QuizDetailsPage import com.instructure.teacher.ui.pages.QuizListPage import com.instructure.teacher.ui.pages.QuizSubmissionListPage @@ -103,6 +103,8 @@ abstract class TeacherTest : CanvasTest() { override val isTesting = BuildConfig.IS_TESTING + val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + @Inject lateinit var workerFactory: HiltWorkerFactory From 7176a9c846d5ddd17393fb75dfb230bdefe882cd Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Wed, 6 Mar 2024 13:47:20 +0100 Subject: [PATCH 48/52] [MBL-17390][Student][Teacher] Fix Compose fonts #2371 refs: MBL-17930 affects: Student, Teacher release note: none --- .../pandautils/compose/CanvasTheme.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt index 18a4c9c0c3..a427dd6966 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt @@ -18,8 +18,12 @@ package com.instructure.pandautils.compose +import android.content.Context import androidx.annotation.FontRes import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Typography import androidx.compose.material.ripple.LocalRippleTheme @@ -28,18 +32,29 @@ import androidx.compose.material.ripple.RippleTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType import com.instructure.pandautils.R @Composable fun CanvasTheme(content: @Composable () -> Unit) { MaterialTheme( - typography = typography + typography = typography.copy( + button = typography.button.copy(letterSpacing = TextUnit(0.5f, TextUnitType.Sp)) + ) ) { CompositionLocalProvider( LocalRippleTheme provides CanvasRippleTheme, + LocalTextSelectionColors provides getCustomTextSelectionColors(context = LocalContext.current), + LocalTextStyle provides TextStyle( + fontFamily = lato, + letterSpacing = TextUnit(0f, TextUnitType.Sp) + ), content = content ) } @@ -72,4 +87,12 @@ private object CanvasRippleTheme : RippleTheme { Color.Black, lightTheme = !isSystemInDarkTheme() ) +} + +private fun getCustomTextSelectionColors(context: Context): TextSelectionColors { + val color = Color(context.getColor(R.color.textDarkest)) + return TextSelectionColors( + handleColor = color, + backgroundColor = color.copy(alpha = 0.4f) + ) } \ No newline at end of file From fc0d3b0b41f3c1083cf293e8a3f5ba79bdd35023 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Thu, 7 Mar 2024 12:28:07 +0100 Subject: [PATCH 49/52] [MBL-17428][Student][Teacher] Swipe down refreshes new discussion details page #2372 refs: MBL-17428 affects: Student. Teacher release note: Fixed a bug where swipe down refreshes redesigned discussions. --- .../src/main/res/drawable/ic_refresh.xml | 22 ++++++++++++++++ libs/pandares/src/main/res/values/strings.xml | 1 + .../DiscussionDetailsWebViewFragment.kt | 14 +++++----- .../fragment_discussion_details_web_view.xml | 14 +++------- .../main/res/menu/menu_discussion_details.xml | 26 +++++++++++++++++++ 5 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 libs/pandares/src/main/res/drawable/ic_refresh.xml create mode 100644 libs/pandautils/src/main/res/menu/menu_discussion_details.xml diff --git a/libs/pandares/src/main/res/drawable/ic_refresh.xml b/libs/pandares/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000000..951de8c80b --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 32702583ce..c665eb497f 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1599,4 +1599,5 @@ Unpublish Publish Unpublish + Refresh diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt index c402282c08..ac94862493 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt @@ -75,7 +75,7 @@ class DiscussionDetailsWebViewFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.data.observe(viewLifecycleOwner) { - applyTheme(it.title) + setupToolbar(it.title) } setupFilePicker() binding.discussionWebView.addVideoClient(requireActivity()) @@ -91,7 +91,6 @@ class DiscussionDetailsWebViewFragment : Fragment() { override fun onPageFinishedCallback(webView: WebView, url: String) { viewModel.setLoading(false) - binding.discussionSwipeRefreshLayout?.isRefreshing = false } override fun routeInternallyCallback(url: String) { @@ -104,10 +103,6 @@ class DiscussionDetailsWebViewFragment : Fragment() { return viewModel.data.value?.url?.substringBefore("?") != url.substringBefore("?") } } - - binding.discussionSwipeRefreshLayout.setOnRefreshListener { - binding.discussionWebView.reload() - } } private fun setupFilePicker() { @@ -148,9 +143,14 @@ class DiscussionDetailsWebViewFragment : Fragment() { } } - private fun applyTheme(title: String) = with(binding) { + private fun setupToolbar(title: String) = with(binding) { toolbar.title = title toolbar.setupAsBackButton(this@DiscussionDetailsWebViewFragment) + binding.toolbar.setMenu(R.menu.menu_discussion_details) { + when (it.itemId) { + R.id.refresh -> binding.discussionWebView.reload() + } + } ViewStyler.themeToolbarColored(requireActivity(), toolbar, canvasContext) } diff --git a/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml b/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml index 0ccc49687d..3d6bd6b2f8 100644 --- a/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml +++ b/libs/pandautils/src/main/res/layout/fragment_discussion_details_web_view.xml @@ -35,19 +35,13 @@ app:layout_constraintTop_toTopOf="parent" app:theme="@style/ToolBarStyle" /> - - - - + app:layout_constraintTop_toBottomOf="@id/toolbar" + app:url="@{viewModel.data.url}" /> + + + + + \ No newline at end of file From ab40e3ab6598e7ba56cc2c97190091e95f2f3669 Mon Sep 17 00:00:00 2001 From: inst-danger Date: Thu, 7 Mar 2024 14:25:34 +0100 Subject: [PATCH 50/52] Update translations (#2373) --- .../src/main/res/values-ja/strings.xml | 8 +-- .../src/main/res/values-ar/strings.xml | 53 +++++++++++++++++++ .../res/values-b+da+DK+instk12/strings.xml | 37 +++++++++++++ .../res/values-b+en+AU+unimelb/strings.xml | 37 +++++++++++++ .../res/values-b+en+GB+instukhe/strings.xml | 37 +++++++++++++ .../res/values-b+nb+NO+instk12/strings.xml | 37 +++++++++++++ .../res/values-b+sv+SE+instk12/strings.xml | 37 +++++++++++++ .../src/main/res/values-b+zh+HK/strings.xml | 33 ++++++++++++ .../src/main/res/values-b+zh+Hans/strings.xml | 33 ++++++++++++ .../src/main/res/values-b+zh+Hant/strings.xml | 33 ++++++++++++ .../src/main/res/values-ca/strings.xml | 37 +++++++++++++ .../src/main/res/values-cy/strings.xml | 37 +++++++++++++ .../src/main/res/values-da/strings.xml | 37 +++++++++++++ .../src/main/res/values-de/strings.xml | 37 +++++++++++++ .../src/main/res/values-en-rAU/strings.xml | 37 +++++++++++++ .../src/main/res/values-en-rCY/strings.xml | 37 +++++++++++++ .../src/main/res/values-en-rGB/strings.xml | 37 +++++++++++++ .../src/main/res/values-es-rES/strings.xml | 37 +++++++++++++ .../src/main/res/values-es/strings.xml | 37 +++++++++++++ .../src/main/res/values-fi/strings.xml | 37 +++++++++++++ .../src/main/res/values-fr-rCA/strings.xml | 37 +++++++++++++ .../src/main/res/values-fr/strings.xml | 37 +++++++++++++ .../src/main/res/values-ht/strings.xml | 37 +++++++++++++ .../src/main/res/values-id/strings.xml | 37 +++++++++++++ .../src/main/res/values-is/strings.xml | 37 +++++++++++++ .../src/main/res/values-it/strings.xml | 37 +++++++++++++ .../src/main/res/values-ja/strings.xml | 37 ++++++++++++- .../src/main/res/values-mi/strings.xml | 37 +++++++++++++ .../src/main/res/values-ms/strings.xml | 37 +++++++++++++ .../src/main/res/values-nb/strings.xml | 37 +++++++++++++ .../src/main/res/values-nl/strings.xml | 37 +++++++++++++ .../src/main/res/values-pl/strings.xml | 45 ++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 37 +++++++++++++ .../src/main/res/values-pt-rPT/strings.xml | 37 +++++++++++++ .../src/main/res/values-ru/strings.xml | 45 ++++++++++++++++ .../src/main/res/values-sl/strings.xml | 37 +++++++++++++ .../src/main/res/values-sv/strings.xml | 37 +++++++++++++ .../src/main/res/values-th/strings.xml | 37 +++++++++++++ .../src/main/res/values-vi/strings.xml | 37 +++++++++++++ .../src/main/res/values-zh/strings.xml | 33 ++++++++++++ .../src/main/res/values-ja/strings.xml | 4 +- 41 files changed, 1463 insertions(+), 8 deletions(-) diff --git a/apps/teacher/src/main/res/values-ja/strings.xml b/apps/teacher/src/main/res/values-ja/strings.xml index 15921efd04..65996d9873 100644 --- a/apps/teacher/src/main/res/values-ja/strings.xml +++ b/apps/teacher/src/main/res/values-ja/strings.xml @@ -187,7 +187,7 @@ グループ内にユーザーなし 公開済み - 未公開 + 非公開 公開されたチェックマーク ポイント ポイント @@ -677,7 +677,7 @@ 新しいディスカッション オプション - 定期購読 + 常に通知 スレッドでの返信を許可する ユーザは返信を表示する前に投稿しなければなりません ユーザーにコメントを許可する @@ -806,7 +806,7 @@ ファイルを作成する ファイルの作成オタンとフォルダの作成ボタンを表示する ファイルの作成とフォルダの作成ボタンを非表示にする - 未公開 + 非公開 制限されたアクセス 部外秘 非表示の内部ファイルはリンクで利用できます。 @@ -826,7 +826,7 @@ ページを削除 これにより、ページは削除されます。この操作は元に戻すことはできません。 この発表を保存しようとしてエラーが発生しました。もう一度試してください。 - ページを先フロントページにした場合、未公開にすることはできません。 + ページを先フロントページにした場合、非公開にすることはできません。 教員のみ 教員と受講者 diff --git a/libs/pandares/src/main/res/values-ar/strings.xml b/libs/pandares/src/main/res/values-ar/strings.xml index 105d68b18b..400a75f0e8 100644 --- a/libs/pandares/src/main/res/values-ar/strings.xml +++ b/libs/pandares/src/main/res/values-ar/strings.xml @@ -71,6 +71,59 @@ مفقود تم تقييم الدرجة + التذكير + أضف إعلامات تذكير تاريخ الاستحقاق بشأن هذه المهمة على هذا الجهاز. + إضافة التذكير + إزالة التذكير + %s قبل + + %d من الدقائق + دقيقة واحدة + %d من الدقائق + %d من الدقائق + %d من الدقائق + %d من الدقائق + + + %d من الساعات + ساعة واحدة + %d من الساعات + %d من الساعات + %d من الساعات + %d من الساعات + + + %d من الأيام + يوم واحد + %d من الأيام + %d من الأيام + %d من الأيام + %d من الأيام + + + %d من الأسابيع + 1 أسبوع + %d من الأسابيع + %d من الأسابيع + %d من الأسابيع + %d من الأسابيع + + مخصص + تذكير مخصص + الكمية + دقائق قبل + ساعات قبل + أيام قبل + أسابيع قبل + إعلامات التذكير + إعلامات Canvas لتذكيرات المهام. + تذكير تاريخ الاستحقاق + هذه المهمة مستحقة في %s: %s + يرجى اختيار وقت مستقبلي لتذكيرك! + لقد قمت بالفعل بتعيين تذكير لهذا الوقت + حذف التذكير + هل حقًا ترغب في حذف هذا التذكير؟ + يجب أن تقوم بتمكين إذن التنبيه الدقيق لهذا الإجراء لا توجد معاينة متوفرة لعناوين URL باستخدام \'http://\' يُرجى إدخال عنوان URL صالح diff --git a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml index 98baa01d90..2c8e219f1e 100644 --- a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml @@ -68,6 +68,43 @@ Mangler Bedømt + Påmindelse + Tilføj påmindelser om afleveringsdato for denne opgave på denne enhed. + Tilføj påmindelse + Fjern påmindelse + %s før + + 1 minut + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dage + + + 1 uge + %d uger + + Brugerdefineret + Brugerdefineret påmindelse + Antal + Minutter før + Timer før + Dage før + Uger før + Påmindelsesmeddelelser + Canvas-meddelelser for opgavepåmindelser. + Påmindelse om afleveringsdato + Denne opgave skal afleveres om %s: %s + Vælg et fremtidigt tidspunkt for din påmindelse! + Du har allerede indstillet en påmindelse for dette tidspunkt + Slet påmindelse + Er du sikker på, at du vil slette denne påmindelse? + Du skal aktivere nøjagtig alarmtilladelse for denne handling Der findes ingen forhåndsvisning for URL’er, der bruger \'http://\' Indtast venligst en gyldig URL diff --git a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml index eb6c0ce191..2db17bcd15 100644 --- a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL diff --git a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml index 323ef754a6..6eb60cea44 100644 --- a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL diff --git a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml index 4bb4d4c529..dd33e1e55a 100644 --- a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -68,6 +68,43 @@ Mangler Vurdert + Påminnelse + Legg til varsler for påminnelse om forfallsdato for oppgaven på denne enheten. + Legg til påminnelse + Fjern påminnelse + %s før + + 1 minutt + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dager + + + 1 uke + %d uker + + Tilpasset + Tilpasset påminnelse + Antall + Minutter før + Timer før + Dager før + Uker før + Påminnelsesvarslinger + Canvas-varslinger for oppgavepåminnelser. + Påminnelse om forfallsdato + Denne oppgaven har frist om %s: %s + Velg en fremtidig forfallsdato for påminnelsen din! + Du har allerede angitt en påminnelse for dette tidspunktet + Slett påminnelse + Er du sikker på at du vil slette denne påminnelsen? + Du må aktivere nøyaktig alarmtillatelse for denne handlingen. Det er ingen forhåndsvisnings-URL ved bruk av \'http://\' Skriv inn gyldig URL diff --git a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml index fb1b08c211..ac8b92c870 100644 --- a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -68,6 +68,43 @@ Saknas Har bedömts + Påminnelse + Lägg till påminnelse om inlämningsdatum om denna uppgift på denna enhet. + Lägg till påminnelse + Ta bort påminnelse + %s Före + + 1 minut + %d minuter + + + 1 timme + %d timmar + + + 1 dag + %d dagar + + + 1 vecka + %d veckor + + Anpassa + Anpassad påminnelse + Kvantitet + Minuter före + Timmar före + Dagar före + Veckor före + Påminnelser + Canvas-meddelanden för påminnelser om uppgifter + Påminnelse om inlämning + Denna uppgift ska lämnas in %s: %s + Välj en tid i framtiden för din påminnelse! + Du har redan ställt in en påminnelse för den här tiden + Radera påminnelse + Är du säker på att du vill radera den här påminnelsen? + Du måste aktivera behörigheten för exakt larm för den här åtgärden. Ingen förhandsgranskning är tillgänglig för URL:er som använder \'http://\' Ange en giltig URL diff --git a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml index 44c1579f5c..23ca780ba0 100644 --- a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml @@ -67,6 +67,39 @@ 缺少 已評分 + 提醒 + 在此裝置上添加有關此作業的截止日期提醒通知。 + 添加提醒 + 移除提醒 + %s 前 + + %d 分鐘 + + + %d 小時 + + + %d 天 + + + %d 週 + + 自訂 + 自訂提醒 + 數量 + 分鐘前 + 小時前 + 天前 + 週前 + 提醒通知 + 使用於作業提醒的 Canvas 通知。 + 截止日期提醒 + 此作業截止於 %s:%s + 請選擇您將來的提醒時間! + 您已經為此時間設定了提醒 + 刪除提醒 + 是否確定要刪除此提醒? + 您需要為此動作啟用確切的警報權限 沒有使用 \'http://\' 的 URL 的可用預覽 請輸入有效的 URL diff --git a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml index e8294a5955..0617c4530c 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml @@ -67,6 +67,39 @@ 缺失 已评分 + 提醒 + 在本设备上添加关于此作业的截止日期提醒通知。 + 添加提醒 + 删除提醒 + 提前的%s + + %d 分钟 + + + %d 小时 + + + %d 天 + + + %d 周 + + 自定义 + 自定义提醒 + 数量 + 提前的分钟数 + 提前的小时数 + 提前的天数 + 提前的周数 + 提醒通知 + Canvas 作业提醒通知。 + 截止日期提醒 + 此作业在%s后截止:%s + 请选择未来的提醒日期! + 您已经为该时间设置提醒 + 删除提醒 + 是否确实要删除此提醒? + 您需要为此操作启用精确警报许可 使用“http://”的 URL 无可用预览 请输入有效的 URL diff --git a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml index 44c1579f5c..23ca780ba0 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml @@ -67,6 +67,39 @@ 缺少 已評分 + 提醒 + 在此裝置上添加有關此作業的截止日期提醒通知。 + 添加提醒 + 移除提醒 + %s 前 + + %d 分鐘 + + + %d 小時 + + + %d 天 + + + %d 週 + + 自訂 + 自訂提醒 + 數量 + 分鐘前 + 小時前 + 天前 + 週前 + 提醒通知 + 使用於作業提醒的 Canvas 通知。 + 截止日期提醒 + 此作業截止於 %s:%s + 請選擇您將來的提醒時間! + 您已經為此時間設定了提醒 + 刪除提醒 + 是否確定要刪除此提醒? + 您需要為此動作啟用確切的警報權限 沒有使用 \'http://\' 的 URL 的可用預覽 請輸入有效的 URL diff --git a/libs/pandares/src/main/res/values-ca/strings.xml b/libs/pandares/src/main/res/values-ca/strings.xml index 3d627d7740..4281c11c5d 100644 --- a/libs/pandares/src/main/res/values-ca/strings.xml +++ b/libs/pandares/src/main/res/values-ca/strings.xml @@ -68,6 +68,43 @@ No presentat Qualificat + Recordatori + Afegiu notificacions de recordatori de dates de lliurament d\'aquesta activitat a aquest dispositiu. + Afegeix un recordatori + Suprimeix el recordatori + %s anterior + + 1 minut + %d minuts + + + 1 hora + %d hores + + + 1 dia + %d dies + + + 1 setmana + %d setmanes + + Personalitzat + Recordatori personalitzat + Quantitat + Minuts anteriors + Hores anteriors + Dies anteriors + Setmanes anteriors + Notificacions de recordatoris + Notificacions del Canvas per a recordatoris d’activitats. + Recordatori de data de lliurament + Aquesta activitat s’ha de lliurar el %s: %s + Trieu una data futura per al recordatori + Ja heu establert un recordatori per a aquesta data + Suprimeix el recordatori + Segur que voleu suprimir aquest recordatori? + Heu d\'activar el permís d’alarma exacte per a aquesta acció Si es fa servir \'http://\', no hi ha cap visualització prèvia disponible dels URL Introduïu un URL vàlid diff --git a/libs/pandares/src/main/res/values-cy/strings.xml b/libs/pandares/src/main/res/values-cy/strings.xml index 52a16fbd8b..abcde4e9b9 100644 --- a/libs/pandares/src/main/res/values-cy/strings.xml +++ b/libs/pandares/src/main/res/values-cy/strings.xml @@ -68,6 +68,43 @@ Ar goll Wedi graddio + Nodyn atgoffa + Ychwanegu hysbysiad atgoffa o ddyddiad erbyn am yr aseiniad hwn ar y ddyfais hon. + Ychwanegu nodyn atgoffa + Tynnu nodyn atgoffa + %s Cyn + + 1 munud + %d Munud + + + 1 awr + %d Awr + + + 1 diwrnod + %d Diwrnod + + + 1 Wythnos + %d Wythnos + + Personol + Nodyn Atgoffa Personol + Swm + Munud Cyn + Awr Cyn + Diwrnod Cyn + Wythnos Cyn + Hysbysiadau Atgoffa + Hysbysiadau Canvas ar gyfer nodiadau atgoffa o aseiniad + Nodyn Atgoffa o Ddyddiad Erbyn + Mae’r aseiniad hwn angen ei gyflwyno erbyn mewn %s: %s + Dewiswch amser yn y dyfodol ar gyfer eich nodyn atgoffa! + Rydych chi eisoes wedi gosod nodyn atgoffa ar gyfer yr amser hwn + Dileu Nodyn Atgoffa + Ydych chi’n siŵr eich bod am ddileu’r nodyn atgoffa hwn? + Mae angen i chi alluogi hawl larwm penodol ar gyfer y cam gweithredu hwn Does dim rhagolwg ar gael ar gyfer URL sy\'n defnyddio \'http://\' Rhowch URL dilys diff --git a/libs/pandares/src/main/res/values-da/strings.xml b/libs/pandares/src/main/res/values-da/strings.xml index db4a169daf..1f2c7986a5 100644 --- a/libs/pandares/src/main/res/values-da/strings.xml +++ b/libs/pandares/src/main/res/values-da/strings.xml @@ -68,6 +68,43 @@ Mangler Bedømt + Påmindelse + Tilføj påmindelser om afleveringsdato for denne opgave på denne enhed. + Tilføj påmindelse + Fjern påmindelse + %s før + + 1 minut + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dage + + + 1 uge + %d uger + + Brugerdefineret + Brugerdefineret påmindelse + Antal + Minutter før + Timer før + Dage før + Uger før + Påmindelsesmeddelelser + Canvas-meddelelser for opgavepåmindelser. + Påmindelse om afleveringsdato + Denne opgave skal afleveres om %s: %s + Vælg et fremtidigt tidspunkt for din påmindelse! + Du har allerede indstillet en påmindelse for dette tidspunkt + Slet påmindelse + Er du sikker på, at du vil slette denne påmindelse? + Du skal aktivere nøjagtig alarmtilladelse for denne handling Der findes ingen forhåndsvisning for URL’er, der bruger \'http://\' Indtast venligst en gyldig URL diff --git a/libs/pandares/src/main/res/values-de/strings.xml b/libs/pandares/src/main/res/values-de/strings.xml index 442768c588..a0b12c826d 100644 --- a/libs/pandares/src/main/res/values-de/strings.xml +++ b/libs/pandares/src/main/res/values-de/strings.xml @@ -68,6 +68,43 @@ Fehlt Benotet + Erinnerung + Fügen Sie Erinnerungsbenachrichtigungen zu dieser Aufgabe auf diesem Gerät hinzu. + Erinnerung hinzufügen + Erinnerung entfernen + %s Vor + + 1 Minute + %d Minuten + + + 1 Stunde + %d Stunden + + + 1 Tag + %d Tage + + + 1 Woche + %d Wochen + + Angepasst + Benutzerdefinierte Erinnerung + Menge + Minuten vorher + Stunden vorher + Tage vorher + Wochen vorher + Erinnerungsbenachrichtigungen + Canvas-Benachrichtigungen für Aufgabenerinnerungen. + Fälligkeitserinnerung + Diese Aufgabe ist fällig in %s: %s + Bitte wählen Sie eine zukünftige Zeit für Ihre Erinnerung! + Sie haben bereits eine Erinnerung für diese Zeit eingestellt + Erinnerung löschen + Sind Sie sicher, dass Sie diese Erinnerung löschen möchten? + Sie müssen für diese Aktion die genaue Alarmberechtigung aktivieren Für URLs, die \'http://\' verwenden, ist keine Vorschau verfügbar. Geben Sie bitte eine gültige URL ein. diff --git a/libs/pandares/src/main/res/values-en-rAU/strings.xml b/libs/pandares/src/main/res/values-en-rAU/strings.xml index 8c83327465..3afd34175a 100644 --- a/libs/pandares/src/main/res/values-en-rAU/strings.xml +++ b/libs/pandares/src/main/res/values-en-rAU/strings.xml @@ -68,6 +68,43 @@ Missing Marked + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL diff --git a/libs/pandares/src/main/res/values-en-rCY/strings.xml b/libs/pandares/src/main/res/values-en-rCY/strings.xml index 323ef754a6..6eb60cea44 100644 --- a/libs/pandares/src/main/res/values-en-rCY/strings.xml +++ b/libs/pandares/src/main/res/values-en-rCY/strings.xml @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL diff --git a/libs/pandares/src/main/res/values-en-rGB/strings.xml b/libs/pandares/src/main/res/values-en-rGB/strings.xml index 853018a743..670d7f2794 100644 --- a/libs/pandares/src/main/res/values-en-rGB/strings.xml +++ b/libs/pandares/src/main/res/values-en-rGB/strings.xml @@ -68,6 +68,43 @@ Missing Graded + Reminder + Add due date reminder notifications about this assignment on this device. + Add reminder + Remove reminder + %s Before + + 1 Minute + %d Minutes + + + 1 Hour + %d Hours + + + 1 Day + %d Days + + + 1 Week + %d Weeks + + Custom + Custom Reminder + Quantity + Minutes Before + Hours Before + Days Before + Weeks Before + Reminder Notifications + Canvas Notifications for assignment reminders. + Due Date Reminder + This assignment is due in %s: %s + Please choose a future time for your reminder! + You have already set a reminder for this time + Delete Reminder + Are you sure you would like to delete this reminder? + You need to enable exact alarm permission for this action No preview available for URLs using \'http://\' Please enter a valid URL diff --git a/libs/pandares/src/main/res/values-es-rES/strings.xml b/libs/pandares/src/main/res/values-es-rES/strings.xml index fbc52175a1..f9d488801c 100644 --- a/libs/pandares/src/main/res/values-es-rES/strings.xml +++ b/libs/pandares/src/main/res/values-es-rES/strings.xml @@ -68,6 +68,43 @@ No presentado Evaluado + Recordatorio + Añadir notificaciones para el recordatorio de la fecha de entrega sobre esta actividad en este dispositivo. + Añadir recordatorio + Eliminar recordatorio + %s antes + + 1 minuto + %d minutos + + + 1 hora + %d horas + + + 1 día + %d días + + + 1 semana + %d semanas + + Personalizar + Personalizar recordatorio + Cantidad + Minutos antes + Horas antes + Días antes + Semanas antes + Recordar notificaciones + Notificaciones de Canvas para los recordatorios de la actividad. + Recordatorio de la fecha de entrega + Esta actividad debe entregarse el %s: %s + ¡Elige una hora posterior para tu recordatorio! + Ya has configurado un recordatorio para esta hora + Eliminar recordatorio + ¿Estás seguro de que quieres eliminar este recordatorio? + Necesitas habilitar el permiso de alarma exacta para realizar esta acción No hay vistas previas disponibles para las URL que usan \'http://\' Introduce una URL válida diff --git a/libs/pandares/src/main/res/values-es/strings.xml b/libs/pandares/src/main/res/values-es/strings.xml index 0a826ef847..a3d2c4eecc 100644 --- a/libs/pandares/src/main/res/values-es/strings.xml +++ b/libs/pandares/src/main/res/values-es/strings.xml @@ -68,6 +68,43 @@ Deficiente Calificado + Recordatorio + Agregar notificaciones de recordatorio de fecha de entrega para esta tarea en este dispositivo. + Agregar recordatorio + Eliminar recordatorio + %s Antes + + 1 minuto + %d minutos + + + 1 hora + %d horas + + + 1 día + %d días + + + 1 semana + %d semanas + + Personalizar + Personalizar Recordatorio + Cantidad + Minutos antes + Horas antes + Días antes + Semanas antes + Notificaciones de recordatorio + Notificaciones de Canvas para recordatorios de tareas. + Recordatorio de fecha de entrega + La tarea se vence en %s: %s + Elija un horario futuro para su recordatorio. + Ya ha creado un recordatorio para este horario. + Eliminar Recordatorio + ¿Seguro que desea eliminar este recordatorio? + Necesita habilitar un permiso de alarma exacto para completar esta acción. No hay vistas previas disponibles para las URL que usan \'http://\' Ingrese una URL válida diff --git a/libs/pandares/src/main/res/values-fi/strings.xml b/libs/pandares/src/main/res/values-fi/strings.xml index fce7fece1f..fcaf19fae0 100644 --- a/libs/pandares/src/main/res/values-fi/strings.xml +++ b/libs/pandares/src/main/res/values-fi/strings.xml @@ -68,6 +68,43 @@ Puuttuu Arvosteltu + Muistutus + Lisää päivämäärän muistutuksia tästä tehtävästä tällä laitteella. + Lisää muistutus + Poista muistutus + %s Ennen + + 1 minuutti + %d minuuttia + + + 1 tunti + %d tuntia + + + 1 päivä + %d päivää + + + 1 viikko + %d viikkoa + + Mukautettu + Mukautettu muistutus + Määrä + Minuuttia ennen + Tuntia ennen + Päivää ennen + Viikkoa ennen + Muistutukset + Canvas-ilmoitukset tehtävämuistutuksille. + Päivämäärämuistutukset + Tämän tehtävän määräpäivä on %s %s + Valitse tuleva aika muistutuksellesi! + Olet jo asettanut muistutuksen tähän ajankohtaan + Poista muistutus + Haluatko varmasti poistaa tämän muistutuksen? + Sinun täytyy ottaa käyttöön täsmälliset hälytyksen käyttöoikeudet tälle toiminnolle Ei saatavissa esikatselua URL-linkeille, jotka käyttävät \'http://\' Syötä voimassa oleva URL diff --git a/libs/pandares/src/main/res/values-fr-rCA/strings.xml b/libs/pandares/src/main/res/values-fr-rCA/strings.xml index b019c0d273..ebabfdafd4 100644 --- a/libs/pandares/src/main/res/values-fr-rCA/strings.xml +++ b/libs/pandares/src/main/res/values-fr-rCA/strings.xml @@ -68,6 +68,43 @@ Manquant Noté + Rappel + Ajouter des notifications de rappel de date d’échéance concernant cette tâche sur cet appareil. + Ajouter un rappel + Retirer un rappel + %s Avant + + 1 Minute + %d Minutes + + + 1 heure + %d heures + + + 1 jour + %d jours + + + 1 semaine + %d semaines + + Personnalisé + Rappel personnalisé + Quantité + Minutes avant + Heures avant + Jours avant + Semaines avant + Notifications de rappel + Notifications Canvas pour les rappels de tâches. + Rappel de la date d’échéance + Cette tâche est dû dans %s : %s + Veuillez choisir une heure ultérieure pour votre rappel! + Vous avez déjà défini un rappel pour cette heure + Supprimer le rappel + Voulez-vous vraiment supprimer ce rappel? + Vous devez activer l’autorisation d’alarme exacte pour cette action Aucun aperçu disponible pour les URL utilisant http:// Veuillez saisir une URL valide diff --git a/libs/pandares/src/main/res/values-fr/strings.xml b/libs/pandares/src/main/res/values-fr/strings.xml index 7ef75407d1..bfecb2f221 100644 --- a/libs/pandares/src/main/res/values-fr/strings.xml +++ b/libs/pandares/src/main/res/values-fr/strings.xml @@ -68,6 +68,43 @@ Manquant Noté + Rappel + Ajouter des notifications de rappel de date limite pour ce travail sur cet appareil. + Ajouter le rappel + Supprimer le rappel + %s à l\'avance + + 1 minute + %d Minutes + + + 1 heure + %d heures + + + 1 jour + %d Jours + + + 1 semaine + %d semaines + + Personnalisé + Rappel personnalisé + Quantité + Minutes à l\'avance + Heures à l\'avance + Jours à l\'avance + Semaines à l\'avance + Notifications de rappel + Notifications Canvas de rappel de travail. + Rappel de date limite + Ce travail est attendu dans %s : %s + Veuillez choisir une heure pour votre rappel ! + Vous avez déjà défini un rappel à cette heure. + Supprimer le rappel + Voulez-vous vraiment supprimer ce rappel ? + Vous devez activer la bonne autorisation d\'alarme pour cette action Aucun aperçu disponible pour les URLs utilisant « http:// » Veuillez saisir une URL valide diff --git a/libs/pandares/src/main/res/values-ht/strings.xml b/libs/pandares/src/main/res/values-ht/strings.xml index e550e51637..2a8d1c352c 100644 --- a/libs/pandares/src/main/res/values-ht/strings.xml +++ b/libs/pandares/src/main/res/values-ht/strings.xml @@ -68,6 +68,43 @@ Manke Klase + Rapèl + Ajoute notifikasyon rapèl delè pou devwa sa a sou aparèy sa a. + Ajoute rapèl + Elimine rapèl + %s Anvan + + 1 Minit + %d Minit + + + 1 èdtan + %d Èdtan + + + 1 Jou + %d Jou + + + 1 Semèn + %d Semèn + + Pèsonalize + Rapèl Pèsonalize + Kantite + Minit Anvan + Èdtan Anvan + Jou Anvan + Semèn Anvan + Notifikasyon Rapèl + Notifikasyon Canvas pou rapèl devwa yo. + Rapèl Delè + Devwa sa a dwe remèt %s: %s + Chwazi yon dat apre pou rapèl ou a! + Ou gentan fikse yon rapèl pou lè sa a + Elimine Rapèl + Èske w sèten ou vle elimine rapèl sa a? + Ou dwe aktive otorizasyon alam egzat la pou aksyon sa a Okenn apèsi disponib pou URL ki itilize \'http://\' Tanpri antre yon URL ki valid diff --git a/libs/pandares/src/main/res/values-id/strings.xml b/libs/pandares/src/main/res/values-id/strings.xml index 0de16706ad..37180e7e64 100644 --- a/libs/pandares/src/main/res/values-id/strings.xml +++ b/libs/pandares/src/main/res/values-id/strings.xml @@ -68,6 +68,43 @@ Tidak Ada Dinilai + Pengingat + Tambah notifikasi pengingat tanggal batas waktu tentang tugas ini pada perangkat ini. + Tambah pengingat + Hapus pengingat + %s Sebelum + + 1 Menit + %d Menit + + + 1 Jam + %d Jam + + + 1 Hari + %d Hari + + + 1 Minggu + %d Minggu + + Khusus + Pengingat Kustom + Jumlah + Menit Sebelum + Jam Sebelum + Hari Sebelum + Minggu Sebelum + Notifikasi Pengingat + Notifikasi Canvas untuk pengingat tugas. + Pengingat Tanggal Batas Waktu + Tugas ini jatuh tempo dalam %s: %s + Silakan pilih waktu di masa depan untuk pengingat Anda! + Anda sudah mengatur pengingat untuk waktu ini + Hapus Pengingat + Anda yakin mau menghapus pengingat ini? + Anda harus mampu menetapkan izin alarm untuk tindakan ini Pratinjau tidak tersedia untuk URL yang menggunakan \'http://\' Masukkan URL yang valid. diff --git a/libs/pandares/src/main/res/values-is/strings.xml b/libs/pandares/src/main/res/values-is/strings.xml index 876ecd299b..e72b1018dc 100644 --- a/libs/pandares/src/main/res/values-is/strings.xml +++ b/libs/pandares/src/main/res/values-is/strings.xml @@ -68,6 +68,43 @@ Vantar Metið + Áminning + Bættu við áminningartilkynningum um skiladag um þetta verkefni á þessu tæki. + Bæta við áminningu + Fjarlægja áminningu + %s áður + + 1 mínútu + %d mínútur + + + 1 klukkustund + %d klukkustundir + + + 1 dag + %d daga + + + 1 viku + %d vikur + + Sérsnið + Sérsniðin áminning + Magn + Mínútum áður + Klukkutímum áður + Dögum áður + Vikum áður + Áminningartilkynningar + Canvas Tilkynningar fyrir áminningar um verkefni. + Áminning um skiladag + Skiladagur þessa verkefnis er eftir %s: %s + Vinsamlegast veldu framtíðartíma fyrir áminningu þína! + Þú hefur þegar sett áminningu fyrir þennan tíma + Eyða áminningu + Viltu örugglega eyða þessari áminningu? + Þú þarft að virkja nákvæmt viðvörunarleyfi fyrir þessa aðgerð Engin forskoðun tiltæk fyrir vefslóð sem nota \'http://\' Settu inn gilda vefslóð diff --git a/libs/pandares/src/main/res/values-it/strings.xml b/libs/pandares/src/main/res/values-it/strings.xml index e74f2d030c..d2718c0d31 100644 --- a/libs/pandares/src/main/res/values-it/strings.xml +++ b/libs/pandares/src/main/res/values-it/strings.xml @@ -68,6 +68,43 @@ Mancante Valutato + Promemoria + Aggiungi notifiche di promemoria della data di scadenza su questo compito su questo dispositivo. + Aggiungi promemoria + Rimuovi promemoria + %s Prima + + 1 minuto + %d minuti + + + 1 ora + %d ore + + + 1 giorno + %d giorni + + + 1 settimana + %d settimane + + Personalizzato + Promemoria personalizzato + Quantità + Minuti prima + Ore prima + Giorni prima + Settimane prima + Notifiche promemoria + Notifica Canvas per promemoria compito. + Promemoria data di scadenza + Questo compito scade in %s: %s + Scegliere un periodo futuro per il promemoria! + Hai già impostato un promemoria per questo periodo + Elimina promemoria + Vuoi eliminare questo promemoria? + Devi abilitare l’autorizzazione di allarme esatto per questa operazione Nessuna anteprima disponibile per gli URL che utilizzano \'http://\' Inserisci un URL valido diff --git a/libs/pandares/src/main/res/values-ja/strings.xml b/libs/pandares/src/main/res/values-ja/strings.xml index 5c3311d69d..5de5425ac7 100644 --- a/libs/pandares/src/main/res/values-ja/strings.xml +++ b/libs/pandares/src/main/res/values-ja/strings.xml @@ -67,6 +67,39 @@ 欠如 採点済み + 事前通知 + このデバイスにこの課題に関する期限リマインダ通知を追加します。 + リマインダを追加する + リマインダを削除する + %s前 + + %d 分 + + + %d時間 + + + %d 日 + + + %d 週間 + + カスタム + カスタムリマインダ + 数量 + 分前 + 時間前 + 日前 + 週前 + リマインダ通知 + Canvasの課題に関する通知 + 期日のリマインダ + この課題の提出期限は%s以内:%s + リマインダの時間には未来の時間を選択してください! + あなたはすでにこの時間にリマインダを設定しています + リマインダを削除する + このリマインダ本当に削除しますか? + この操作を行うには、正確なアラーム許可を有効にする必要があります。 URL を \'http://\' でプレビューすることはできません 有効なURLを入力してください @@ -1319,7 +1352,7 @@ 受講者の提出がある場合は課題を取り消すことはできません。 カレンダーフィードを購読する このダイアログで「定期購読」ボタンをクリックすると、CanvasカレンダーをGoogleカレンダーアカウントに同期させることができます。その後、デバイスのGoogleカレンダーアプリで、新しいカレンダーの設定で同期を有効にする必要があります。 - 定期購読 + 常に通知 ライトモードに切り替える ダークモードに切り替える あなたは新しいユーザーであるか、最後に同意して以降、許可される使用ポリシーが変更されています。続行する前に許可される使用ポリシーに同意してください。 @@ -1331,7 +1364,7 @@ %d 試行 質問:%d 時間制限:%s - 試行許可数:%s + 試行許可回数:%s 試行した回数:%d 予期しないエラーが発生しました。 diff --git a/libs/pandares/src/main/res/values-mi/strings.xml b/libs/pandares/src/main/res/values-mi/strings.xml index b7f0aa7e84..ac24ae565b 100644 --- a/libs/pandares/src/main/res/values-mi/strings.xml +++ b/libs/pandares/src/main/res/values-mi/strings.xml @@ -68,6 +68,43 @@ Ngaro kōekehia + Whakamaumahara + Tāpirihia nga whakamohiotanga whakamaumahara mo te ra tika mo tenei taumahi i runga i tenei taputapu. + Tāpiri whakamaumahara + Tango whakamaumahara + %s I mua + + 1 meneti + %d meneti + + + 1 Haora + %d Ngā Haora + + + 1 Ra + %d Ngā Ra + + + 1 Wiki + %d Nga wiki + + Tikanga + Whakamaumahara Ritenga + Te nui + Nga meneti o mua + Nga haora i mua + Nga ra o mua + Nga wiki o mua + Panui Whakamaumahara + Whakamōhiotanga Canvas mō ngā whakamaumahara taumahi. + Whakamaumahara ki te Ra Whakatau + Ka tika tenei taumahi i roto %s: %s + Tena koa whiriwhiria he wa kei te heke mai mo to whakamaumahara! + Kua tautuhia e koe he whakamaumahara mo tenei wa + Muku Whakamaumahara + E tino hiahia ana koe ki te muku i tenei whakamaumahara? + Me whakaahei koe i te whakaaetanga whakaoho mo tenei mahi Kaore e wātea he arokite mo ngā URL e mahi ana http// Tēnā koa whakauru he URL whaimana diff --git a/libs/pandares/src/main/res/values-ms/strings.xml b/libs/pandares/src/main/res/values-ms/strings.xml index 8383951961..b832724d45 100644 --- a/libs/pandares/src/main/res/values-ms/strings.xml +++ b/libs/pandares/src/main/res/values-ms/strings.xml @@ -68,6 +68,43 @@ Tiada Digredkan + Peringatan + Tambah peringatan tarikh siap untuk tugasan ini pada peranti ini. + Tambah peringatan + Alih keluar peringatan + %s Sebelum + + 1 Minit + %d Minit + + + 1 Jam + %d Jam + + + 1 Hari + %d Hari + + + 1 Minggu + %d Minggu + + Tersuai + Peringatan Tersuai + Kuantiti + Minit Sebelum + Jam Sebelum + Hari Sebelum + Minggu Sebelum + Pemberitahuan Peringatan + Pemberitahuan Canvas untuk peringatan tugasan. + Peringatan Tarikh Siap + Tugasan ini perlu disiapkan dalam %s: %s + Sila pilih masa akan datang untuk peringatan anda! + Anda telah menetapkan peringatan untuk masa ini + Padam Peringatan + Adakah anda pasti anda ingin memadamkan peringatan ini? + Anda perlu mendayakan keizinan penggera yang tepat untuk tindakan ini Tiada pratonton tersedia untuk URL menggunakan \'http://\' Sila masukkan URL yang sah diff --git a/libs/pandares/src/main/res/values-nb/strings.xml b/libs/pandares/src/main/res/values-nb/strings.xml index a4018ab8fe..2103ae6842 100644 --- a/libs/pandares/src/main/res/values-nb/strings.xml +++ b/libs/pandares/src/main/res/values-nb/strings.xml @@ -68,6 +68,43 @@ Mangler Vurdert + Påminnelse + Legg til varsler for påminnelse om forfallsdato for oppgaven på denne enheten. + Legg til påminnelse + Fjern påminnelse + %s før + + 1 minutt + %d minutter + + + 1 time + %d timer + + + 1 dag + %d dager + + + 1 uke + %d uker + + Tilpasset + Tilpasset påminnelse + Antall + Minutter før + Timer før + Dager før + Uker før + Påminnelsesvarslinger + Canvas-varslinger for oppgavepåminnelser. + Påminnelse om forfallsdato + Denne oppgaven har frist om %s: %s + Velg en fremtidig forfallsdato for påminnelsen din! + Du har allerede angitt en påminnelse for dette tidspunktet + Slett påminnelse + Er du sikker på at du vil slette denne påminnelsen? + Du må aktivere nøyaktig alarmtillatelse for denne handlingen. Det er ingen forhåndsvisnings-URL ved bruk av \'http://\' Skriv inn gyldig URL diff --git a/libs/pandares/src/main/res/values-nl/strings.xml b/libs/pandares/src/main/res/values-nl/strings.xml index a9688a48d6..a71a87122c 100644 --- a/libs/pandares/src/main/res/values-nl/strings.xml +++ b/libs/pandares/src/main/res/values-nl/strings.xml @@ -68,6 +68,43 @@ Ontbrekend Beoordeeld + Herinnering + Voeg herinneringsmeldingen voor inleverdatum over deze opdracht toe op dit apparaat. + Herinnering toevoegen + Herinnering verwijderen + %s vóór + + 1 minuut + %d minuten + + + 1 uur + %d uur + + + 1 dag + %d dagen + + + 1 week + %d weken + + Aangepast + Aangepaste herinnering + Hoeveelheid + Minuten vóór + Uur vóór + Dagen vóór + Weken vóór + Herinneringsmeldingen + Canvas-meldingen voor opdrachtherinneringen. + Herinnering voor inleverdatum + Deze opdracht moet uiterlijk worden ingeleverd op %s %s + Kies een toekomstige tijd voor je herinnering! + Je hebt al een herinnering voor deze tijd ingesteld + Herinnering verwijderen + Weet je zeker dat je deze herinnering wilt verwijderen? + Je moet exacte alarmmachtiging inschakelen voor deze actie Geen voorbeeld beschikbaar voor URL\'s die \'http://\' gebruiken Voer een geldige URL in diff --git a/libs/pandares/src/main/res/values-pl/strings.xml b/libs/pandares/src/main/res/values-pl/strings.xml index baf62b0691..3506aef4af 100644 --- a/libs/pandares/src/main/res/values-pl/strings.xml +++ b/libs/pandares/src/main/res/values-pl/strings.xml @@ -70,6 +70,51 @@ Brak Oceniono + Przypomnienie + Dodaj przypomnienie o terminie dla tego zadania, na tym urządzeniu. + Dodaj przypomnienie + Usuń przypomnienie + %s do + + 1 min + %d min + %d min + %d min + + + 1 godz. + %d godz. + %d godz. + %d godz. + + + 1 dzień + %d dni + %d dni + %d dni + + + 1 tyg. + %d tyg. + %d tyg. + %d tyg. + + Niestandardowe + Niestandardowe przypomnienie + Ilość + min. do + godz. do + dni do + tyg. do + Przypomnienia + Powiadomienia Canvas dotyczące przypomnień o zadaniach. + Przypomnienie o terminie + Termin przesłania tego zadania upłynie za %s: %s + Wybierz przyszły termin dla przypomnienia! + Ustawiono już przypomnienie dla tego terminu + Usuń przypomnienie + Czy na pewno chcesz usunąć to przypomnienie? + Aby użyć tego działania, należy włączyć uprawnienia alertów. Brak podglądu dla adresu URL z użyciem \'http://\' Wpisz prawidłowy adres URL diff --git a/libs/pandares/src/main/res/values-pt-rBR/strings.xml b/libs/pandares/src/main/res/values-pt-rBR/strings.xml index b9828af155..fc76fee792 100644 --- a/libs/pandares/src/main/res/values-pt-rBR/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rBR/strings.xml @@ -68,6 +68,43 @@ Faltante Avaliado + Lembrete + Adicione notificações de lembrete de data de vencimento sobre essa tarefa nesse dispositivo. + Adicionar lembrete + Remover lembrete + %s antes + + 1 Minuto + %d Minutos + + + 1 Hora + %d Horas + + + 1 dia + %d dias + + + 1 semana + %d semanas + + Personalizar + Lembrete personalizado + Quantidade + Minutos antes + Horas antes + Dias antes + Semanas antes + Notificações de lembrete + Notificações do Canvas para lembretes de tarefas. + Lembrete de data de vencimento + Esta tarefa deve ser entregue em %s: %s + Escolha um horário futuro para seu lembrete! + Você já definiu um lembrete para esse horário + Excluir lembrete + Tem certeza de que deseja excluir esse lembrete? + Você precisa ativar a permissão exata de alarme para essa ação Nenhuma pré-visualização disponível para URLs usando \'http://\' Por favor, insira uma URL válida diff --git a/libs/pandares/src/main/res/values-pt-rPT/strings.xml b/libs/pandares/src/main/res/values-pt-rPT/strings.xml index 426b1051aa..7bad8df237 100644 --- a/libs/pandares/src/main/res/values-pt-rPT/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rPT/strings.xml @@ -68,6 +68,43 @@ Em falta Classificado + Lembrete + Adicionar notificações de lembrete de data de vencimento sobre este trabalho neste dispositivo. + Adicionar lembrete + Remover lembrete + %s Antes de + + 1 minuto + %d minutos + + + 1 hora + %d horas + + + 1 dia + %d Dias + + + 1 Semana + %d Semanas + + Personalizado + Lembrete personalizado + Quantidade + Minutos antes + Horas antes + Dias antes + Semanas antes + Notificações de lembrete + Notificações do Canvas para lembretes de tarefas. + Lembrete de data de entrega + Esta tarefa deve ser entregue em %s: %s + Escolha uma hora futura para o seu lembrete! + Já definiu um lembrete para esta hora + Eliminar lembrete + Tem a certeza de que pretende apagar este lembrete? + É necessário ativar a permissão de alarme exato para esta ação Nenhuma visualização disponível para URLs usando \'http://\' Por favor, insira um URL válido diff --git a/libs/pandares/src/main/res/values-ru/strings.xml b/libs/pandares/src/main/res/values-ru/strings.xml index 7fb0f883b7..0dd4d9f812 100644 --- a/libs/pandares/src/main/res/values-ru/strings.xml +++ b/libs/pandares/src/main/res/values-ru/strings.xml @@ -70,6 +70,51 @@ Отсутствует С оценкой + Напоминание + Добавьте уведомления с напоминанием о сроках выполнения этого задания на этом устройстве. + Добавить напоминание + Удалить напоминание + %s До + + 1 минута + %d минут + %d минут + %d минут + + + 1 час + %d часов + %d часов + %d часов + + + 1 день + %d дней + %d дней + %d дней + + + 1 неделя + %d недели (недель) + %d недели (недель) + %d недели (недель) + + Пользовательская + Пользовательское напоминание + Количество + Минут(ы) до + Часа(ов) до + Дня(ей) до + Недель(и) до + Уведомления с напоминанием + Уведомления Canvas для напоминаний о тестировании. + Напоминание о сроке выполнения + Данное задание необходимо выполнить через %s: %s + Выберите время в будущем для напоминания! + Вы уже установили напоминание на это время + Удалить напоминание + Вы действительно хотите удалить это напоминание? + Для этого действия необходимо включить разрешение на точное оповещение Предпросмотр для URL-адресов с использованием \'http://\' недоступен Введите действительный URL-адрес diff --git a/libs/pandares/src/main/res/values-sl/strings.xml b/libs/pandares/src/main/res/values-sl/strings.xml index ce64f76177..d59c7cf222 100644 --- a/libs/pandares/src/main/res/values-sl/strings.xml +++ b/libs/pandares/src/main/res/values-sl/strings.xml @@ -68,6 +68,43 @@ Manjkajoče Ocenjeno + Opomnik + Dodajte obvestila za opomnik o roku za to nalogo na tej napravi. + Dodaj opomnik + Odstrani opomnik + %s pred + + 1 minuta + %d minut + + + 1 ura + %d ur + + + 1 dan + %d dni(-evi) + + + 1 teden + %d tednov + + Po meri + Opomnik po meri + Količina + Minut pred + Ur pred + Dni pred + Tednov pred + Obvestila za opomnik + Obvestila Canvas za opomnike za naloge + Opomnik za rok + Ta naloga ima rok čez %s: %s + Za opomnik izberite čas v prihodnosti! + Za ta čas ste že nastavili opomnik + Odstrani opomnik + Ali ste prepričani, da želite ta opomnik odstraniti? + Za to dejanje morate omogočiti natančno dovoljenje za alarm Za naslove URL, ki uporabljajo »http://«, predogled ni na voljo. Vnesite veljaven naslov URL. diff --git a/libs/pandares/src/main/res/values-sv/strings.xml b/libs/pandares/src/main/res/values-sv/strings.xml index def34c123a..19363df17a 100644 --- a/libs/pandares/src/main/res/values-sv/strings.xml +++ b/libs/pandares/src/main/res/values-sv/strings.xml @@ -68,6 +68,43 @@ Saknas Har bedömts + Påminnelse + Lägg till påminnelse om inlämningsdatum om denna uppgift på denna enhet. + Lägg till påminnelse + Ta bort påminnelse + %s Före + + 1 minut + %d minuter + + + 1 timme + %d timmar + + + 1 dag + %d dagar + + + 1 vecka + %d veckor + + Anpassa + Anpassad påminnelse + Kvantitet + Minuter före + Timmar före + Dagar före + Veckor före + Påminnelser + Canvas-meddelanden för påminnelser om uppgifter + Påminnelse om inlämning + Denna uppgift ska lämnas in %s: %s + Välj en tid i framtiden för din påminnelse! + Du har redan ställt in en påminnelse för den här tiden + Radera påminnelse + Är du säker på att du vill radera den här påminnelsen? + Du måste aktivera behörigheten för exakt larm för den här åtgärden. Ingen förhandsgranskning är tillgänglig för URL:er som använder \'http://\' Ange en giltig URL diff --git a/libs/pandares/src/main/res/values-th/strings.xml b/libs/pandares/src/main/res/values-th/strings.xml index df2e36e324..a3282f2149 100644 --- a/libs/pandares/src/main/res/values-th/strings.xml +++ b/libs/pandares/src/main/res/values-th/strings.xml @@ -68,6 +68,43 @@ ขาดหาย ให้เกรดแล้ว + แจ้งเตือน + เพิ่มการแจ้งเตือนครบกำหนดเกี่ยวกับภารกิจในอุปกรณ์นี้ + เพิ่มการแจ้งเตือน + ลบการแจ้งเตือน + %s ก่อน + + 1 นาที + %d นาที + + + 1 ชั่วโมง + %d ชั่วโมง + + + 1 วัน + %d วัน + + + 1 สัปดาห์ + %d สัปดาห์ + + กำหนดเอง + การแจ้งเตือนกำหนดเอง + จำนวน + นาทีก่อนหน้า + ชั่วโมงก่อนหน้า + วันก่อนหน้า + สัปดาห์ก่อนหน้า + การแจ้งเตือน + การแจ้งข้อมูลจาก Canvas สำหรับการแจ้งเตือนภารกิจ + แจ้งเตือนวันครบกำหนด + ภารกิจนี้ครบกำหนดใน %s %s + กรุณาเลือกเวลาในอนาคตสำหรับการแจ้งเตือนของคุณ! + คุณกำหนดการแจ้งเตือนสำหรับเวลานี้ไปแล้ว + ลบการแจ้งเตือน + แน่ใจว่าต้องการลบการแจ้งเตือนนี้หรือไม่ + คุณจะต้องเปิดใช้สิทธิ์การแจ้งเตือนที่เจาะจงสำหรับการดำเนินการนี้ ไม่มีการแสดงตัวอย่างสำหรับ URL ที่ใช้ ‘http://’ กรุณากรอก URL ที่ถูกต้อง diff --git a/libs/pandares/src/main/res/values-vi/strings.xml b/libs/pandares/src/main/res/values-vi/strings.xml index f7cdda048f..e73a2afbb2 100644 --- a/libs/pandares/src/main/res/values-vi/strings.xml +++ b/libs/pandares/src/main/res/values-vi/strings.xml @@ -68,6 +68,43 @@ Bị Thiếu Đã Chấm Điểm + Lời nhắc nhở + Thêm thông báo lời nhắc nhở ngày đến hạn về bài tập này trên thiết bị này. + Thêm lời nhắc nhở + Gỡ lời nhắc nhở + %s Trước + + 1 Phút + %d Phút + + + 1 Giờ + %d Giờ + + + 1 Ngày + %d Ngày + + + 1 Tuần + %d Tuần + + Tùy Chỉnh + Lời Nhắc Nhở Tùy Chỉnh + Số Lượng + Phút Trước + Giờ Trước + Ngày Trước + Tuần Trước + Thông Báo Lời Nhắc Nhở + Thông Báo Canvas cho lời nhắc nhở bài tập. + Lời Nhắc Nhở Ngày Đến Hạn + Bài tập này đến hạn vào %s: %s + Vui lòng chọn thời gian trong tương lai cho lời nhắc nhở của bạn! + Bạn đã thiết lập lời nhắc nhở cho thời gian này + Xóa Lời Nhắc Nhở + Bạn có chắc chắn muốn xóa lời nhắc nhở này không? + Bạn cần bật quyền báo động chính xác cho hành động này Không có mục xem trước có thể sử dụng cho URL sử dụng \"http://\" Vui lòng nhập URL hợp lệ diff --git a/libs/pandares/src/main/res/values-zh/strings.xml b/libs/pandares/src/main/res/values-zh/strings.xml index e8294a5955..0617c4530c 100644 --- a/libs/pandares/src/main/res/values-zh/strings.xml +++ b/libs/pandares/src/main/res/values-zh/strings.xml @@ -67,6 +67,39 @@ 缺失 已评分 + 提醒 + 在本设备上添加关于此作业的截止日期提醒通知。 + 添加提醒 + 删除提醒 + 提前的%s + + %d 分钟 + + + %d 小时 + + + %d 天 + + + %d 周 + + 自定义 + 自定义提醒 + 数量 + 提前的分钟数 + 提前的小时数 + 提前的天数 + 提前的周数 + 提醒通知 + Canvas 作业提醒通知。 + 截止日期提醒 + 此作业在%s后截止:%s + 请选择未来的提醒日期! + 您已经为该时间设置提醒 + 删除提醒 + 是否确实要删除此提醒? + 您需要为此操作启用精确警报许可 使用“http://”的 URL 无可用预览 请输入有效的 URL diff --git a/libs/pandautils/src/main/res/values-ja/strings.xml b/libs/pandautils/src/main/res/values-ja/strings.xml index 14904e4b51..508e963bc4 100644 --- a/libs/pandautils/src/main/res/values-ja/strings.xml +++ b/libs/pandautils/src/main/res/values-ja/strings.xml @@ -154,7 +154,7 @@ 小テストなし すみません、まだ小テストは存在しません。 表示する公開されている小テストはありません。 - アナウンスメントなし + 現在、アナウンスメントはありません まだなにもアナウンスされていません コースなし このアカウントに関連付けられているコースはないようです。今日コースを作成するには、ウェブをご覧ください。 @@ -332,7 +332,7 @@ ロック日をロック解除日より前にすることはできません 課題対象者を空白にすることはできません ~へ割り当てられました - 次から使用可能 + 開始日時 利用できる人 利用可能期間の開始日付 利用可能期間の開始時間 From 067ff3312946e9c42bcb760d73d2ada7e20c07d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Thu, 7 Mar 2024 14:27:26 +0100 Subject: [PATCH 51/52] Version bump --- apps/student/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index c487133e6d..815299e864 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 259 - versionName = '7.1.0' + versionCode = 260 + versionName = '7.2.0' vectorDrawables.useSupportLibrary = true multiDexEnabled = true From 640730867233e4a5ffcb38ee9d66ef7447ac7c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Thu, 7 Mar 2024 15:40:49 +0100 Subject: [PATCH 52/52] stub failing test --- .../java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt index 29efacabea..243bcc03c1 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ScheduleE2ETest.kt @@ -21,7 +21,9 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.ReleaseExclude import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.Stub import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.utils.toApiString @@ -52,6 +54,7 @@ class ScheduleE2ETest : StudentTest() { @JvmField var globalTimeout: Timeout = Timeout.millis(600000) // //TODO: workaround for that sometimes this test is running infinite time because of scrollToElement does not find an element. + @Stub @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.CANVAS_FOR_ELEMENTARY, TestCategory.E2E, SecondaryFeatureCategory.SCHEDULE)

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 Unit) { + MaterialTheme( + typography = typography, + content = content + ) +} + +private val lato = FontFamily( + Font(R.font.lato_regular) +) + +private var typography = Typography( + defaultFontFamily = lato, +) + +fun overrideComposeFonts(@FontRes fontResource: Int) { + val newFont = FontFamily( + Font(fontResource) + ) + + typography = Typography( + defaultFontFamily = newFont, + ) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt index 0ef8b68b92..c9cda529ac 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/typeface/TypefaceBehavior.kt @@ -18,9 +18,9 @@ package com.instructure.pandautils.typeface import android.content.Context import android.graphics.Typeface -import android.graphics.fonts.FontFamily +import com.instructure.pandautils.compose.overrideComposeFonts +import com.instructure.pandautils.utils.CanvasFont import java.lang.reflect.Field -import java.lang.reflect.Type const val REGULAR_FONT_KEY = "sans-serif" const val MEDIUM_FONT_KEY = "sans-serif-medium" @@ -29,9 +29,11 @@ class TypefaceBehavior(private val context: Context) { private var fontOverriden = false - fun overrideFont(fontPath: String) { + fun overrideFont(canvasFont: CanvasFont) { if (fontOverriden) return + val fontPath = canvasFont.fontPath + val typefaceMap: Map = mapOf( REGULAR_FONT_KEY to fontPath, MEDIUM_FONT_KEY to fontPath @@ -45,10 +47,13 @@ class TypefaceBehavior(private val context: Context) { val updatedSystemMap = mutableMapOf() updatedSystemMap.putAll(fontMap) staticField.set(null, updatedSystemMap) + + overrideComposeFonts(canvasFont.fontRes) fontOverriden = true } catch (e: Exception) { e.printStackTrace() } + } fun resetFonts() { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FontFamily.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/CanvasFont.kt similarity index 75% rename from libs/pandautils/src/main/java/com/instructure/pandautils/utils/FontFamily.kt rename to libs/pandautils/src/main/java/com/instructure/pandautils/utils/CanvasFont.kt index 711f50fde2..c5f6c640b6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FontFamily.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/CanvasFont.kt @@ -16,7 +16,9 @@ package com.instructure.pandautils.utils -enum class FontFamily(val fontPath: String) { - REGULAR("fonts/lato_regular.ttf"), - K5("fonts/balsamiq_regular.ttf") +import com.instructure.pandautils.R + +enum class CanvasFont(val fontPath: String, val fontRes: Int) { + REGULAR("fonts/lato_regular.ttf", R.font.lato_regular), + K5("fonts/balsamiq_regular.ttf", R.font.balsamiq_regular) } \ No newline at end of file From 565ffe6f1274b25f7b199392300637d598142b6e Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 9 Feb 2024 13:01:12 +0100 Subject: [PATCH 21/52] [MBL-17371][Parent] Grades page displays letter grade with no percentage refs: MBL-17371 affects: Parent release note: Added percentage to course grade. --- .../lib/screens/courses/courses_screen.dart | 7 ++- .../courses/details/course_grades_screen.dart | 13 +++-- .../courses/course_grades_screen_test.dart | 51 ++++++++++++++++++- .../screens/courses/courses_screen_test.dart | 43 +++++++++++++++- 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/apps/flutter_parent/lib/screens/courses/courses_screen.dart b/apps/flutter_parent/lib/screens/courses/courses_screen.dart index c3484287c7..36ab053186 100644 --- a/apps/flutter_parent/lib/screens/courses/courses_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/courses_screen.dart @@ -130,11 +130,14 @@ class _CoursesScreenState extends State { // If there is no current grade, return 'No grade' // Otherwise, we have a grade, so check if we have the actual grade string // or a score + var formattedScore = (grade.currentScore() != null && !(course.settings?.restrictQuantitativeData ?? false)) + ? format.format(grade.currentScore()! / 100) + : ''; var text = grade.noCurrentGrade() ? L10n(context).noGrade : grade.currentGrade()?.isNotEmpty == true - ? grade.currentGrade()! - : format.format(grade.currentScore()! / 100); + ? "${grade.currentGrade()}${formattedScore.isNotEmpty ? ' $formattedScore' : ''}" + : formattedScore; return Text( text, diff --git a/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart b/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart index 97e7c9222e..4163980c2a 100644 --- a/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart @@ -255,22 +255,25 @@ class _CourseGradeHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(L10n(context).courseTotalGradeLabel, style: textTheme.bodyMedium), - Text(_courseGrade(context, grade), style: textTheme.bodyMedium, key: Key("total_grade")), + Text(_courseGrade(context, grade, model.courseSettings?.restrictQuantitativeData ?? false), style: textTheme.bodyMedium, key: Key("total_grade")), ], ), ); } - String _courseGrade(BuildContext context, CourseGrade grade) { + String _courseGrade(BuildContext context, CourseGrade grade, bool restrictQuantitativeData) { final format = NumberFormat.percentPattern(); format.maximumFractionDigits = 2; if (grade.noCurrentGrade()) { return L10n(context).noGrade; } else { + var formattedScore = (grade.currentScore() != null && restrictQuantitativeData == false) + ? format.format(grade.currentScore()! / 100) + : ''; return grade.currentGrade()?.isNotEmpty == true - ? grade.currentGrade()! - : format.format(grade.currentScore()! / 100); // format multiplies by 100 for percentages + ? "${grade.currentGrade()}${formattedScore.isNotEmpty ? ' $formattedScore' : ''}" + : formattedScore; } } } @@ -375,7 +378,7 @@ class _AssignmentRow extends StatelessWidget { final submission = assignment.submission(studentId); - final restrictQuantitativeData = course?.settings?.restrictQuantitativeData ?? false; + final restrictQuantitativeData = course.settings?.restrictQuantitativeData ?? false; if (submission?.excused ?? false) { text = restrictQuantitativeData diff --git a/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart b/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart index 7ef1f02a8a..9ece106217 100644 --- a/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart +++ b/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart @@ -427,6 +427,30 @@ void main() { expect(find.text(grade), findsOneWidget); }); + testWidgetsWithAccessibilityChecks('from current grade and score', (tester) async { + final grade = 'Big fat F'; + final score = 15.15; + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentScore: score, currentGrade: grade)); + final model = CourseDetailsModel(_student, _courseId); + model.course = _mockCourse(); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => groups); + when(interactor.loadGradingPeriods(_courseId)).thenAnswer((_) async => + GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of([]).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)) + .thenAnswer((_) async => [enrollment]); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + expect(find.text("$grade $score%"), findsOneWidget); + }); + testWidgetsWithAccessibilityChecks('is not shown when locked', (tester) async { final groups = [ _mockAssignmentGroup(assignments: [_mockAssignment()]) @@ -497,6 +521,31 @@ void main() { expect(find.text(grade), findsOneWidget); }); + testWidgetsWithAccessibilityChecks('only grade is shown when restricted', (tester) async { + final grade = 'Big fat F'; + final score = 15.15; + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentScore: score, currentGrade: grade)); + final model = CourseDetailsModel(_student, _courseId); + model.course = _mockCourse(); + model.courseSettings = CourseSettings((b) => b..restrictQuantitativeData = true); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => groups); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of([]).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + // Verify that we are showing the course grade when restricted + expect(find.text(grade), findsOneWidget); + }); + testWidgetsWithAccessibilityChecks('is shown when looking at a grading period', (tester) async { final groups = [ _mockAssignmentGroup(assignments: [_mockAssignment()]) @@ -780,7 +829,7 @@ Course _mockCourse() { GradeBuilder _mockGrade({double? currentScore, double? finalScore, String? currentGrade, String? finalGrade}) { return GradeBuilder() ..htmlUrl = '' - ..currentScore = currentScore ?? 0 + ..currentScore = currentScore ..finalScore = finalScore ?? 0 ..currentGrade = currentGrade ?? '' ..finalGrade = finalGrade ?? ''; diff --git a/apps/flutter_parent/test/screens/courses/courses_screen_test.dart b/apps/flutter_parent/test/screens/courses/courses_screen_test.dart index 864d68f7de..6dde78c2a0 100644 --- a/apps/flutter_parent/test/screens/courses/courses_screen_test.dart +++ b/apps/flutter_parent/test/screens/courses/courses_screen_test.dart @@ -41,7 +41,6 @@ import 'package:provider/provider.dart'; import '../../utils/accessibility_utils.dart'; import '../../utils/platform_config.dart'; import '../../utils/test_app.dart'; -import '../../utils/test_helpers/mock_helpers.dart'; import '../../utils/test_helpers/mock_helpers.mocks.dart'; void main() { @@ -174,6 +173,48 @@ void main() { expect(gradeWidget, findsNWidgets(courses.length)); }); + testWidgetsWithAccessibilityChecks('shows grade and score if there is a current grade and score', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentGrade: 'A', computedCurrentScore: 75)], + ), + ), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('A 75%'); + expect(gradeWidget, findsNWidgets(courses.length)); + }); + + testWidgetsWithAccessibilityChecks('shows grade only if there is a current grade and score and restricted', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentGrade: 'A', computedCurrentScore: 75)], + ), + ).rebuild((b) => b..settings = (b.settings..restrictQuantitativeData = true)), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('A'); + expect(gradeWidget, findsNWidgets(courses.length)); + }); + testWidgetsWithAccessibilityChecks('shows score if there is a grade but no grade string', (tester) async { var student = _mockStudent('1'); var courses = List.generate( From 2f2191b1a4a6d39f145f878a42d1e72217b6c08d Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Mon, 12 Feb 2024 15:14:44 +0100 Subject: [PATCH 22/52] [MBL-17238][All] Auto verify wildcard deeplinks (#2338) refs: MBL-17238 affects: All release note: none * Added autoVerify * Try fixed link autoVerify. * Parent autoVerify --- .../android/app/src/main/AndroidManifest.xml | 2 +- apps/student/src/main/AndroidManifest.xml | 3 ++- apps/teacher/src/main/AndroidManifest.xml | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/flutter_parent/android/app/src/main/AndroidManifest.xml b/apps/flutter_parent/android/app/src/main/AndroidManifest.xml index 7968bc38fe..92cc44acbe 100644 --- a/apps/flutter_parent/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter_parent/android/app/src/main/AndroidManifest.xml @@ -37,7 +37,7 @@ - + diff --git a/apps/student/src/main/AndroidManifest.xml b/apps/student/src/main/AndroidManifest.xml index ec13a71e53..121b471855 100644 --- a/apps/student/src/main/AndroidManifest.xml +++ b/apps/student/src/main/AndroidManifest.xml @@ -145,7 +145,8 @@ android:label="@string/student_app_name" android:configChanges="keyboardHidden|orientation" android:exported="true"> - + diff --git a/apps/teacher/src/main/AndroidManifest.xml b/apps/teacher/src/main/AndroidManifest.xml index 8ce6e5605d..69124c6abb 100644 --- a/apps/teacher/src/main/AndroidManifest.xml +++ b/apps/teacher/src/main/AndroidManifest.xml @@ -97,7 +97,7 @@ android:noHistory="true" android:theme="@style/LoginFlowTheme.Splash_Teacher" android:exported="true"> - + @@ -107,7 +107,7 @@ android:host="*.instructure.com" android:scheme="https" /> - + @@ -117,7 +117,7 @@ android:host="*.instructure.com" android:scheme="http" /> - + @@ -127,7 +127,7 @@ android:host="*.canvas.net" android:scheme="https" /> - + From c09dc139feebf8fe8dc4099727367a35a020f7db Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Tue, 13 Feb 2024 10:29:58 +0100 Subject: [PATCH 23/52] [MBL-17368][Student] Dashboard reorder issues #2340 refs: MBL-17368 affects: Student release note: Fixed a crash while reordering the dashboard cards. --- .../student/activity/NavigationActivity.kt | 3 +++ .../student/fragment/DashboardFragment.kt | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index e5be625fff..59da6b00bd 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -132,6 +132,7 @@ import com.instructure.student.flutterChannels.FlutterComm import com.instructure.student.fragment.BookmarksFragment import com.instructure.student.fragment.CalendarEventFragment import com.instructure.student.fragment.CalendarFragment +import com.instructure.student.fragment.DashboardFragment import com.instructure.student.fragment.InboxComposeMessageFragment import com.instructure.student.fragment.InboxConversationFragment import com.instructure.student.fragment.InboxRecipientsFragment @@ -957,6 +958,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. private fun selectBottomNavFragment(fragmentClass: Class) { val selectedFragment = supportFragmentManager.findFragmentByTag(fragmentClass.name) + (topFragment as? DashboardFragment)?.cancelCardDrag() + if (selectedFragment == null) { val fragment = createBottomNavFragment(fragmentClass.name) val newArguments = if (fragment?.arguments != null) fragment.requireArguments() else Bundle() diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt index b599ab9883..2ba2396090 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt @@ -25,6 +25,8 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL import android.view.View import android.view.ViewGroup import androidx.lifecycle.lifecycleScope @@ -36,6 +38,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo.State import androidx.work.WorkManager import androidx.work.WorkQuery +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.managers.CourseNicknameManager import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.models.* @@ -99,6 +102,9 @@ class DashboardFragment : ParentFragment() { @Inject lateinit var workManager: WorkManager + @Inject + lateinit var firebaseCrashlytics: FirebaseCrashlytics + private val binding by viewBinding(FragmentCourseGridBinding::bind) private lateinit var recyclerBinding: CourseGridRecyclerRefreshLayoutBinding @@ -357,6 +363,10 @@ class DashboardFragment : ParentFragment() { addItemTouchHelperForCardReorder() } + fun cancelCardDrag() { + recyclerBinding.listView.onTouchEvent(MotionEvent.obtain(0L, 0L, ACTION_CANCEL, 0f, 0f, 0)) + } + private fun addItemTouchHelperForCardReorder() { val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.START or ItemTouchHelper.END or ItemTouchHelper.DOWN or ItemTouchHelper.UP, @@ -414,10 +424,17 @@ class DashboardFragment : ParentFragment() { ) { val finishingPosition = viewHolder.bindingAdapterPosition + if (finishingPosition == RecyclerView.NO_POSITION) { + itemToMove = null + firebaseCrashlytics.recordException(Throwable("Failed to reorder dashboard. finishingPosition == RecyclerView.NO_POSITION")) + toast(R.string.failedToUpdateDashboardOrder) + return + } + itemToMove?.let { recyclerAdapter?.moveItems(DashboardRecyclerAdapter.ItemType.COURSE_HEADER, it, finishingPosition - 1) recyclerAdapter?.notifyDataSetChanged() - itemToMove == null + itemToMove = null } val courseItems = recyclerAdapter?.getItems(DashboardRecyclerAdapter.ItemType.COURSE_HEADER) From 2c9b6b76eeb1b371b4cc37cb3b5118a39502c55a Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Tue, 13 Feb 2024 15:53:34 +0100 Subject: [PATCH 24/52] [MBL-17327][Student][Teacher] Embedded media content prompts for authentication when logged in via QR login (#2337) refs: MBL-17327 affects: Student, Teacher release note: Fixed a bug where embedded media prompts for authentication after QR login. * Added dummy webview for QR login to authenticate webviews. * Teacher * Fixed teacher issue when prompting for login after killing the app and reopening. --- .../student/activity/InterwebsToApplication.kt | 11 ++++++++++- .../res/layout/interwebs_to_application.xml | 6 ++++++ .../activities/RouteValidatorActivity.kt | 17 +++++++++++++++-- .../features/login/TeacherLoginNavigation.kt | 3 +++ .../res/layout/activity_route_validator.xml | 6 ++++++ .../pandautils/binding/ViewBindingDelegate.kt | 7 +++++++ 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt b/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt index 4794912e88..75b0f78651 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt @@ -27,11 +27,13 @@ import android.view.View import android.view.Window import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import com.instructure.canvasapi2.managers.OAuthManager import com.instructure.canvasapi2.models.AccountDomain import com.instructure.canvasapi2.utils.Analytics import com.instructure.canvasapi2.utils.AnalyticsEventConstants import com.instructure.canvasapi2.utils.AnalyticsParamConstants import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.weave.apiAsync import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.loginapi.login.tasks.LogoutTask @@ -74,7 +76,7 @@ class InterwebsToApplication : AppCompatActivity() { public override fun onCreate(savedInstanceState: Bundle?) { requestWindowFeature(Window.FEATURE_NO_TITLE) super.onCreate(savedInstanceState) - setContentView(R.layout.interwebs_to_application) + setContentView(binding.root) loadingBinding = LoadingCanvasViewBinding.bind(binding.root) loadingBinding.loadingRoute.visibility = View.VISIBLE @@ -127,6 +129,13 @@ class InterwebsToApplication : AppCompatActivity() { val tokenResponse = performSSOLogin(data, this@InterwebsToApplication) + val authResult = apiAsync { OAuthManager.getAuthenticatedSession(ApiPrefs.fullDomain, it) }.await() + if (authResult.isSuccess) { + authResult.dataOrNull?.sessionUrl?.let { + binding.dummyWebView.loadUrl(it) + } + } + val canvasForElementary = featureFlagProvider.getCanvasForElementaryFlag() // Add delay for animation and launch Navigation Activity diff --git a/apps/student/src/main/res/layout/interwebs_to_application.xml b/apps/student/src/main/res/layout/interwebs_to_application.xml index 33f24c7765..935d0cf98d 100644 --- a/apps/student/src/main/res/layout/interwebs_to_application.xml +++ b/apps/student/src/main/res/layout/interwebs_to_application.xml @@ -20,6 +20,12 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt index 68c8415fe0..d9e092bdb9 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt @@ -21,11 +21,13 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Handler -import androidx.fragment.app.FragmentActivity import android.view.Window import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import com.instructure.canvasapi2.managers.OAuthManager import com.instructure.canvasapi2.models.AccountDomain import com.instructure.canvasapi2.utils.* +import com.instructure.canvasapi2.utils.weave.apiAsync import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.interactions.router.Route @@ -34,9 +36,11 @@ import com.instructure.interactions.router.RouterParams import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.loginapi.login.util.QRLogin import com.instructure.loginapi.login.util.QRLogin.verifySSOLoginUri +import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.Utils import com.instructure.teacher.R +import com.instructure.teacher.databinding.ActivityRouteValidatorBinding import com.instructure.teacher.fragments.FileListFragment import com.instructure.teacher.router.RouteMatcher import com.instructure.teacher.services.FileDownloadService @@ -45,12 +49,14 @@ import kotlinx.coroutines.Job class RouteValidatorActivity : FragmentActivity() { + private val binding by viewBinding(ActivityRouteValidatorBinding::inflate) + private var routeValidatorJob: Job? = null public override fun onCreate(savedInstanceState: Bundle?) { requestWindowFeature(Window.FEATURE_NO_TITLE) super.onCreate(savedInstanceState) - setContentView(R.layout.activity_route_validator) + setContentView(binding.root) val data: Uri? = intent.data val url: String? = data?.toString() @@ -83,6 +89,13 @@ class RouteValidatorActivity : FragmentActivity() { val tokenResponse = QRLogin.performSSOLogin(data, this@RouteValidatorActivity, true) + val authResult = apiAsync { OAuthManager.getAuthenticatedSession(ApiPrefs.fullDomain, it) }.await() + if (authResult.isSuccess) { + authResult.dataOrNull?.sessionUrl?.let { + binding.dummyWebView.loadUrl(it) + } + } + // If we have a real user, this is a QR code from a masquerading web user val intent = if (tokenResponse.realUser != null && tokenResponse.user != null) { // We need to set the masquerade request to the user (masqueradee), the real user it the admin user currently masquerading diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt index 5bd968dd79..5a0b7c3a98 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherLoginNavigation.kt @@ -17,6 +17,7 @@ package com.instructure.teacher.features.login import android.content.Intent +import android.webkit.CookieManager import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.loginapi.login.LoginNavigation @@ -35,6 +36,8 @@ class TeacherLoginNavigation(private val activity: FragmentActivity) : LoginNavi override fun initMainActivityIntent(): Intent { PushNotificationRegistrationWorker.scheduleJob(activity, ApiPrefs.isMasquerading) + CookieManager.getInstance().flush() + return SplashActivity.createIntent(activity, activity.intent?.extras) } } \ No newline at end of file diff --git a/apps/teacher/src/main/res/layout/activity_route_validator.xml b/apps/teacher/src/main/res/layout/activity_route_validator.xml index ac79f0f7b2..d5922f088f 100644 --- a/apps/teacher/src/main/res/layout/activity_route_validator.xml +++ b/apps/teacher/src/main/res/layout/activity_route_validator.xml @@ -23,6 +23,12 @@ android:background="@color/backgroundLightest" android:orientation="vertical"> + + AppCompatActivity.viewBinding( lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) } + +inline fun FragmentActivity.viewBinding( + crossinline bindingInflater: (LayoutInflater) -> T) = + lazy(LazyThreadSafetyMode.NONE) { + bindingInflater.invoke(layoutInflater) + } From 398f599f6a23ad736e32695a34b9d75eb7848155 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Wed, 14 Feb 2024 12:24:55 +0100 Subject: [PATCH 25/52] [MBL-17325][Student] Unsupported push notifications can be toggled refs: MBL-17325 affects: Student release note: Updated push notification categories. * filtered unsupported push notification categories * fixed tests --- .../NotificationPreferencesViewModel.kt | 4 +- .../PushNotificationPreferencesViewModel.kt | 25 +++- ...ushNotificationPreferencesViewModelTest.kt | 120 ++++++++++++++---- 3 files changed, 124 insertions(+), 25 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt index 31388183c0..aa51da896f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/NotificationPreferencesViewModel.kt @@ -101,7 +101,7 @@ abstract class NotificationPreferencesViewModel ( val categories = hashMapOf>() - for ((categoryName, prefs) in items.groupBy { it.category }) { + for ((categoryName, prefs) in items.filterNotificationPreferences().groupBy { it.category }) { val categoryHelper = categoryHelperMap[categoryName] ?: continue val header = groupHeaderMap[categoryHelper.categoryGroup] ?: continue @@ -131,4 +131,6 @@ abstract class NotificationPreferencesViewModel ( } abstract fun createCategoryItemViewModel(viewData: NotificationCategoryViewData): NotificationCategoryItemViewModel + + open fun List.filterNotificationPreferences(): List = this } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt index ab9603eedc..5ded7c0b6d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModel.kt @@ -20,8 +20,10 @@ import android.content.res.Resources import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.managers.CommunicationChannelsManager import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency -import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency.* +import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency.IMMEDIATELY +import com.instructure.canvasapi2.managers.NotificationPreferencesFrequency.NEVER import com.instructure.canvasapi2.managers.NotificationPreferencesManager +import com.instructure.canvasapi2.models.NotificationPreference import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.pandautils.BR import com.instructure.pandautils.R @@ -48,6 +50,8 @@ class PushNotificationPreferencesViewModel @Inject constructor( return PushNotificationCategoryItemViewModel(viewData, ::toggleNotification) } + override fun List.filterNotificationPreferences() = filter { it.category in ALLOWED_PUSH_NOTIFICATIONS } + private fun toggleNotification(enabled: Boolean, categoryName: String) { viewModelScope.launch { try { @@ -81,4 +85,23 @@ class PushNotificationPreferencesViewModel @Inject constructor( private val Boolean.frequency: NotificationPreferencesFrequency get() = if (this) IMMEDIATELY else NEVER + companion object { + private val ALLOWED_PUSH_NOTIFICATIONS = listOf( + "announcement", + "appointment_availability", + "appointment_cancelations", + "calendar", + "conversation_message", + "course_content", + "discussion_mention", + "reported_reply", + "due_date", + "grading", + "invitation", + "student_appointment_signups", + "submission_comment", + "discussion", + "discussion_entry" + ) + } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt index b0b7b7f04f..512a401470 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/notification/preferences/PushNotificationPreferencesViewModelTest.kt @@ -81,9 +81,9 @@ class PushNotificationPreferencesViewModelTest { val notificationResponse = NotificationPreferenceResponse( notificationPreferences = listOf( NotificationPreference(notification = "notification1", category = "due_date", frequency = "immediately"), - NotificationPreference(notification = "notification2", category = "membership_update", frequency = "immediately"), + NotificationPreference(notification = "notification2", category = "conversation_message", frequency = "immediately"), NotificationPreference(notification = "notification3", category = "discussion", frequency = "never"), - NotificationPreference(notification = "notification4", category = "announcement_created_by_you", frequency = "never") + NotificationPreference(notification = "notification4", category = "announcement", frequency = "never") ) ) @@ -97,7 +97,7 @@ class PushNotificationPreferencesViewModelTest { val data = viewModel.data.value - assertEquals(3, viewModel.data.value?.items?.size) + assertEquals(3, data?.items?.size) //Course Activities val courseActivitiesHeader = data?.items?.get(0) @@ -113,10 +113,10 @@ class PushNotificationPreferencesViewModelTest { assertEquals(1, courseActivitiesItems?.get(0)?.data?.position) assertEquals(true, courseActivitiesItems?.get(0)?.isChecked) - //Announcement Created By You - assertEquals("Announcement Created By You", courseActivitiesItems?.get(1)?.data?.title) - assertEquals("Get notified when you create an announcement and when somebody replies to your announcement.", courseActivitiesItems?.get(1)?.data?.description) - assertEquals(6, courseActivitiesItems?.get(1)?.data?.position) + //Announcement + assertEquals("Announcement", courseActivitiesItems?.get(1)?.data?.title) + assertEquals("Get notified when there is a new announcement in your course.", courseActivitiesItems?.get(1)?.data?.description) + assertEquals(5, courseActivitiesItems?.get(1)?.data?.position) assertEquals(false, courseActivitiesItems?.get(1)?.isChecked) //Discussions @@ -133,19 +133,19 @@ class PushNotificationPreferencesViewModelTest { assertEquals(1, discussionItems?.get(0)?.data?.position) assertEquals(false, discussionItems?.get(0)?.isChecked) - //Groups - val groupsHeader = data?.items?.get(2) - assertEquals("Groups", groupsHeader?.data?.title) - assertEquals(4, groupsHeader?.data?.position) - assertEquals(1, groupsHeader?.itemViewModels?.size) + //Conversations + val conversationsHeader = data?.items?.get(2) + assertEquals("Conversations", conversationsHeader?.data?.title) + assertEquals(2, conversationsHeader?.data?.position) + assertEquals(1, conversationsHeader?.itemViewModels?.size) //Membership update - val groupsItems = groupsHeader?.itemViewModels as? List - assertEquals(1, groupsItems?.size) - assertEquals("Membership Update", groupsItems?.get(0)?.data?.title) - assertEquals("Admin only, pending enrollment activated. Get notified when a group enrollment is accepted or rejected.", groupsItems?.get(0)?.data?.description) - assertEquals(1, groupsItems?.get(0)?.data?.position) - assertEquals(true, groupsItems?.get(0)?.isChecked) + val conversationsItems = conversationsHeader?.itemViewModels as? List + assertEquals(1, conversationsItems?.size) + assertEquals("Conversation Message", conversationsItems?.get(0)?.data?.title) + assertEquals("Get notified when you have a new inbox message.", conversationsItems?.get(0)?.data?.description) + assertEquals(2, conversationsItems?.get(0)?.data?.position) + assertEquals(true, conversationsItems?.get(0)?.isChecked) } @Test @@ -335,6 +335,80 @@ class PushNotificationPreferencesViewModelTest { assertEquals(1, viewModel.data.value?.items?.size) } + @Test + fun `Notification categories filtered correctly`() { + val notificationResponse = NotificationPreferenceResponse( + notificationPreferences = listOf( + NotificationPreference(notification = "notification1", category = "announcement", frequency = "immediately"), + NotificationPreference(notification = "notification2", category = "due_date", frequency = "immediately"), + NotificationPreference(notification = "notification3", category = "course_content", frequency = "immediately"), + NotificationPreference(notification = "notification4", category = "grading_policies", frequency = "immediately"), + NotificationPreference(notification = "notification5", category = "grading", frequency = "immediately"), + NotificationPreference(notification = "notification6", category = "calendar", frequency = "immediately"), + NotificationPreference(notification = "notification7", category = "invitation", frequency = "immediately"), + NotificationPreference(notification = "notification8", category = "registration", frequency = "immediately"), + NotificationPreference(notification = "notification9", category = "discussion", frequency = "immediately"), + NotificationPreference(notification = "notification10", category = "late_grading", frequency = "immediately"), + NotificationPreference(notification = "notification11", category = "submission_comment", frequency = "immediately"), + NotificationPreference(notification = "notification12", category = "summaries", frequency = "immediately"), + NotificationPreference(notification = "notification13", category = "other", frequency = "immediately"), + NotificationPreference(notification = "notification14", category = "reminder", frequency = "immediately"), + NotificationPreference(notification = "notification15", category = "membership_update", frequency = "immediately"), + NotificationPreference(notification = "notification16", category = "discussion_entry", frequency = "immediately"), + NotificationPreference(notification = "notification17", category = "migration", frequency = "immediately"), + NotificationPreference(notification = "notification18", category = "all_submissions", frequency = "immediately"), + NotificationPreference(notification = "notification19", category = "conversation_message", frequency = "immediately"), + NotificationPreference(notification = "notification20", category = "added_to_conversation", frequency = "immediately"), + NotificationPreference(notification = "notification21", category = "alert", frequency = "immediately"), + NotificationPreference(notification = "notification22", category = "student_appointment_signups", frequency = "immediately"), + NotificationPreference(notification = "notification23", category = "appointment_cancelations", frequency = "immediately"), + NotificationPreference(notification = "notification24", category = "appointment_availability", frequency = "immediately"), + NotificationPreference(notification = "notification25", category = "appointment_signups", frequency = "immediately"), + NotificationPreference(notification = "notification26", category = "files", frequency = "immediately"), + NotificationPreference(notification = "notification27", category = "announcement_created_by_you", frequency = "immediately"), + NotificationPreference(notification = "notification28", category = "conversation_created", frequency = "immediately"), + NotificationPreference(notification = "notification29", category = "recording_ready", frequency = "immediately"), + NotificationPreference(notification = "notification30", category = "blueprint", frequency = "immediately"), + NotificationPreference(notification = "notification31", category = "content_link_error", frequency = "immediately"), + NotificationPreference(notification = "notification32", category = "account_notification", frequency = "immediately"), + NotificationPreference(notification = "notification33", category = "discussion_mention", frequency = "immediately"), + NotificationPreference(notification = "notification34", category = "reported_reply", frequency = "immediately") + ) + ) + + every { notificationPreferencesManager.getNotificationPreferencesAsync(any(), any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(notificationResponse) + } + + val viewModel = createViewModel() + + viewModel.data.observe(lifecycleOwner) {} + + val expected = listOf( + "notification2", + "notification3", + "notification1", + "notification5", + "notification7", + "notification11", + "notification9", + "notification16", + "notification19", + "notification22", + "notification23", + "notification24", + "notification6" + ) + + val actual = viewModel.data.value?.items?.flatMap { header -> + header.itemViewModels.map { + it.data.notification + } + } + + assertEquals(expected, actual) + } + private fun createViewModel(): PushNotificationPreferencesViewModel { return PushNotificationPreferencesViewModel(communicationChannelsManager, notificationPreferencesManager, apiPrefs, notificationPreferenceUtils, resources) } @@ -342,15 +416,15 @@ class PushNotificationPreferencesViewModelTest { private fun setupStrings() { every { resources.getString(R.string.notification_pref_due_date) } returns "Due Date" every { resources.getString(R.string.notification_pref_discussion) } returns "Discussion" - every { resources.getString(R.string.notification_pref_announcement_created_by_you) } returns "Announcement Created By You" - every { resources.getString(R.string.notification_pref_membership_update) } returns "Membership Update" + every { resources.getString(R.string.notification_pref_announcement) } returns "Announcement" + every { resources.getString(R.string.notification_pref_conversation_message) } returns "Conversation Message" every { resources.getString(R.string.notification_desc_due_date) } returns "Get notified when an assignment due date changes." - every { resources.getString(R.string.notification_desc_announcement_created_by_you) } returns "Get notified when you create an announcement and when somebody replies to your announcement." + every { resources.getString(R.string.notification_desc_announcement) } returns "Get notified when there is a new announcement in your course." every { resources.getString(R.string.notification_desc_discussion) } returns "Get notified when there’s a new discussion topic in your course." - every { resources.getString(R.string.notification_desc_membership_update) } returns "Admin only, pending enrollment activated. Get notified when a group enrollment is accepted or rejected." + every { resources.getString(R.string.notification_desc_conversation_message) } returns "Get notified when you have a new inbox message." every { resources.getString(R.string.notification_cat_course_activities) } returns "Course Activities" every { resources.getString(R.string.notification_cat_discussions) } returns "Discussions" - every { resources.getString(R.string.notification_cat_groups) } returns "Groups" + every { resources.getString(R.string.notification_cat_conversations) } returns "Conversations" every { resources.getString(R.string.errorOccurred) } returns "An unexpected error occurred." } } \ No newline at end of file From 64c7520cb52bc527d36db629346f754050932772 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Thu, 15 Feb 2024 10:58:43 +0100 Subject: [PATCH 26/52] [MBL-17277][Teacher] Progress screen (#2341) Test plan: Test the bulk update progress screen. See ticket for design. All bulk module operations should show the progress screen except when only a single module is updated. refs: MBL-17277 affects: Teacher release note: none --- .../modules/list/ModuleListEffectHandler.kt | 85 ++- .../features/modules/list/ModuleListModels.kt | 18 +- .../features/modules/list/ModuleListUpdate.kt | 31 + .../modules/list/ui/ModuleListFragment.kt | 21 +- .../modules/list/ui/ModuleListView.kt | 24 +- .../list/ModuleListEffectHandlerTest.kt | 220 ++++++- .../unit/modules/list/ModuleListUpdateTest.kt | 37 ++ buildSrc/src/main/java/GlobalDependencies.kt | 1 + .../canvasapi2/apis/ProgressAPI.kt | 3 + .../instructure/canvasapi2/models/Progress.kt | 2 +- libs/pandares/src/main/res/values/strings.xml | 14 +- libs/pandautils/build.gradle | 1 + .../10.json | 616 ++++++++++++++++++ .../pandautils/compose/CanvasTheme.kt | 28 +- .../pandautils/di/ProgressModule.kt | 46 ++ .../progress/ProgressDialogFragment.kt | 97 +++ .../features/progress/ProgressPreferences.kt | 27 + .../features/progress/ProgressUiState.kt | 43 ++ .../features/progress/ProgressViewModel.kt | 111 ++++ .../progress/composables/ProgressScreen.kt | 227 +++++++ .../room/appdatabase/AppDatabase.kt | 9 +- .../room/appdatabase/AppDatabaseMigrations.kt | 4 + .../appdatabase/daos/ModuleBulkProgressDao.kt | 42 ++ .../entities/ModuleBulkProgressEntity.kt | 33 + .../list/filter/ContextFilterViewModelTest.kt | 4 +- .../progress/ProgressViewModelTest.kt | 170 +++++ 26 files changed, 1866 insertions(+), 48 deletions(-) create mode 100644 libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/10.json create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/di/ProgressModule.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressDialogFragment.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressPreferences.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressUiState.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressViewModel.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/composables/ProgressScreen.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ModuleBulkProgressDao.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ModuleBulkProgressEntity.kt create mode 100644 libs/pandautils/src/test/java/com/instructure/pandautils/features/progress/ProgressViewModelTest.kt diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt index 8c0e9556aa..5b8591919c 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListEffectHandler.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.features.modules.list import com.instructure.canvasapi2.CanvasRestAdapter -import com.instructure.canvasapi2.apis.FileFolderAPI import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.builders.RestParams @@ -26,27 +25,30 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.Progress -import com.instructure.canvasapi2.models.UpdateFileFolder import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.exhaustive import com.instructure.canvasapi2.utils.isValid -import com.instructure.canvasapi2.utils.toApiString import com.instructure.canvasapi2.utils.tryOrNull import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.awaitApiResponse +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity import com.instructure.pandautils.utils.poll import com.instructure.pandautils.utils.retry +import com.instructure.teacher.R import com.instructure.teacher.features.modules.list.ui.ModuleListView import com.instructure.teacher.mobius.common.ui.EffectHandler import kotlinx.coroutines.launch import retrofit2.Response -import java.util.Date class ModuleListEffectHandler( private val moduleApi: ModuleAPI.ModuleInterface, private val progressApi: ProgressAPI.ProgressInterface, + private val progressPreferences: ProgressPreferences, + private val moduleBulkProgressDao: ModuleBulkProgressDao ) : EffectHandler() { override fun accept(effect: ModuleListEffect) { when (effect) { @@ -69,6 +71,7 @@ class ModuleListEffectHandler( is ModuleListEffect.BulkUpdateModules -> bulkUpdateModules( effect.canvasContext, effect.moduleIds, + effect.affectedIds, effect.action, effect.skipContentTags, allModules = effect.allModules @@ -88,6 +91,10 @@ class ModuleListEffectHandler( is ModuleListEffect.UpdateFileModuleItem -> { view?.showUpdateFileDialog(effect.fileId, effect.contentDetails) } + + is ModuleListEffect.BulkUpdateStarted -> { + handleBulkUpdate(effect.progressId, effect.allModules, effect.skipContentTags, effect.action) + } }.exhaustive } @@ -198,6 +205,7 @@ class ModuleListEffectHandler( private fun bulkUpdateModules( canvasContext: CanvasContext, moduleIds: List, + affectedIds: List, action: BulkModuleUpdateAction, skipContentTags: Boolean, async: Boolean = true, @@ -221,20 +229,58 @@ class ModuleListEffectHandler( val bulkUpdateProgress = progress?.progress if (bulkUpdateProgress == null) { consumer.accept(ModuleListEvent.BulkUpdateFailed(skipContentTags)) - return@launch + } else { + moduleBulkProgressDao.insert( + ModuleBulkProgressEntity( + courseId = canvasContext.id, + progressId = bulkUpdateProgress.id, + action = action.toString(), + skipContentTags = skipContentTags, + allModules = allModules, + affectedIds = affectedIds + ) + ) + if (allModules || !skipContentTags) { + showProgressScreen(bulkUpdateProgress.id, skipContentTags, action, allModules) + } + consumer.accept( + ModuleListEvent.BulkUpdateStarted( + canvasContext, + bulkUpdateProgress.id, + allModules, + skipContentTags, + affectedIds, + action + ) + ) } + } + } - val success = trackUpdateProgress(bulkUpdateProgress) + private fun handleBulkUpdate( + progressId: Long, + allModules: Boolean, + skipContentTags: Boolean, + action: BulkModuleUpdateAction + ) { + launch { + val success = trackUpdateProgress(progressId) + moduleBulkProgressDao.deleteById(progressId) if (success) { consumer.accept(ModuleListEvent.BulkUpdateSuccess(skipContentTags, action, allModules)) } else { - consumer.accept(ModuleListEvent.BulkUpdateFailed(skipContentTags)) + if (progressPreferences.cancelledProgressIds.contains(progressId)) { + consumer.accept(ModuleListEvent.BulkUpdateCancelled) + progressPreferences.cancelledProgressIds = progressPreferences.cancelledProgressIds - progressId + } else { + consumer.accept(ModuleListEvent.BulkUpdateFailed(skipContentTags)) + } } } } - private suspend fun trackUpdateProgress(progress: Progress): Boolean { + private suspend fun trackUpdateProgress(progressId: Long): Boolean { val params = RestParams(isForceReadFromNetwork = true) val result = poll(500, maxAttempts = -1, @@ -244,7 +290,7 @@ class ModuleListEffectHandler( block = { var newProgress: Progress? = null retry(initialDelay = 500) { - newProgress = progressApi.getProgress(progress.id.toString(), params).dataOrThrow + newProgress = progressApi.getProgress(progressId.toString(), params).dataOrThrow } newProgress }) @@ -272,4 +318,25 @@ class ModuleListEffectHandler( } ?: consumer.accept(ModuleListEvent.ModuleItemUpdateFailed(itemId)) } } + + private fun showProgressScreen( + progressId: Long, + skipContentTags: Boolean, + action: BulkModuleUpdateAction, + allModules: Boolean + ) { + val title = when { + allModules && skipContentTags -> R.string.allModules + allModules && !skipContentTags -> R.string.allModulesAndItems + !allModules && !skipContentTags -> R.string.selectedModulesAndItems + else -> R.string.selectedModules + } + + val progressTitle = when (action) { + BulkModuleUpdateAction.PUBLISH -> R.string.publishing + BulkModuleUpdateAction.UNPUBLISH -> R.string.unpublishing + } + + view?.showProgressDialog(progressId, title, progressTitle, R.string.moduleBulkUpdateNote) + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt index aa29119aaf..7584ce52a6 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListModels.kt @@ -23,7 +23,6 @@ import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.isValid -import java.util.Date sealed class ModuleListEvent { object PullToRefresh : ModuleListEvent() @@ -51,8 +50,17 @@ sealed class ModuleListEvent { ) : ModuleListEvent() data class BulkUpdateFailed(val skipContentTags: Boolean) : ModuleListEvent() + data class BulkUpdateStarted( + val canvasContext: CanvasContext, + val progressId: Long, + val allModules: Boolean, + val skipContentTags: Boolean, + val affectedIds: List, + val action: BulkModuleUpdateAction + ) : ModuleListEvent() data class UpdateFileModuleItem(val fileId: Long, val contentDetails: ModuleContentDetails) : ModuleListEvent() + object BulkUpdateCancelled : ModuleListEvent() } sealed class ModuleListEffect { @@ -79,6 +87,7 @@ sealed class ModuleListEffect { data class BulkUpdateModules( val canvasContext: CanvasContext, val moduleIds: List, + val affectedIds: List, val action: BulkModuleUpdateAction, val skipContentTags: Boolean, val allModules: Boolean @@ -97,6 +106,13 @@ sealed class ModuleListEffect { val fileId: Long, val contentDetails: ModuleContentDetails ) : ModuleListEffect() + + data class BulkUpdateStarted( + val progressId: Long, + val allModules: Boolean, + val skipContentTags: Boolean, + val action: BulkModuleUpdateAction + ) : ModuleListEffect() } data class ModuleListModel( diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt index ef6ad24612..2945615e33 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ModuleListUpdate.kt @@ -171,6 +171,7 @@ class ModuleListUpdate : UpdateInit { + val newModel = model.copy( + isLoading = true, + modules = emptyList(), + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val effect = ModuleListEffect.LoadNextPage( + newModel.course, + newModel.pageData, + newModel.scrollToItemId + ) + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.updateCancelled) + return Next.next(newModel, setOf(effect, snackbarEffect)) + } + + is ModuleListEvent.BulkUpdateStarted -> { + val newModel = model.copy( + loadingModuleItemIds = model.loadingModuleItemIds + event.affectedIds + ) + val effect = ModuleListEffect.BulkUpdateStarted( + event.progressId, + event.allModules, + event.skipContentTags, + event.action + ) + return Next.next(newModel, setOf(effect)) + } } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt index 94dcc2922f..7c43150dbb 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListFragment.kt @@ -19,13 +19,18 @@ package com.instructure.teacher.features.modules.list.ui import android.os.Bundle +import android.view.View +import androidx.lifecycle.lifecycleScope import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.apis.ProgressAPI import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.withArgs import com.instructure.teacher.features.modules.list.ModuleListEffectHandler import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -37,13 +42,27 @@ class ModuleListFragment : ModuleListMobiusFragment() { @Inject lateinit var progressApi: ProgressAPI.ProgressInterface - override fun makeEffectHandler() = ModuleListEffectHandler(moduleApi, progressApi) + @Inject + lateinit var progressPreferences: ProgressPreferences + + @Inject + lateinit var moduleBulkProgressDao: ModuleBulkProgressDao + + override fun makeEffectHandler() = ModuleListEffectHandler(moduleApi, progressApi, progressPreferences, moduleBulkProgressDao) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) retainInstance = false } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launch { + val progresses = moduleBulkProgressDao.findByCourseId(canvasContext.id) + this@ModuleListFragment.view.bulkUpdateInProgress(progresses) + } + } + companion object { fun makeBundle(course: CanvasContext, scrollToModuleItemId: Long? = null) = Bundle().apply { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt index cb8a93c9e7..7ea36151c0 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt @@ -27,6 +27,8 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.pandarecycler.PaginatedScrollListener +import com.instructure.pandautils.features.progress.ProgressDialogFragment +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.showThemed import com.instructure.teacher.R @@ -243,6 +245,26 @@ class ModuleListView( fun showUpdateFileDialog(fileId: Long, contentDetails: ModuleContentDetails) { val fragment = UpdateFileDialogFragment.newInstance(fileId, contentDetails, course) - fragment.show((context as FragmentActivity).supportFragmentManager, "editFileDialog") + fragment.show((activity as FragmentActivity).supportFragmentManager, "editFileDialog") + } + + fun showProgressDialog( + progressId: Long, + @StringRes title: Int, + @StringRes progressTitle: Int, + @StringRes note: Int? = null + ) { + val fragment = ProgressDialogFragment.newInstance( + progressId, + context.getString(title), + context.getString(progressTitle), + note?.let { context.getString(it) }) + fragment.show((activity as FragmentActivity).supportFragmentManager, "progressDialog") + } + + fun bulkUpdateInProgress(progresses: List) { + progresses.forEach { + consumer?.accept(ModuleListEvent.BulkUpdateStarted(course, it.progressId, it.allModules, it.skipContentTags, it.affectedIds, BulkModuleUpdateAction.valueOf(it.action))) + } } } diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt index 43d75ea9a1..c49520ad71 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListEffectHandlerTest.kt @@ -24,10 +24,14 @@ import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.Progress +import com.instructure.canvasapi2.models.postmodels.BulkUpdateProgress +import com.instructure.canvasapi2.models.postmodels.BulkUpdateResponse import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.awaitApiResponse +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao import com.instructure.teacher.features.modules.list.BulkModuleUpdateAction import com.instructure.teacher.features.modules.list.CollapsedModulesStore import com.instructure.teacher.features.modules.list.ModuleListEffect @@ -38,6 +42,7 @@ import com.instructure.teacher.features.modules.list.ui.ModuleListView import com.spotify.mobius.Connection import com.spotify.mobius.functions.Consumer import io.mockk.Ordering +import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.confirmVerified import io.mockk.every @@ -53,6 +58,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.test.setMain import okhttp3.Headers.Companion.toHeaders +import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Test @@ -69,15 +75,29 @@ class ModuleListEffectHandlerTest : Assert() { private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) private val progressApi: ProgressAPI.ProgressInterface = mockk(relaxed = true) + private val progressPreferences: ProgressPreferences = mockk(relaxed = true) + private val moduleBulkProgressDao: ModuleBulkProgressDao = mockk(relaxed = true) @ExperimentalCoroutinesApi @Before fun setUp() { Dispatchers.setMain(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) view = mockk(relaxed = true) - effectHandler = ModuleListEffectHandler(moduleApi, progressApi).apply { view = this@ModuleListEffectHandlerTest.view } + effectHandler = + ModuleListEffectHandler(moduleApi, progressApi, progressPreferences, moduleBulkProgressDao).apply { + view = this@ModuleListEffectHandlerTest.view + } consumer = mockk(relaxed = true) connection = effectHandler.connect(consumer) + + every { progressPreferences.cancelledProgressIds } returns mutableSetOf() + every { progressPreferences.cancelledProgressIds = any() } returns Unit + coEvery { moduleBulkProgressDao.findByCourseId(any()) } returns emptyList() + } + + @After + fun teardown() { + clearAllMocks() } @Test @@ -199,7 +219,8 @@ class ModuleListEffectHandlerTest : Assert() { val nextUrl1 = "fake_next_url_1" val nextUrl2 = "fake_next_url_2" val firstPageModules = makeModulePage(pageNumber = 0) - val secondPageModules = listOf(ModuleObject(id = moduleId, itemCount = 1, items = listOf(ModuleItem(scrollToItemId)))) + val secondPageModules = + listOf(ModuleObject(id = moduleId, itemCount = 1, items = listOf(ModuleItem(scrollToItemId)))) val thirdPageModules = makeModulePage(pageNumber = 1) val expectedEvent = ModuleListEvent.PageLoaded( @@ -290,14 +311,36 @@ class ModuleListEffectHandlerTest : Assert() { } @Test - fun `BulkUpdateModules results in correct success event`() { + fun `BulkUpdateStarted results in correct success event`() { val pageModules = makeModulePage() val expectedEvent = ModuleListEvent.BulkUpdateSuccess(false, BulkModuleUpdateAction.PUBLISH, false) - coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Success(mockk(relaxed = true)) - coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(Progress(1L, workflowState = "completed")) + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success( + Progress( + 1L, + workflowState = "completed" + ) + ) - connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false, false)) + connection.accept( + ModuleListEffect.BulkUpdateStarted( + 1L, + false, + false, + BulkModuleUpdateAction.PUBLISH + ) + ) verify(timeout = 1000) { consumer.accept(expectedEvent) } confirmVerified(consumer) @@ -308,23 +351,64 @@ class ModuleListEffectHandlerTest : Assert() { val pageModules = makeModulePage() val expectedEvent = ModuleListEvent.BulkUpdateFailed(false) - coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Fail() - - connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false, false)) + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Fail() + + connection.accept( + ModuleListEffect.BulkUpdateModules( + course, + pageModules.map { it.id }, + pageModules.map { it.id } + pageModules.flatMap { it.items.map { it.id } }, + BulkModuleUpdateAction.PUBLISH, + false, + false + ) + ) verify(timeout = 1000) { consumer.accept(expectedEvent) } confirmVerified(consumer) } @Test - fun `BulkUpdateModules results in correct failed event when progress fails`() { + fun `BulkUpdateStarted results in correct failed event when progress fails`() { val pageModules = makeModulePage() val expectedEvent = ModuleListEvent.BulkUpdateFailed(false) - coEvery { moduleApi.bulkUpdateModules(any(), any(), any(), any(), any(), any(), any()) } returns DataResult.Success(mockk(relaxed = true)) - coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(Progress(1L, workflowState = "failed")) + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success( + Progress( + 1L, + workflowState = "failed" + ) + ) - connection.accept(ModuleListEffect.BulkUpdateModules(course, pageModules.map { it.id }, BulkModuleUpdateAction.PUBLISH, false, false)) + connection.accept( + ModuleListEffect.BulkUpdateStarted( + 1L, + false, + false, + BulkModuleUpdateAction.PUBLISH + ) + ) verify(timeout = 1000) { consumer.accept(expectedEvent) } confirmVerified(consumer) @@ -334,9 +418,14 @@ class ModuleListEffectHandlerTest : Assert() { fun `UpdateModuleItem results in correct success event`() { val moduleId = 1L val itemId = 2L - val expectedEvent = ModuleListEvent.ModuleItemUpdateSuccess(ModuleItem(id = itemId, moduleId = moduleId, published = true), true) + val expectedEvent = ModuleListEvent.ModuleItemUpdateSuccess( + ModuleItem(id = itemId, moduleId = moduleId, published = true), + true + ) - coEvery { moduleApi.publishModuleItem(any(), any(), any(), any(), any(), any()) } returns DataResult.Success(ModuleItem(2L, 1L, published = true)) + coEvery { moduleApi.publishModuleItem(any(), any(), any(), any(), any(), any()) } returns DataResult.Success( + ModuleItem(2L, 1L, published = true) + ) connection.accept(ModuleListEffect.UpdateModuleItem(course, moduleId, itemId, true)) @@ -366,6 +455,95 @@ class ModuleListEffectHandlerTest : Assert() { confirmVerified(view) } + @Test + fun `UpdateFileModuleItem calls showUpdateFileDialog on view`() { + val fileId = 123L + val contentDetails = ModuleContentDetails() + connection.accept(ModuleListEffect.UpdateFileModuleItem(fileId, contentDetails)) + verify(timeout = 100) { view.showUpdateFileDialog(fileId, contentDetails) } + confirmVerified(view) + } + + @Test + fun `Bulk update cancel emits correct event`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateCancelled + + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success( + BulkUpdateResponse(BulkUpdateProgress(Progress(id = 1L))) + ) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success( + Progress( + 1L, + workflowState = "failed" + ) + ) + every { progressPreferences.cancelledProgressIds } returns mutableSetOf(1L) + + connection.accept( + ModuleListEffect.BulkUpdateStarted( + 1L, + false, + false, + BulkModuleUpdateAction.PUBLISH + ) + ) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + @Test + fun `BulkUpdateModules result in correct event`() { + val pageModules = makeModulePage() + val expectedEvent = ModuleListEvent.BulkUpdateStarted( + course, + 0L, + true, + false, + pageModules.map { it.id } + pageModules.flatMap { it.items.map { it.id } }, + BulkModuleUpdateAction.PUBLISH) + + coEvery { + moduleApi.bulkUpdateModules( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + connection.accept( + ModuleListEffect.BulkUpdateModules( + course, + pageModules.map { it.id }, + pageModules.map { it.id } + pageModules.flatMap { it.items.map { it.id } }, + BulkModuleUpdateAction.PUBLISH, + false, + true + ) + ) + + verify(timeout = 1000) { consumer.accept(expectedEvent) } + confirmVerified(consumer) + } + + private fun makeLinkHeader(nextUrl: String) = + mapOf("Link" to """<$nextUrl>; rel="next"""").toHeaders() + private fun makeModulePage( moduleCount: Int = 3, itemsPerModule: Int = 3, @@ -381,16 +559,4 @@ class ModuleListEffectHandlerTest : Assert() { } } - @Test - fun `UpdateFileModuleItem calls showUpdateFileDialog on view`() { - val fileId = 123L - val contentDetails = ModuleContentDetails() - connection.accept(ModuleListEffect.UpdateFileModuleItem(fileId, contentDetails)) - verify(timeout = 100) { view.showUpdateFileDialog(fileId, contentDetails) } - confirmVerified(view) - } - - private fun makeLinkHeader(nextUrl: String) = - mapOf("Link" to """<$nextUrl>; rel="next"""").toHeaders() - } diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt index f097179a80..bd0cc4e2b9 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/modules/list/ModuleListUpdateTest.kt @@ -428,6 +428,7 @@ class ModuleListUpdateTest : Assert() { val expectedEffect = ModuleListEffect.BulkUpdateModules( expectedModel.course, listOf(1L), + listOf(1L), BulkModuleUpdateAction.PUBLISH, true, false @@ -455,6 +456,7 @@ class ModuleListUpdateTest : Assert() { val expectedEffect = ModuleListEffect.BulkUpdateModules( expectedModel.course, listOf(1L), + listOf(1L, 100L), BulkModuleUpdateAction.PUBLISH, false, false @@ -490,6 +492,7 @@ class ModuleListUpdateTest : Assert() { val expectedEffect = ModuleListEffect.BulkUpdateModules( expectedModel.course, listOf(1L, 2L), + listOf(1L, 2L), BulkModuleUpdateAction.UNPUBLISH, true, true @@ -525,6 +528,7 @@ class ModuleListUpdateTest : Assert() { val expectedEffect = ModuleListEffect.BulkUpdateModules( expectedModel.course, listOf(1L, 2L), + listOf(1L, 2L, 100L, 200L, 201L), BulkModuleUpdateAction.UNPUBLISH, false, true @@ -877,5 +881,38 @@ class ModuleListUpdateTest : Assert() { ) } + @Test + fun `BulkUpdateCancelled emits LoadNextPage effect`() { + val model = initModel.copy( + isLoading = false, + pageData = ModuleListPageData(DataResult.Success(emptyList()), false, "fakeUrl"), + modules = listOf( + ModuleObject(1L, items = listOf(ModuleItem(100L))), + ModuleObject(2L, items = listOf(ModuleItem(200L))) + ), + loadingModuleItemIds = setOf(1L) + ) + val expectedModel = initModel.copy( + isLoading = true, + pageData = ModuleListPageData(forceNetwork = true), + loadingModuleItemIds = emptySet() + ) + val expectedEffect = ModuleListEffect.LoadNextPage( + expectedModel.course, + expectedModel.pageData, + expectedModel.scrollToItemId + ) + val snackbarEffect = ModuleListEffect.ShowSnackbar(R.string.updateCancelled) + updateSpec + .given(model) + .whenEvent(ModuleListEvent.BulkUpdateCancelled) + .then( + assertThatNext( + hasModel(expectedModel), + matchesEffects(expectedEffect, snackbarEffect) + ) + ) + } + } diff --git a/buildSrc/src/main/java/GlobalDependencies.kt b/buildSrc/src/main/java/GlobalDependencies.kt index a5ec07d941..7bd1d86c1b 100644 --- a/buildSrc/src/main/java/GlobalDependencies.kt +++ b/buildSrc/src/main/java/GlobalDependencies.kt @@ -168,6 +168,7 @@ object Libs { const val COMPOSE_MATERIAL = "androidx.compose.material:material" const val COMPOSE_PREVIEW = "androidx.compose.ui:ui-tooling-preview" const val COMPOSE_TOOLING = "androidx.compose.ui:ui-tooling" + const val COMPOSE_UI = "androidx.compose.ui:ui-android:1.6.0" const val COMPOSE_VIEW_MODEL = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1" } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt index 6223cc1541..137d1b463b 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/ProgressAPI.kt @@ -32,6 +32,9 @@ object ProgressAPI { @GET("progress/{progressId}") suspend fun getProgress(@Path("progressId") progressId: String, @Tag params: RestParams): DataResult + + @POST("progress/{progressId}/cancel") + suspend fun cancelProgress(@Path("progressId") progressId: String, @Tag params: RestParams): DataResult } fun getProgress(adapter: RestBuilder, params: RestParams, progressId: String, callback: StatusCallback) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt index 6b371e21c6..2116772320 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Progress.kt @@ -33,7 +33,7 @@ data class Progress( @SerializedName("workflow_state") private val workflowState: String = "", // One of 'queued', 'running', 'completed', 'failed' val tag: String = "", // The type of operation - val completion: Long = 0, // Percent completed + val completion: Float = 0f, // Percent completed val message: String? = null // Optional details about the job ) : CanvasModel() { val isQueued: Boolean get() = workflowState == "queued" diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 58c6a53eec..bad4efc593 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1561,7 +1561,7 @@ All Modules and all Items unpublished Inherit from Course Course Members - Institutions Members + Institution Members Public Edit Permissions Update @@ -1575,6 +1575,18 @@ Time Clear From Date Clear Until Date + This process could take a few minutes. You may close the modal or navigate away from the page during this process. + Note + All Modules + All Modules and Items + Selected Modules and Items + Selected Modules + Publishing + Unpublishing + Modules and items that have already been processed will not be reverted to their previous state when the process is discontinued. + Success! + Update failed + Update cancelled %.0f pt %.0f pts diff --git a/libs/pandautils/build.gradle b/libs/pandautils/build.gradle index 47adcae125..4bfea8cb84 100644 --- a/libs/pandautils/build.gradle +++ b/libs/pandautils/build.gradle @@ -210,6 +210,7 @@ dependencies { implementation Libs.COMPOSE_PREVIEW debugImplementation Libs.COMPOSE_TOOLING implementation Libs.COMPOSE_VIEW_MODEL + implementation Libs.COMPOSE_UI implementation Libs.FLEXBOX_LAYOUT diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/10.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/10.json new file mode 100644 index 0000000000..6e46f92cd7 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/10.json @@ -0,0 +1,616 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "175d57d3fa38e37eb607adb69a222821", + "entities": [ + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, `attempt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EnvironmentFeatureFlags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `featureFlags` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "featureFlags", + "columnName": "featureFlags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileUploadInputEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `courseId` INTEGER, `assignmentId` INTEGER, `quizId` INTEGER, `quizQuestionId` INTEGER, `position` INTEGER, `parentFolderId` INTEGER, `action` TEXT NOT NULL, `userId` INTEGER, `attachments` TEXT NOT NULL, `submissionId` INTEGER, `filePaths` TEXT NOT NULL, `attemptId` INTEGER, `notificationId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizQuestionId", + "columnName": "quizQuestionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filePaths", + "columnName": "filePaths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pageId` TEXT NOT NULL, `comment` TEXT, `date` INTEGER NOT NULL, `status` TEXT NOT NULL, `workerId` TEXT, `filePath` TEXT, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardFileUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT, `subtitle` TEXT, `courseId` INTEGER, `assignmentId` INTEGER, `attemptId` INTEGER, `folderId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ReminderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `htmlUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `text` TEXT NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ModuleBulkProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`progressId` INTEGER NOT NULL, `allModules` INTEGER NOT NULL, `skipContentTags` INTEGER NOT NULL, `action` TEXT NOT NULL, `courseId` INTEGER NOT NULL, `affectedIds` TEXT NOT NULL, PRIMARY KEY(`progressId`))", + "fields": [ + { + "fieldPath": "progressId", + "columnName": "progressId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allModules", + "columnName": "allModules", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipContentTags", + "columnName": "skipContentTags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "affectedIds", + "columnName": "affectedIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "progressId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '175d57d3fa38e37eb607adb69a222821')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt index b73739a061..18a4c9c0c3 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt @@ -19,9 +19,16 @@ package com.instructure.pandautils.compose import androidx.annotation.FontRes +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme import androidx.compose.material.Typography +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material.ripple.RippleTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import com.instructure.pandautils.R @@ -29,9 +36,13 @@ import com.instructure.pandautils.R @Composable fun CanvasTheme(content: @Composable () -> Unit) { MaterialTheme( - typography = typography, - content = content - ) + typography = typography + ) { + CompositionLocalProvider( + LocalRippleTheme provides CanvasRippleTheme, + content = content + ) + } } private val lato = FontFamily( @@ -50,4 +61,15 @@ fun overrideComposeFonts(@FontRes fontResource: Int) { typography = Typography( defaultFontFamily = newFont, ) +} + +private object CanvasRippleTheme : RippleTheme { + @Composable + override fun defaultColor(): Color = colorResource(id = R.color.backgroundDark) + + @Composable + override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( + Color.Black, + lightTheme = !isSystemInDarkTheme() + ) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ProgressModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ProgressModule.kt new file mode 100644 index 0000000000..d28a0978f2 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ProgressModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.di + +import com.instructure.pandautils.features.progress.ProgressPreferences +import com.instructure.pandautils.room.appdatabase.AppDatabase +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class ProgressModule { + + @Provides + @Singleton + fun provideProgressPreferences(): ProgressPreferences { + return ProgressPreferences + } + + @Provides + @Singleton + fun provideModuleBulkProgressDao(appDatabase: AppDatabase): ModuleBulkProgressDao { + return appDatabase.moduleBulkProgressDao() + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressDialogFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressDialogFragment.kt new file mode 100644 index 0000000000..0fe9cd0fd2 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressDialogFragment.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.progress + +import android.app.Dialog +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.instructure.pandautils.features.progress.composables.ProgressScreen +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@AndroidEntryPoint +class ProgressDialogFragment : BottomSheetDialogFragment() { + + private val viewModel: ProgressViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setContent { + val uiState by viewModel.uiState.collectAsState() + ProgressScreen(uiState, viewModel::handleAction) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + lifecycleScope.launch { + withContext(Dispatchers.Main.immediate) { + viewModel.events.collect { action -> + when (action) { + is ProgressViewModelAction.Close -> dismiss() + } + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + setOnShowListener { + val bottomSheet = findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.skipCollapsed = true + } + } + } + + companion object { + + const val PROGRESS_ID = "progressId" + const val TITLE = "title" + const val PROGRESS_TITLE = "progressTitle" + const val NOTE = "note" + + fun newInstance(progressId: Long, title: String, progressTitle: String, note: String? = null) = + ProgressDialogFragment().apply { + arguments = Bundle().apply { + putLong(PROGRESS_ID, progressId) + putString(TITLE, title) + putString(PROGRESS_TITLE, progressTitle) + note?.let { putString(NOTE, it) } + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressPreferences.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressPreferences.kt new file mode 100644 index 0000000000..9518f8c4d1 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressPreferences.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.progress + +import com.instructure.canvasapi2.utils.PrefManager +import com.instructure.canvasapi2.utils.SetPref + +object ProgressPreferences : PrefManager("progress-preferences") { + + var cancelledProgressIds by SetPref(Long::class) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressUiState.kt new file mode 100644 index 0000000000..7881113127 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressUiState.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.progress + +data class ProgressUiState( + val title: String, + val progressTitle: String, + val progress: Float, + val note: String?, + val state: ProgressState, +) + +enum class ProgressState { + QUEUED, + RUNNING, + COMPLETED, + FAILED +} + +sealed class ProgressAction { + object Cancel : ProgressAction() + object Close : ProgressAction() +} + +sealed class ProgressViewModelAction { + object Close : ProgressViewModelAction() +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressViewModel.kt new file mode 100644 index 0000000000..3cddb37460 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/ProgressViewModel.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.progress + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.ProgressAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.pandautils.utils.poll +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProgressViewModel @Inject constructor( + stateHandle: SavedStateHandle, + private val progressApi: ProgressAPI.ProgressInterface, + private val progressPreferences: ProgressPreferences +) : ViewModel() { + + private val progressId = stateHandle.get(ProgressDialogFragment.PROGRESS_ID) ?: -1 + private val title = stateHandle.get(ProgressDialogFragment.TITLE) ?: "" + private val progressTitle = stateHandle.get(ProgressDialogFragment.PROGRESS_TITLE) ?: "" + private val note = stateHandle.get(ProgressDialogFragment.NOTE) + + private val _uiState = MutableStateFlow( + ProgressUiState( + title = title, + progressTitle = progressTitle, + progress = 0f, + note = note, + state = ProgressState.QUEUED, + ) + ) + val uiState = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + init { + viewModelScope.launch { + loadData() + } + } + + private suspend fun loadData() { + val params = RestParams(isForceReadFromNetwork = true) + try { + poll(500, -1, block = { + val progress = progressApi.getProgress(progressId.toString(), params).dataOrThrow + val newState = when { + progress.isQueued -> ProgressState.QUEUED + progress.isRunning -> ProgressState.RUNNING + progress.isCompleted -> ProgressState.COMPLETED + progress.isFailed -> ProgressState.FAILED + else -> ProgressState.QUEUED + } + _uiState.emit(_uiState.value.copy(progress = progress.completion, state = newState)) + progress + }, + validate = { + it.hasRun + }) + } catch (e: Exception) { + _uiState.emit(_uiState.value.copy(state = ProgressState.FAILED)) + } + } + + fun handleAction(action: ProgressAction) { + viewModelScope.launch { + when (action) { + is ProgressAction.Cancel -> { + cancel() + } + + is ProgressAction.Close -> { + _events.send(ProgressViewModelAction.Close) + } + } + } + } + + private suspend fun cancel() { + progressPreferences.cancelledProgressIds = progressPreferences.cancelledProgressIds + progressId + val params = RestParams(isForceReadFromNetwork = true) + progressApi.cancelProgress(progressId.toString(), params).dataOrNull + _events.send(ProgressViewModelAction.Close) + } + +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/composables/ProgressScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/composables/ProgressScreen.kt new file mode 100644 index 0000000000..aa0b43c28f --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/progress/composables/ProgressScreen.kt @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.progress.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.instructure.canvasapi2.models.CanvasTheme +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.features.progress.ProgressState +import com.instructure.pandautils.features.progress.ProgressUiState +import com.instructure.pandautils.features.progress.ProgressAction +import kotlin.math.roundToInt + +@Composable +fun ProgressScreen( + progressUiState: ProgressUiState, + actionHandler: (ProgressAction) -> Unit +) { + CanvasTheme { + Scaffold( + modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)), + backgroundColor = colorResource(id = R.color.backgroundLightest), + topBar = { + ProgressTopBar( + title = progressUiState.title, + state = progressUiState.state, + actionHandler = actionHandler + ) + } + ) { padding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(padding), + color = colorResource(id = R.color.backgroundLightest) + ) { + ProgressContent( + progressTitle = progressUiState.progressTitle, + progress = progressUiState.progress, + note = progressUiState.note, + state = progressUiState.state + ) + } + } + } +} + +@Composable +fun ProgressTopBar( + modifier: Modifier = Modifier, + title: String, + state: ProgressState, + actionHandler: (ProgressAction) -> Unit +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { actionHandler(ProgressAction.Close) }) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(id = R.string.a11y_closeProgress), + tint = colorResource(id = R.color.textDarkest) + ) + } + Text( + text = title, + fontSize = 16.sp, + color = colorResource(id = R.color.textDarkest), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.weight(1f)) + if (state == ProgressState.COMPLETED || state == ProgressState.FAILED) { + TextButton( + modifier = Modifier.padding(end = 12.dp), + onClick = { actionHandler(ProgressAction.Close) }) { + Text(text = stringResource(id = R.string.done), color = colorResource(id = R.color.textDarkest)) + } + } else { + TextButton( + modifier = Modifier.padding(end = 12.dp), + onClick = { actionHandler(ProgressAction.Cancel) }) { + Text(text = stringResource(id = R.string.cancel), color = colorResource(id = R.color.textDarkest)) + } + } + } + Divider(color = colorResource(id = R.color.backgroundMedium)) + } + +} + +@Composable +fun ProgressContent( + modifier: Modifier = Modifier, + progressTitle: String, + progress: Float, + note: String?, + state: ProgressState +) { + Column(modifier = modifier) { + ProgressIndicator( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp), + progressTitle = progressTitle, + progress = progress, + state = state + ) + Divider(color = colorResource(id = R.color.backgroundMedium)) + Text( + text = stringResource(R.string.progressMessage), + modifier = Modifier.padding(16.dp), + color = colorResource(id = R.color.textDarkest) + ) + note?.let { + Note(modifier = Modifier.padding(16.dp), note = it) + } + } +} + +@Composable +fun Note(modifier: Modifier = Modifier, note: String) { + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + Text( + text = stringResource(id = R.string.noteTitle), + color = colorResource(id = R.color.textDark), + fontSize = 14.sp + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = note, + color = colorResource(id = R.color.textDarkest), + fontSize = 16.sp + ) + + } +} + +@Composable +fun ProgressIndicator(modifier: Modifier = Modifier, progressTitle: String, progress: Float, state: ProgressState) { + val title = when (state) { + ProgressState.COMPLETED -> stringResource(id = R.string.success) + ProgressState.FAILED -> stringResource(id = R.string.updateFailed) + else -> "$progressTitle ${progress.roundToInt()}%" + } + val progressColor = when (state) { + ProgressState.COMPLETED -> colorResource(id = R.color.backgroundSuccess) + ProgressState.FAILED -> colorResource(id = R.color.backgroundDanger) + else -> colorResource(id = R.color.backgroundInfo) + } + Column( + modifier = modifier + .fillMaxWidth() + ) { + Text( + text = title, + modifier = Modifier.align(Alignment.CenterHorizontally), + fontSize = 14.sp, + color = colorResource(id = R.color.textDarkest) + ) + LinearProgressIndicator( + progress = progress / 100f, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + color = progressColor, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ProgressScreenPreview() { + ProgressScreen( + progressUiState = ProgressUiState( + title = "All modules and items", + progressTitle = "Publishing", + progress = 40f, + note = "Modules and items that have already been processed will not be reverted to their previous state when the process is discontinued.", + state = ProgressState.RUNNING + ), + actionHandler = {} + ) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt index 2dc92b3770..d171a90bd3 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt @@ -9,6 +9,7 @@ import com.instructure.pandautils.room.appdatabase.daos.DashboardFileUploadDao import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao import com.instructure.pandautils.room.appdatabase.daos.FileUploadInputDao import com.instructure.pandautils.room.appdatabase.daos.MediaCommentDao +import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao import com.instructure.pandautils.room.appdatabase.daos.PendingSubmissionCommentDao import com.instructure.pandautils.room.appdatabase.daos.ReminderDao import com.instructure.pandautils.room.appdatabase.daos.SubmissionCommentDao @@ -18,6 +19,7 @@ import com.instructure.pandautils.room.appdatabase.entities.DashboardFileUploadE import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags import com.instructure.pandautils.room.appdatabase.entities.FileUploadInputEntity import com.instructure.pandautils.room.appdatabase.entities.MediaCommentEntity +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity import com.instructure.pandautils.room.appdatabase.entities.PendingSubmissionCommentEntity import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.room.appdatabase.entities.SubmissionCommentEntity @@ -33,8 +35,9 @@ import com.instructure.pandautils.room.common.Converters SubmissionCommentEntity::class, PendingSubmissionCommentEntity::class, DashboardFileUploadEntity::class, - ReminderEntity::class - ], version = 9 + ReminderEntity::class, + ModuleBulkProgressEntity::class + ], version = 10 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -56,4 +59,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun environmentFeatureFlagsDao(): EnvironmentFeatureFlagsDao abstract fun reminderDao(): ReminderDao + + abstract fun moduleBulkProgressDao(): ModuleBulkProgressDao } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt index 707e4f0a0e..8f17252874 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt @@ -57,5 +57,9 @@ val appDatabaseMigrations = arrayOf( createMigration(8, 9) { database -> database.execSQL("CREATE TABLE IF NOT EXISTS ReminderEntity (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, userId INTEGER NOT NULL, assignmentId INTEGER NOT NULL, htmlUrl TEXT NOT NULL, name TEXT NOT NULL, text TEXT NOT NULL, time INTEGER NOT NULL)") + }, + + createMigration(9, 10) { database -> + database.execSQL("CREATE TABLE IF NOT EXISTS `ModuleBulkProgressEntity` (`progressId` INTEGER NOT NULL, `allModules` INTEGER NOT NULL, `skipContentTags` INTEGER NOT NULL, `action` TEXT NOT NULL, `courseId` INTEGER NOT NULL, `affectedIds` TEXT NOT NULL, PRIMARY KEY(`progressId`))") } ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ModuleBulkProgressDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ModuleBulkProgressDao.kt new file mode 100644 index 0000000000..83f7c7e623 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ModuleBulkProgressDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.daos + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Upsert +import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity + +@Dao +interface ModuleBulkProgressDao { + + @Upsert + suspend fun insert(moduleBulkProgressEntity: ModuleBulkProgressEntity) + + @Delete + suspend fun delete(moduleBulkProgressEntity: ModuleBulkProgressEntity) + + @Query("SELECT * FROM ModuleBulkProgressEntity WHERE courseId = :courseId") + suspend fun findByCourseId(courseId: Long): List + + @Query("DELETE FROM ModuleBulkProgressEntity WHERE progressId = :progressId") + suspend fun deleteById(progressId: Long) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ModuleBulkProgressEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ModuleBulkProgressEntity.kt new file mode 100644 index 0000000000..177dfdd16a --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ModuleBulkProgressEntity.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class ModuleBulkProgressEntity( + @PrimaryKey + val progressId: Long, + val allModules: Boolean, + val skipContentTags: Boolean, + val action: String, + val courseId: Long, + val affectedIds: List +) \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt index 087abfd2fe..308ff10317 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/list/filter/ContextFilterViewModelTest.kt @@ -80,12 +80,12 @@ class ContextFilterViewModelTest { @Test fun `Clicking items sends event with id`() { - val canvasContext = listOf(Course(id = 1, name = "Course")) + val canvasContext = listOf(Course(id = 1L, name = "Course")) viewModel.setFilterItems(canvasContext) val listItems = viewModel.itemViewModels.value ?: emptyList() (listItems[1] as ContextFilterItemViewModel).onClicked() - assertEquals(1, viewModel.events.value!!.peekContent()) + assertEquals(1L, viewModel.events.value!!.peekContent()) } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/progress/ProgressViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/progress/ProgressViewModelTest.kt new file mode 100644 index 0000000000..b38adb38d1 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/progress/ProgressViewModelTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.progress + +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.ProgressAPI +import com.instructure.canvasapi2.models.Progress +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class ProgressViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()) + + private val stateHandle: SavedStateHandle = mockk(relaxed = true) + private val progressApi: ProgressAPI.ProgressInterface = mockk(relaxed = true) + private val progressPreferences: ProgressPreferences = mockk(relaxed = true) + + private lateinit var viewModel: ProgressViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { stateHandle.get("progressId") } returns 1L + every { stateHandle.get("title") } returns "Title" + every { stateHandle.get("progressTitle") } returns "Publishing" + every { stateHandle.get("note") } returns "Note" + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `Emit progress updates`() { + var progress = Progress( + id = 1L, + workflowState = "running", + completion = 0f + ) + var expectedState = ProgressUiState( + title = "Title", + progressTitle = "Publishing", + progress = 0f, + note = "Note", + state = ProgressState.RUNNING, + ) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + viewModel = createViewModel() + assertEquals(expectedState, viewModel.uiState.value) + + progress = progress.copy(completion = 11.11f) + expectedState = expectedState.copy(progress = 11.11f) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + testDispatcher.scheduler.advanceTimeBy(510) + assertEquals(expectedState, viewModel.uiState.value) + + progress = progress.copy(completion = 100f, workflowState = "completed") + expectedState = expectedState.copy(progress = 100f, state = ProgressState.COMPLETED) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + testDispatcher.scheduler.advanceTimeBy(510) + assertEquals(expectedState, viewModel.uiState.value) + } + + @Test + fun `Emit success state`() { + val progress = Progress( + id = 1L, + workflowState = "completed", + completion = 100f + ) + val expectedState = ProgressUiState( + title = "Title", + progressTitle = "Publishing", + progress = 100f, + note = "Note", + state = ProgressState.COMPLETED, + ) + + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + + viewModel = createViewModel() + + assertEquals(expectedState, viewModel.uiState.value) + } + + @Test + fun `Emit failed state on exception`() { + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Fail() + + viewModel = createViewModel() + + assertEquals(ProgressState.FAILED, viewModel.uiState.value.state) + } + + @Test + fun `Cancel event sends close action`() = runTest { + val progress = Progress( + id = 1L, + workflowState = "failed", + completion = 11.11f + ) + coEvery { progressApi.getProgress(any(), any()) } returns DataResult.Success(progress) + coEvery { + progressApi.cancelProgress( + any(), + any() + ) + } returns DataResult.Success(progress.copy(workflowState = "failed")) + + viewModel = createViewModel() + + viewModel.handleAction(ProgressAction.Cancel) + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assert(events.last() is ProgressViewModelAction.Close) + + coVerify { + progressApi.cancelProgress("1", any()) + } + } + + private fun createViewModel() = ProgressViewModel(stateHandle, progressApi, progressPreferences) + + +} \ No newline at end of file From 013000e4ee84c63ff19fb0775ace025f549a2220 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 15 Feb 2024 14:07:43 +0100 Subject: [PATCH 27/52] [MBL-17275][Student] - Implement Assignment Reminder E2E test and add an interaction test case (#2343) --- .../student/ui/e2e/AssignmentsE2ETest.kt | 117 +++++++++++++++++- .../AssignmentDetailsInteractionTest.kt | 35 +++++- .../student/ui/pages/AssignmentDetailsPage.kt | 17 +-- .../canvas/espresso/TestMetaData.kt | 2 +- 4 files changed, 151 insertions(+), 20 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt index 3793f2be87..13656b2f3a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt @@ -24,8 +24,10 @@ import androidx.test.rule.GrantPermissionRule import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.checkToastText import com.instructure.dataseeding.api.AssignmentGroupsApi import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.CoursesApi @@ -42,6 +44,7 @@ import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.student.R import com.instructure.student.ui.pages.AssignmentListPage import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.ViewUtils @@ -54,6 +57,7 @@ import org.junit.Test @HiltAndroidTest class AssignmentsE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -65,6 +69,115 @@ class AssignmentsE2ETest: StudentTest() { android.Manifest.permission.CAMERA ) + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E, SecondaryFeatureCategory.ASSIGNMENT_REMINDER) + fun testAssignmentReminderE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course.") + val testAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 2.days.fromNow.iso8601) + val alreadyPastAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 2.days.ago.iso8601) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Select course: '${course.name}'.") + dashboardPage.selectCourse(course) + + Log.d(STEP_TAG,"Navigate to course Assignments Page.") + courseBrowserPage.selectAssignments() + + Log.d(STEP_TAG,"Click on assignment '${testAssignment.name}'.") + assignmentListPage.clickAssignment(testAssignment) + + Log.d(STEP_TAG, "Assert that the corresponding views are displayed on the Assignment Details Page. Assert that the reminder section is displayed as well.") + assignmentDetailsPage.assertPageObjects() + assignmentDetailsPage.assertReminderSectionDisplayed() + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Hour Before' and assert that the reminder has been picked up and displayed on the Assignment Details Page.") + assignmentDetailsPage.selectTimeOption("1 Hour Before") + assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Hour Before' again, and assert that a toast message is occurring which warns that we cannot pick up the same time reminder twice.") + assignmentDetailsPage.selectTimeOption("1 Hour Before") + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + + Log.d(STEP_TAG, "Remove the '1 Hour Before' reminder, confirm the deletion dialog and assert that the '1 Hour Before' reminder is not displayed any more.") + assignmentDetailsPage.removeReminderWithText("1 Hour Before") + assignmentDetailsPage.assertReminderNotDisplayedWithText("1 Hour Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select 'Custom' reminder.") + assignmentDetailsPage.clickCustom() + + Log.d(STEP_TAG, "Assert that the 'Done' button is disabled by default.") + assignmentDetailsPage.assertDoneButtonIsDisabled() + + Log.d(STEP_TAG, "Fill the quantity text input with '15' and assert that the 'Done' button is still disabled since there is no option selected yet.") + assignmentDetailsPage.fillQuantity("15") + assignmentDetailsPage.assertDoneButtonIsDisabled() + + Log.d(STEP_TAG, "Select the 'Hours Before' option, and click on 'Done' button, since it will be enabled because both the quantity and option are filled and selected.") + assignmentDetailsPage.clickHoursBefore() + assignmentDetailsPage.clickDone() + + Log.d(STEP_TAG, "Assert that the '15 Hours Before' reminder is displayed on the Assignment Details Page.") + assignmentDetailsPage.assertReminderDisplayedWithText("15 Hours Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Week Before' and assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for an assignment which ends tomorrow).") + assignmentDetailsPage.selectTimeOption("1 Week Before") + assignmentDetailsPage.assertReminderNotDisplayedWithText("1 Week Before") + checkToastText(R.string.reminderInPast, activityRule.activity) + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select '1 Day Before' and assert that the reminder has been picked up and displayed on the Assignment Details Page.") + assignmentDetailsPage.selectTimeOption("1 Day Before") + assignmentDetailsPage.assertReminderDisplayedWithText("1 Day Before") + + Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") + assignmentDetailsPage.clickAddReminder() + + Log.d(STEP_TAG, "Select 'Custom' reminder.") + assignmentDetailsPage.clickCustom() + + Log.d(STEP_TAG, "Fill the quantity text input with '24' and select 'Hours Before' as option. Click on 'Done'.") + assignmentDetailsPage.fillQuantity("24") + assignmentDetailsPage.clickHoursBefore() + assignmentDetailsPage.clickDone() + + Log.d(STEP_TAG, "Assert that a toast message is occurring which warns that we cannot pick up the same time reminder twice. (Because 1 days and 24 hours is the same)") + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + + Log.d(STEP_TAG, "Navigate back to Assignment List Page.") + Espresso.pressBack() + + Log.d(STEP_TAG,"Click on assignment '${alreadyPastAssignment.name}'.") + assignmentListPage.clickAssignment(alreadyPastAssignment) + + Log.d(STEP_TAG, "Assert that the reminder section is NOT displayed, because the '${alreadyPastAssignment.name}' assignment has already passed..") + assignmentDetailsPage.assertReminderSectionNotDisplayed() + } + @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) @@ -690,7 +803,7 @@ class AssignmentsE2ETest: StudentTest() { dashboardPage.assertCourseGrade(course.name, "80%") Log.d(PREPARATION_TAG, "Update ${course.name} course's settings: Enable restriction for quantitative data.") - var restrictQuantitativeDataMap = mutableMapOf() + val restrictQuantitativeDataMap = mutableMapOf() restrictQuantitativeDataMap["restrict_quantitative_data"] = true CoursesApi.updateCourseSettings(course.id, restrictQuantitativeDataMap) @@ -842,7 +955,7 @@ class AssignmentsE2ETest: StudentTest() { gradeSubmission(teacher, course, pointsTextAssignment.id, student, "12") Log.d(PREPARATION_TAG, "Update ${course.name} course's settings: Enable restriction for quantitative data.") - var restrictQuantitativeDataMap = mutableMapOf() + val restrictQuantitativeDataMap = mutableMapOf() restrictQuantitativeDataMap["restrict_quantitative_data"] = true CoursesApi.updateCourseSettings(course.id, restrictQuantitativeDataMap) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt index cd8fdb1b6e..3d89f7af28 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt @@ -37,7 +37,7 @@ import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertNotNull import org.junit.Test -import java.util.Calendar +import java.util.* @HiltAndroidTest class AssignmentDetailsInteractionTest : StudentTest() { @@ -429,7 +429,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { assignmentListPage.clickAssignment(assignment) assignmentDetailsPage.clickAddReminder() - assignmentDetailsPage.clickOneHourBefore() + assignmentDetailsPage.selectTimeOption("1 Hour Before") assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") } @@ -446,7 +446,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { assignmentListPage.clickAssignment(assignment) assignmentDetailsPage.clickAddReminder() - assignmentDetailsPage.clickOneHourBefore() + assignmentDetailsPage.selectTimeOption("1 Hour Before") assignmentDetailsPage.assertReminderDisplayedWithText("1 Hour Before") @@ -489,7 +489,7 @@ class AssignmentDetailsInteractionTest : StudentTest() { assignmentListPage.clickAssignment(assignment) assignmentDetailsPage.clickAddReminder() - assignmentDetailsPage.clickOneHourBefore() + assignmentDetailsPage.selectTimeOption("1 Hour Before") checkToastText(R.string.reminderInPast, activityRule.activity) } @@ -506,9 +506,32 @@ class AssignmentDetailsInteractionTest : StudentTest() { assignmentListPage.clickAssignment(assignment) assignmentDetailsPage.clickAddReminder() - assignmentDetailsPage.clickOneHourBefore() + assignmentDetailsPage.selectTimeOption("1 Hour Before") assignmentDetailsPage.clickAddReminder() - assignmentDetailsPage.clickOneHourBefore() + assignmentDetailsPage.selectTimeOption("1 Hour Before") + + checkToastText(R.string.reminderAlreadySet, activityRule.activity) + } + + @Test + @TestMetaData(Priority.NICE_TO_HAVE, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) + fun testAddReminderForTheSameTimeWithDifferentMeasureOfTimeShowsError() { + val data = setUpData() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id, name = "Test Assignment", dueAt = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 10) + }.time.toApiString()) + goToAssignmentList() + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickAddReminder() + assignmentDetailsPage.selectTimeOption("1 Week Before") + assignmentDetailsPage.clickAddReminder() + + assignmentDetailsPage.clickCustom() + assignmentDetailsPage.fillQuantity("7") + assignmentDetailsPage.clickDaysBefore() + assignmentDetailsPage.clickDone() checkToastText(R.string.reminderAlreadySet, activityRule.activity) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt index 249720e25a..871d533d39 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt @@ -46,8 +46,6 @@ import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.clearText import com.instructure.espresso.click import com.instructure.espresso.page.BasePage -import com.instructure.espresso.page.getPluralFromResource -import com.instructure.espresso.page.getStringFromResource import com.instructure.espresso.page.onView import com.instructure.espresso.page.onViewWithText import com.instructure.espresso.page.plus @@ -265,15 +263,8 @@ open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { onView(withId(R.id.reminderAdd)).scrollTo().click() } - fun clickOneHourBefore() { - onView( - withText( - getStringFromResource( - R.string.reminderBefore, - getPluralFromResource(R.plurals.reminderHour, 1, 1) - ) - ) - ).scrollTo().click() + fun selectTimeOption(timeOption: String) { + onView(withText(timeOption)).scrollTo().click() } fun assertReminderDisplayedWithText(text: String) { @@ -307,6 +298,10 @@ open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { onView(withId(R.id.hours)).scrollTo().click() } + fun clickDaysBefore() { + onView(withId(R.id.days)).scrollTo().click() + } + fun assertDoneButtonIsDisabled() { onView(withText(R.string.done)).check(matches(not(isEnabled()))) } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt index 3fb7dce239..8a0d3ff2b5 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt @@ -42,7 +42,7 @@ enum class SecondaryFeatureCategory { ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, GROUPS_DASHBOARD, GROUPS_FILES, GROUPS_ANNOUNCEMENTS, GROUPS_DISCUSSIONS, GROUPS_PAGES, GROUPS_PEOPLE, EVENTS_DISCUSSIONS, EVENTS_QUIZZES, EVENTS_ASSIGNMENTS, EVENTS_NOTIFICATIONS, - MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE, ALL_COURSES, CHANGE_USER + MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE, ALL_COURSES, CHANGE_USER, ASSIGNMENT_REMINDER } enum class TestCategory { From 0d3cdc3907d46a3b9ec98fe9f181570b623d8147 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Fri, 16 Feb 2024 15:34:42 +0100 Subject: [PATCH 28/52] [MBL-16673][Student] - Introduce ApiManager class and extract direct API calls from E2E test files (#2336) --- .../student/ui/e2e/AssignmentsE2ETest.kt | 354 ++++++------------ .../student/ui/e2e/BookmarksE2ETest.kt | 21 +- .../student/ui/e2e/ConferencesE2ETest.kt | 8 +- .../student/ui/e2e/DashboardE2ETest.kt | 5 +- .../student/ui/e2e/DiscussionsE2ETest.kt | 68 ++-- .../student/ui/e2e/FilesE2ETest.kt | 110 ++---- .../student/ui/e2e/GradesE2ETest.kt | 78 +--- .../student/ui/e2e/InboxE2ETest.kt | 75 ++-- .../student/ui/e2e/LoginE2ETest.kt | 69 ++-- .../student/ui/e2e/ModulesE2ETest.kt | 161 ++------ .../student/ui/e2e/NotificationsE2ETest.kt | 67 +--- .../student/ui/e2e/PagesE2ETest.kt | 27 +- .../student/ui/e2e/PeopleE2ETest.kt | 4 +- .../student/ui/e2e/QuizzesE2ETest.kt | 24 +- .../student/ui/e2e/SettingsE2ETest.kt | 4 +- .../student/ui/e2e/ShareExtensionE2ETest.kt | 29 +- .../student/ui/e2e/SyllabusE2ETest.kt | 42 +-- .../instructure/student/ui/e2e/TodoE2ETest.kt | 82 ++-- .../ui/e2e/k5/GradesElementaryE2ETest.kt | 75 +--- .../student/ui/e2e/k5/HomeroomE2ETest.kt | 52 +-- .../ui/e2e/k5/ImportantDatesE2ETest.kt | 49 +-- .../student/ui/e2e/k5/ResourcesE2ETest.kt | 14 +- .../student/ui/e2e/k5/ScheduleE2ETest.kt | 31 +- .../offline/ManageOfflineContentE2ETest.kt | 1 + .../e2e/offline/OfflineAllCoursesE2ETest.kt | 1 + .../offline/OfflineCourseBrowserE2ETest.kt | 1 + .../ui/e2e/offline/OfflineDashboardE2ETest.kt | 1 + .../ui/e2e/offline/OfflineFilesE2ETest.kt | 3 +- .../e2e/offline/OfflineLeftSideMenuE2ETest.kt | 1 + .../ui/e2e/offline/OfflineLoginE2ETest.kt | 9 +- .../ui/e2e/offline/OfflinePagesE2ETest.kt | 28 +- .../ui/e2e/offline/OfflinePeopleE2ETest.kt | 1 + .../e2e/offline/OfflineSyncProgressE2ETest.kt | 1 + .../e2e/offline/OfflineSyncSettingsE2ETest.kt | 1 + .../ElementaryDashboardInteractionTest.kt | 6 +- .../ElementaryGradesInteractionTest.kt | 16 +- .../ui/interaction/HomeroomInteractionTest.kt | 22 +- .../ImportantDatesInteractionTest.kt | 16 +- .../interaction/ResourcesInteractionTest.kt | 16 +- .../ui/interaction/ScheduleInteractionTest.kt | 22 +- .../student/ui/utils/StudentTestExtensions.kt | 24 +- .../teacher/ui/e2e/AssignmentE2ETest.kt | 9 +- .../teacher/ui/e2e/ModulesE2ETest.kt | 8 +- .../teacher/ui/utils/TeacherTestExtensions.kt | 42 ++- .../dataseeding/api/AssignmentGroupsApi.kt | 2 +- .../dataseeding/api/AssignmentsApi.kt | 54 +-- .../dataseeding/api/ConferencesApi.kt | 5 +- .../dataseeding/api/ConversationsApi.kt | 2 +- .../dataseeding/api/EnrollmentsApi.kt | 6 +- .../dataseeding/api/FileFolderApi.kt | 10 +- .../instructure/dataseeding/api/ModulesApi.kt | 22 +- .../instructure/dataseeding/api/PagesApi.kt | 10 +- .../instructure/dataseeding/api/QuizzesApi.kt | 21 +- .../dataseeding/api/SubmissionsApi.kt | 132 +++---- .../instructure/dataseeding/api/UserApi.kt | 2 +- .../dataseeding/util/Randomizer.kt | 14 +- .../dataseeding/soseedy/AssignmentsTest.kt | 10 +- .../dataseeding/soseedy/ModulesTest.kt | 6 +- .../canvas/espresso/TestMetaData.kt | 4 +- 59 files changed, 671 insertions(+), 1307 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt index 13656b2f3a..c61b83cc1e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt @@ -32,13 +32,8 @@ import com.instructure.dataseeding.api.AssignmentGroupsApi import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.CoursesApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.AttachmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.FileUploadType import com.instructure.dataseeding.model.GradingType -import com.instructure.dataseeding.model.SubmissionApiModel import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days @@ -80,9 +75,11 @@ class AssignmentsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course.") - val testAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 2.days.fromNow.iso8601) - val alreadyPastAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 2.days.ago.iso8601) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course with 2 days ahead due date.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, dueAt = 2.days.fromNow.iso8601, pointsPossible = 15.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course with 2 days past due date.") + val alreadyPastAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, dueAt = 2.days.ago.iso8601, pointsPossible = 15.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) @@ -190,7 +187,14 @@ class AssignmentsE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + val pointsTextAssignment = AssignmentsApi.createAssignment( + courseId = course.id, + teacherToken = teacher.token, + gradingType = GradingType.POINTS, + pointsPossible = 15.0, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY) + ) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -219,14 +223,14 @@ class AssignmentsE2ETest: StudentTest() { Espresso.pressBack() Log.d(PREPARATION_TAG,"Submit assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) assignmentDetailsPage.refresh() assignmentDetailsPage.assertStatusSubmitted() assignmentDetailsPage.assertSubmissionAndRubricLabel() Log.d(PREPARATION_TAG,"Grade submission: ${pointsTextAssignment.name} with 13 points.") - val textGrade = gradeSubmission(teacher, course, pointsTextAssignment.id, student, "13") + val textGrade = SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "13") Log.d(STEP_TAG,"Refresh the page. Assert that the assignment ${pointsTextAssignment.name} has been graded with 13 points.") assignmentDetailsPage.refresh() @@ -250,13 +254,13 @@ class AssignmentsE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val letterGradeTextAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + val letterGradeTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Submit assignment: ${letterGradeTextAssignment.name} for student: ${student.name}.") - submitAssignment(letterGradeTextAssignment, course, student) + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, letterGradeTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(PREPARATION_TAG,"Grade submission: ${letterGradeTextAssignment.name} with 13 points.") - val submissionGrade = gradeSubmission(teacher, course, letterGradeTextAssignment.id, student, "13") + val submissionGrade = SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeTextAssignment.id, student.id, postedGrade = "13") Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -282,7 +286,7 @@ class AssignmentsE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val percentageFileAssignment = createAssignment(course.id, teacher, GradingType.PERCENT, 25.0, allowedExtensions = listOf("txt", "pdf", "jpg"), submissionType = listOf(SubmissionType.ONLINE_UPLOAD)) + val percentageFileAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 25.0, allowedExtensions = listOf("txt", "pdf", "jpg"), submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD)) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -307,14 +311,14 @@ class AssignmentsE2ETest: StudentTest() { ) Log.d(PREPARATION_TAG,"Submit ${percentageFileAssignment.name} assignment for ${student.name} student.") - submitCourseAssignment(course, percentageFileAssignment, uploadInfo, student) + SubmissionsApi.submitCourseAssignment(course.id, student.token, percentageFileAssignment.id, SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(uploadInfo.id)) Log.d(STEP_TAG,"Refresh the page. Assert that the ${percentageFileAssignment.name} assignment has been submitted.") assignmentDetailsPage.refresh() assignmentDetailsPage.assertAssignmentSubmitted() Log.d(PREPARATION_TAG,"Grade ${percentageFileAssignment.name} assignment with 22 percentage.") - gradeSubmission(teacher, course, percentageFileAssignment, student,"22") + SubmissionsApi.gradeSubmission(teacher.token, course.id, percentageFileAssignment.id, student.id, postedGrade = "22") Log.d(STEP_TAG,"Refresh the page. Assert that the ${percentageFileAssignment.name} assignment has been graded with 22 percentage.") assignmentDetailsPage.refresh() @@ -342,21 +346,6 @@ class AssignmentsE2ETest: StudentTest() { submissionDetailsPage.assertFileDisplayed(uploadInfo.fileName) } - private fun submitCourseAssignment( - course: CourseApiModel, - percentageFileAssignment: AssignmentApiModel, - uploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = percentageFileAssignment.id, - fileIds = listOf(uploadInfo.id).toMutableList(), - studentToken = student.token - ) - } - @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) @@ -368,36 +357,36 @@ class AssignmentsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val letterGradeTextAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val letterGradeTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${letterGradeTextAssignment.name} assignment for ${student.name} student.") - submitAssignment(letterGradeTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${letterGradeTextAssignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, letterGradeTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(PREPARATION_TAG,"Grade ${letterGradeTextAssignment.name} assignment with 16.") - gradeSubmission(teacher, course, letterGradeTextAssignment, student, "16") + Log.d(PREPARATION_TAG,"Grade '${letterGradeTextAssignment.name}' assignment with 16.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeTextAssignment.id, student.id, postedGrade = "16") - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${pointsTextAssignment.name} assignment for ${student.name} student.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${pointsTextAssignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(PREPARATION_TAG,"Grade ${pointsTextAssignment.name} assignment with 13 points.") - gradeSubmission(teacher, course, pointsTextAssignment.id, student, "13") + Log.d(PREPARATION_TAG,"Grade '${pointsTextAssignment.name}' assignment with 13 points.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "13") Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select ${course.name} course and navigate to it's Assignments Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to it's Assignments Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Assert that ${pointsTextAssignment.name} assignment is displayed with the corresponding grade: 13.") + Log.d(STEP_TAG,"Assert that '${pointsTextAssignment.name}' assignment is displayed with the corresponding grade: 13.") assignmentListPage.assertHasAssignment(pointsTextAssignment,"13") - Log.d(STEP_TAG,"Assert that ${letterGradeTextAssignment.name} assignment is displayed with the corresponding grade: 16.") + Log.d(STEP_TAG,"Assert that '${letterGradeTextAssignment.name}' assignment is displayed with the corresponding grade: 16.") assignmentListPage.assertHasAssignment(letterGradeTextAssignment, "16") } @@ -412,22 +401,22 @@ class AssignmentsE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") - val upcomingAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + val upcomingAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") - val missingAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0, 2.days.ago.iso8601) + val missingAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, dueAt = 2.days.ago.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Seeding a GRADED assignment for ${course.name} course.") - val gradedAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0) + val gradedAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade the '${gradedAssignment.name}' with '11' points out of 20.") - gradeSubmission(teacher, course, gradedAssignment, student, "11") + SubmissionsApi.gradeSubmission(teacher.token, course.id, gradedAssignment.id, student.id, postedGrade = "11") Log.d(PREPARATION_TAG,"Create an Assignment Group for '${course.name}' course.") - val assignmentGroup = createAssignmentGroup(teacher, course) + val assignmentGroup = AssignmentGroupsApi.createAssignmentGroup(teacher.token, course.id, name = "Discussions") Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") - val otherTypeAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 20.0, assignmentGroupId = assignmentGroup.id) + val otherTypeAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 20.0, assignmentGroupId = assignmentGroup.id, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -520,18 +509,6 @@ class AssignmentsE2ETest: StudentTest() { assignmentListPage.assertAssignmentNotDisplayed(gradedAssignment.name) } - private fun createAssignmentGroup( - teacher: CanvasUserApiModel, - course: CourseApiModel - ) = AssignmentGroupsApi.createAssignmentGroup( - token = teacher.token, - courseId = course.id, - name = "Discussions", - position = null, - groupWeight = null, - sisSourceId = null - ) - @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.COMMENTS, TestCategory.E2E) @@ -543,21 +520,21 @@ class AssignmentsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - submitAssignment(assignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, assignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select ${course.name} course and navigate to it's Assignments Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to it's Assignments Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment.") assignmentListPage.clickAssignment(assignment) Log.d(STEP_TAG,"Navigate to submission details Comments Tab.") @@ -587,13 +564,13 @@ class AssignmentsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - submitAssignment(assignment, course, student) + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, assignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() @@ -604,16 +581,16 @@ class AssignmentsE2ETest: StudentTest() { token = student.token, fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, mutableListOf(commentUploadInfo.id)) - Log.d(STEP_TAG,"Select ${course.name} course and navigate to it's Assignments Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to it's Assignments Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment.") assignmentListPage.clickAssignment(assignment) - Log.d(STEP_TAG,"Assert that ${commentUploadInfo.fileName} file is displayed as a comment by ${student.name} student.") + Log.d(STEP_TAG,"Assert that '${commentUploadInfo.fileName}' file is displayed as a comment by '${student.name}' student.") assignmentDetailsPage.goToSubmissionDetails() submissionDetailsPage.openComments() submissionDetailsPage.assertCommentAttachmentDisplayed(commentUploadInfo.fileName, student) @@ -633,26 +610,26 @@ class AssignmentsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG, "Select course: ${course.name}.") + Log.d(STEP_TAG, "Select course: '${course.name}'.") dashboardPage.selectCourse(course) Log.d(STEP_TAG, "Navigate to course Assignments Page.") courseBrowserPage.selectAssignments() Log.d(STEP_TAG, "Verify that our assignments are present," + - "along with any grade/date info. Click on assignment ${pointsTextAssignment.name}.") + "along with any grade/date info. Click on assignment '${pointsTextAssignment.name}'.") assignmentListPage.assertHasAssignment(pointsTextAssignment) assignmentListPage.clickAssignment(pointsTextAssignment) - Log.d(PREPARATION_TAG,"Submit assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the page.") assignmentDetailsPage.refresh() @@ -660,8 +637,8 @@ class AssignmentsE2ETest: StudentTest() { Log.d(STEP_TAG, "Assert that when only there is one attempt, the spinner is not displayed.") assignmentDetailsPage.assertNoAttemptSpinner() - Log.d(PREPARATION_TAG,"Generate another submission for assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Generate another submission for assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the page.") assignmentDetailsPage.refresh() @@ -695,20 +672,20 @@ class AssignmentsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select course: ${course.name}.") + Log.d(STEP_TAG,"Select course: '${course.name}'.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to course Assignments Page.") courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Verify that our assignments are present, along with any grade/date info. Click on assignment ${pointsTextAssignment.name}.") + Log.d(STEP_TAG,"Verify that our assignments are present, along with any grade/date info. Click on assignment '${pointsTextAssignment.name}'.") assignmentListPage.assertHasAssignment(pointsTextAssignment) assignmentListPage.clickAssignment(pointsTextAssignment) @@ -724,16 +701,16 @@ class AssignmentsE2ETest: StudentTest() { submissionDetailsPage.assertNoSubmissionEmptyView() Espresso.pressBack() - Log.d(PREPARATION_TAG,"Submit assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Submit assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the Assignment Details Page. Assert that the assignment's status is submitted and the 'Submission and Rubric' label is displayed.") assignmentDetailsPage.refresh() assignmentDetailsPage.assertStatusSubmitted() assignmentDetailsPage.assertSubmissionAndRubricLabel() - Log.d(PREPARATION_TAG,"Make another submission for assignment: ${pointsTextAssignment.name} for student: ${student.name}.") - val secondSubmissionAttempt = submitAssignment(pointsTextAssignment, course, student) + Log.d(PREPARATION_TAG,"Make another submission for assignment: '${pointsTextAssignment.name}' for student: '${student.name}'.") + val secondSubmissionAttempt = SubmissionsApi.seedAssignmentSubmission(course.id, student.token, pointsTextAssignment.id, submissionSeedsList = listOf(SubmissionsApi.SubmissionSeedInfo(amount = 1, submissionType = SubmissionType.ONLINE_TEXT_ENTRY))) Log.d(STEP_TAG, "Refresh the Assignment Details Page. Assert that the assignment's status is submitted and the 'Submission and Rubric' label is displayed.") assignmentDetailsPage.refresh() @@ -748,7 +725,7 @@ class AssignmentsE2ETest: StudentTest() { assignmentDetailsPage.goToSubmissionDetails() submissionDetailsPage.openComments() - Log.d(STEP_TAG,"Assert that ${secondSubmissionAttempt[0].body} text submission has been displayed as a comment.") + Log.d(STEP_TAG,"Assert that '${secondSubmissionAttempt[0].body}' text submission has been displayed as a comment.") submissionDetailsPage.assertTextSubmissionDisplayedAsComment() val newComment = "Comment for second attempt" @@ -789,14 +766,14 @@ class AssignmentsE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() Log.d(PREPARATION_TAG,"Grade submission: ${pointsTextAssignment.name} with 12 points.") - gradeSubmission(teacher, course, pointsTextAssignment.id, student, "12") + SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "12") Log.d(STEP_TAG, "Refresh the Dashboard page. Assert that the course grade is 80%.") dashboardPage.refresh() @@ -812,28 +789,28 @@ class AssignmentsE2ETest: StudentTest() { dashboardPage.assertCourseGrade(course.name, "B-") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val percentageAssignment = createAssignment(course.id, teacher, GradingType.PERCENT, 15.0, 1.days.fromNow.iso8601) + val percentageAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${percentageAssignment.name} with 66% of the maximum points (aka. 10).") - gradeSubmission(teacher, course, percentageAssignment.id, student, "10") + SubmissionsApi.gradeSubmission(teacher.token, course.id, percentageAssignment.id, student.id, postedGrade = "10") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val letterGradeAssignment = createAssignment(course.id, teacher, GradingType.LETTER_GRADE, 15.0, 1.days.fromNow.iso8601) + val letterGradeAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${letterGradeAssignment.name} with C.") - gradeSubmission(teacher, course, letterGradeAssignment.id, student, "C") + SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeAssignment.id, student.id, postedGrade = "C") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val passFailAssignment = createAssignment(course.id, teacher, GradingType.PASS_FAIL, 15.0, 1.days.fromNow.iso8601) + val passFailAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PASS_FAIL, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${passFailAssignment.name} with 'Incomplete'.") - gradeSubmission(teacher, course, passFailAssignment.id, student, "Incomplete") + SubmissionsApi.gradeSubmission(teacher.token, course.id, passFailAssignment.id, student.id, postedGrade = "Incomplete") Log.d(PREPARATION_TAG,"Seeding 'Text Entry' assignment for ${course.name} course.") - val gpaScaleAssignment = createAssignment(course.id, teacher, GradingType.GPA_SCALE, 15.0, 1.days.fromNow.iso8601) + val gpaScaleAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.GPA_SCALE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(PREPARATION_TAG,"Grade submission: ${gpaScaleAssignment.name} with 3.7.") - gradeSubmission(teacher, course, gpaScaleAssignment.id, student, "3.7") + SubmissionsApi.gradeSubmission(teacher.token, course.id, gpaScaleAssignment.id, student.id, postedGrade = "3.7") Log.d(STEP_TAG, "Refresh the Dashboard page to let the newly added submissions and their grades propagate.") dashboardPage.refresh() @@ -944,17 +921,17 @@ class AssignmentsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val pointsTextAssignment = createAssignment(course.id, teacher, GradingType.POINTS, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val pointsTextAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(PREPARATION_TAG, "Grade submission: ${pointsTextAssignment.name} with 12 points.") - gradeSubmission(teacher, course, pointsTextAssignment.id, student, "12") + Log.d(PREPARATION_TAG, "Grade submission: '${pointsTextAssignment.name}' with 12 points.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, pointsTextAssignment.id, student.id, postedGrade = "12") - Log.d(PREPARATION_TAG, "Update ${course.name} course's settings: Enable restriction for quantitative data.") + Log.d(PREPARATION_TAG, "Update '${course.name}' course's settings: Enable restriction for quantitative data.") val restrictQuantitativeDataMap = mutableMapOf() restrictQuantitativeDataMap["restrict_quantitative_data"] = true CoursesApi.updateCourseSettings(course.id, restrictQuantitativeDataMap) @@ -963,52 +940,34 @@ class AssignmentsE2ETest: StudentTest() { dashboardPage.refresh() dashboardPage.assertCourseGrade(course.name, "B-") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val percentageAssignment = createAssignment(course.id, teacher, GradingType.PERCENT, 15.0, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val percentageAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PERCENT, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${percentageAssignment.name} with 66% of the maximum points (aka. 10).") - gradeSubmission(teacher, course, percentageAssignment.id, student, "10") + Log.d(PREPARATION_TAG, "Grade submission: '${percentageAssignment.name}' with 66% of the maximum points (aka. 10).") + SubmissionsApi.gradeSubmission(teacher.token, course.id, percentageAssignment.id, student.id, postedGrade = "10") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val letterGradeAssignment = createAssignment( - course.id, - teacher, - GradingType.LETTER_GRADE, - 15.0, - 1.days.fromNow.iso8601 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val letterGradeAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.LETTER_GRADE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${letterGradeAssignment.name} with C.") - gradeSubmission(teacher, course, letterGradeAssignment.id, student, "C") + Log.d(PREPARATION_TAG, "Grade submission: '${letterGradeAssignment.name}' with C.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, letterGradeAssignment.id, student.id, postedGrade = "C") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val passFailAssignment = createAssignment( - course.id, - teacher, - GradingType.PASS_FAIL, - 15.0, - 1.days.fromNow.iso8601 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val passFailAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.PASS_FAIL, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${passFailAssignment.name} with 'Incomplete'.") - gradeSubmission(teacher, course, passFailAssignment.id, student, "Incomplete") + Log.d(PREPARATION_TAG, "Grade submission: '${passFailAssignment.name}' with 'Incomplete'.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, passFailAssignment.id, student.id, postedGrade = "Incomplete") - Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for ${course.name} course.") - val gpaScaleAssignment = createAssignment( - course.id, - teacher, - GradingType.GPA_SCALE, - 15.0, - 1.days.fromNow.iso8601 - ) + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val gpaScaleAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.GPA_SCALE, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG, "Grade submission: ${gpaScaleAssignment.name} with 3.7.") - gradeSubmission(teacher, course, gpaScaleAssignment.id, student, "3.7") + Log.d(PREPARATION_TAG, "Grade submission: '${gpaScaleAssignment.name}' with 3.7.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, gpaScaleAssignment.id, student.id, postedGrade = "3.7") Log.d(STEP_TAG, "Refresh the Dashboard page to let the newly added submissions and their grades propagate.") dashboardPage.refresh() - Log.d(STEP_TAG, "Select course: ${course.name}. Select 'Grades' menu.") + Log.d(STEP_TAG, "Select course: '${course.name}'. Select 'Grades' menu.") dashboardPage.selectCourse(course) courseBrowserPage.selectGrades() @@ -1021,7 +980,7 @@ class AssignmentsE2ETest: StudentTest() { if(isLowResDevice()) courseGradesPage.swipeUp() courseGradesPage.assertAssignmentDisplayed(gpaScaleAssignment.name, "F") - Log.d(PREPARATION_TAG, "Update ${course.name} course's settings: Enable restriction for quantitative data.") + Log.d(PREPARATION_TAG, "Update '${course.name}' course's settings: Enable restriction for quantitative data.") restrictQuantitativeDataMap["restrict_quantitative_data"] = false CoursesApi.updateCourseSettings(course.id, restrictQuantitativeDataMap) @@ -1038,95 +997,4 @@ class AssignmentsE2ETest: StudentTest() { courseGradesPage.swipeUp() courseGradesPage.assertAssignmentDisplayed(gpaScaleAssignment.name, "3.7/15 (F)") } - - private fun createAssignment( - courseId: Long, - teacher: CanvasUserApiModel, - gradingType: GradingType, - pointsPossible: Double, - dueAt: String = EMPTY_STRING, - allowedExtensions: List? = null, - assignmentGroupId: Long? = null, - submissionType: List = listOf(SubmissionType.ONLINE_TEXT_ENTRY) - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = courseId, - submissionTypes = submissionType, - gradingType = gradingType, - teacherToken = teacher.token, - pointsPossible = pointsPossible, - dueAt = dueAt, - allowedExtensions = allowedExtensions, - assignmentGroupId = assignmentGroupId - ) - ) - } - - private fun submitAssignment( - assignment: AssignmentApiModel, - course: CourseApiModel, - student: CanvasUserApiModel - ): List { - return SubmissionsApi.seedAssignmentSubmission( - SubmissionsApi.SubmissionSeedRequest( - assignmentId = assignment.id, - courseId = course.id, - studentToken = student.token, - submissionSeedsList = listOf( - SubmissionsApi.SubmissionSeedInfo( - amount = 1, - submissionType = SubmissionType.ONLINE_TEXT_ENTRY - ) - ) - ) - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - student: CanvasUserApiModel, - postedGrade: String, - excused: Boolean = false - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment.id, - studentId = student.id, - postedGrade = postedGrade, - excused = excused - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignmentId: Long, - student: CanvasUserApiModel, - postedGrade: String - ) = SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignmentId, - studentId = student.id, - postedGrade = postedGrade, - excused = false - ) - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt index 5a70977edd..e9429327ce 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/BookmarksE2ETest.kt @@ -25,9 +25,6 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days @@ -58,7 +55,7 @@ class BookmarksE2ETest : StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Preparing an assignment which will be saved as a bookmark.") - val assignment = createAssignment(course, teacher) + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -110,20 +107,4 @@ class BookmarksE2ETest : StudentTest() { bookmarkPage.assertEmptyView() } - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = 15.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } - } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt index dcc641e94d..b79cfa0ae7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ConferencesE2ETest.kt @@ -53,15 +53,11 @@ class ConferencesE2ETest: StudentTest() { val testConferenceTitle = "E2E test conference" val testConferenceDescription = "Nightly E2E Test conference description" Log.d(PREPARATION_TAG,"Create a conference with '$testConferenceTitle' title and '$testConferenceDescription' description.") - ConferencesApi.createCourseConference(teacher.token, - testConferenceTitle, testConferenceDescription,"BigBlueButton",false,70, - listOf(student.id),course.id) + ConferencesApi.createCourseConference(course.id, teacher.token, testConferenceTitle, testConferenceDescription, recipientUserIds = listOf(student.id)) val testConferenceTitle2 = "E2E test conference 2" val testConferenceDescription2 = "Nightly E2E Test conference description 2" - ConferencesApi.createCourseConference(teacher.token, - testConferenceTitle2, testConferenceDescription2,"BigBlueButton",true,120, - listOf(student.id),course.id) + ConferencesApi.createCourseConference(course.id, teacher.token, testConferenceTitle2, testConferenceDescription2, longRunning = true, duration = 120, recipientUserIds = listOf(student.id)) Log.d(STEP_TAG,"Refresh the page. Assert that $testConferenceTitle conference is displayed on the Conference List Page with the corresponding status.") refresh() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt index 9770e01244..4b11b77837 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt @@ -50,10 +50,7 @@ class DashboardE2ETest : StudentTest() { val course2 = data.coursesList[1] Log.d(PREPARATION_TAG, "Seed an Inbox conversation via API.") - ConversationsApi.createConversation( - token = teacher.token, - recipients = listOf(student.id.toString()) - ) + ConversationsApi.createConversation(teacher.token, listOf(student.id.toString())) Log.d(PREPARATION_TAG,"Seed some group info.") val groupCategory = GroupsApi.createCourseGroupCategory(data.coursesList[0].id, teacher.token) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt index bf6bbeca3b..aa5efe83d0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt @@ -25,8 +25,6 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.DiscussionTopicsApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.espresso.ViewUtils import com.instructure.espresso.getCurrentDateInCanvasFormat import com.instructure.student.ui.utils.StudentTest @@ -52,51 +50,51 @@ class DiscussionsE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seed a discussion topic.") - val topic1 = createDiscussion(course, teacher) + Log.d(PREPARATION_TAG,"Seed a discussion topic for '${course.name}' course.") + val topic1 = DiscussionTopicsApi.createDiscussion(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Seed another discussion topic.") - val topic2 = createDiscussion(course, teacher) + Log.d(PREPARATION_TAG,"Seed another discussion topic for '${course.name}' course.") + val topic2 = DiscussionTopicsApi.createDiscussion(course.id, teacher.token) - Log.d(STEP_TAG,"Seed an announcement for ${course.name} course.") - val announcement = createAnnouncement(course, teacher) + Log.d(STEP_TAG,"Seed an announcement for '${course.name}' course.") + val announcement = DiscussionTopicsApi.createAnnouncement(course.id, teacher.token) - Log.d(STEP_TAG,"Seed another announcement for ${course.name} course.") - val announcement2 = createAnnouncement(course, teacher) + Log.d(STEP_TAG,"Seed another announcement for '${course.name}' course.") + val announcement2 = DiscussionTopicsApi.createAnnouncement(course.id, teacher.token) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) - dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select course: ${course.name}.") + Log.d(STEP_TAG,"Wait for the Dashboard Page to be rendered. Select course: '${course.name}'.") + dashboardPage.waitForRender() dashboardPage.selectCourse(course) - Log.d(STEP_TAG,"Verify that the Discussions and Announcements Tabs are both displayed on the CourseBrowser Page.") + Log.d(STEP_TAG,"Verify that the 'Discussions' and 'Announcements' Tabs are both displayed on the CourseBrowser Page.") courseBrowserPage.assertTabDisplayed("Announcements") courseBrowserPage.assertTabDisplayed("Discussions") - Log.d(STEP_TAG,"Navigate to Announcements Page. Assert that both ${announcement.title} and ${announcement2.title} announcements are displayed.") + Log.d(STEP_TAG,"Navigate to Announcements Page. Assert that both '${announcement.title}' and '${announcement2.title}' announcements are displayed.") courseBrowserPage.selectAnnouncements() discussionListPage.assertTopicDisplayed(announcement.title) discussionListPage.assertTopicDisplayed(announcement2.title) - Log.d(STEP_TAG,"Select ${announcement.title} announcement and assert if the details page is displayed.") + Log.d(STEP_TAG,"Select '${announcement.title}' announcement and assert if the Discussion Details Page is displayed.") discussionListPage.selectTopic(announcement.title) discussionDetailsPage.assertTitleText(announcement.title) - Log.d(STEP_TAG,"Navigate back.") + Log.d(STEP_TAG,"Navigate back to the Discussion List Page.") Espresso.pressBack() - Log.d(STEP_TAG,"Click on the 'Search' button and search for ${announcement2.title}. announcement.") + Log.d(STEP_TAG,"Click on the 'Search' button and search for '${announcement2.title}'. announcement.") discussionListPage.searchable.clickOnSearchButton() discussionListPage.searchable.typeToSearchBar(announcement2.title) - Log.d(STEP_TAG,"Refresh the page. Assert that the searching method is working well, so ${announcement.title} won't be displayed and ${announcement2.title} is displayed.") + Log.d(STEP_TAG,"Refresh the page. Assert that the searching method is working well, so '${announcement.title}' won't be displayed and '${announcement2.title}' is displayed.") discussionListPage.pullToUpdate() discussionListPage.assertTopicDisplayed(announcement2.title) discussionListPage.assertTopicNotDisplayed(announcement.title) - Log.d(STEP_TAG,"Clear the search input field and assert that both announcements, ${announcement.title} and ${announcement2.title} has been diplayed.") + Log.d(STEP_TAG,"Clear the search input field and assert that both announcements, '${announcement.title}' and '${announcement2.title}' has been displayed.") discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(announcement.title) discussionListPage.assertTopicDisplayed(announcement2.title) @@ -107,22 +105,22 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate to Discussions Page.") courseBrowserPage.selectDiscussions() - Log.d(STEP_TAG,"Select ${topic1.title} discussion and assert if the details page is displayed and there is no reply for the discussion yet.") + Log.d(STEP_TAG,"Select '${topic1.title}' discussion and assert if the details page is displayed and there is no reply for the discussion yet.") discussionListPage.assertTopicDisplayed(topic1.title) discussionListPage.selectTopic(topic1.title) discussionDetailsPage.assertTitleText(topic1.title) discussionDetailsPage.assertNoRepliesDisplayed() - Log.d(STEP_TAG,"Navigate back to Discussions Page.") - Espresso.pressBack() // Back to discussion list + Log.d(STEP_TAG,"Navigate back to Discussion List Page.") + Espresso.pressBack() - Log.d(STEP_TAG,"Select ${topic1.title} discussion and assert if the details page is displayed and there is no reply for the discussion yet.") + Log.d(STEP_TAG,"Select '${topic1.title}' discussion and assert if the details page is displayed and there is no reply for the discussion yet.") discussionListPage.assertTopicDisplayed(topic2.title) discussionListPage.selectTopic(topic2.title) discussionDetailsPage.assertTitleText(topic2.title) discussionDetailsPage.assertNoRepliesDisplayed() - Log.d(STEP_TAG,"Navigate back to Discussions Page.") + Log.d(STEP_TAG,"Navigate back to Discussion List Page.") Espresso.pressBack() val newTopicName = "Do we really need discussions?" @@ -131,11 +129,11 @@ class DiscussionsE2ETest: StudentTest() { discussionListPage.createDiscussionTopic(newTopicName, newTopicDescription) sleep(2000) // Allow some time for creation to propagate - Log.d(STEP_TAG,"Assert that $newTopicName topic has been created successfully with 0 reply count.") + Log.d(STEP_TAG,"Assert that '$newTopicName' topic has been created successfully with 0 reply count.") discussionListPage.assertTopicDisplayed(newTopicName) discussionListPage.assertReplyCount(newTopicName, 0) - Log.d(STEP_TAG,"Select $newTopicName topic and assert that there is no reply on the details page as well.") + Log.d(STEP_TAG,"Select '$newTopicName' topic and assert that there is no reply on the details page as well.") discussionListPage.selectTopic(newTopicName) discussionDetailsPage.assertNoRepliesDisplayed() @@ -147,7 +145,7 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert the the previously sent reply ($replyMessage) is displayed on the details page.") discussionDetailsPage.assertRepliesDisplayed() - Log.d(STEP_TAG,"Navigate back to Discussions Page.") + Log.d(STEP_TAG,"Navigate back to Discussion List Page.") Espresso.pressBack() Log.d(STEP_TAG,"Refresh the page. Assert that the previously sent reply has been counted, and there are no unread replies.") @@ -158,22 +156,6 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG, "Assert that the due date is the current date (in the expected format).") val currentDate = getCurrentDateInCanvasFormat() discussionListPage.assertDueDate(newTopicName, currentDate) - } - private fun createAnnouncement( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createAnnouncement( - courseId = course.id, - token = teacher.token - ) - - private fun createDiscussion( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = teacher.token - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt index b3cdea4a7b..15c3c673a3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt @@ -26,20 +26,19 @@ import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.DiscussionEntry import com.instructure.canvasapi2.utils.weave.awaitApiResponse import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.AttachmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.FileUploadType +import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.Randomizer +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.iso8601 import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.ViewUtils import com.instructure.student.ui.utils.seedData @@ -52,6 +51,7 @@ import java.io.FileWriter @HiltAndroidTest class FilesE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -67,8 +67,8 @@ class FilesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), allowedExtensions = listOf("txt")) Log.d(PREPARATION_TAG, "Seed a text file.") val submissionUploadInfo = uploadTextFile( @@ -78,20 +78,20 @@ class FilesE2ETest: StudentTest() { fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION ) - Log.d(PREPARATION_TAG,"Submit ${assignment.name} assignment for ${student.name} student.") - submitAssignment(course, assignment, submissionUploadInfo, student) + Log.d(PREPARATION_TAG,"Submit '${assignment.name}' assignment for '${student.name}' student.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(submissionUploadInfo.id)) - Log.d(STEP_TAG,"Seed a comment attachment upload.") + Log.d(STEP_TAG,"Seed a comment attachment (file) upload.") val commentUploadInfo = uploadTextFile( assignmentId = assignment.id, courseId = course.id, token = student.token, fileUploadType = FileUploadType.COMMENT_ATTACHMENT ) - commentOnSubmission(student, course, assignment, commentUploadInfo) + SubmissionsApi.commentOnSubmission(course.id, student.token, assignment.id, mutableListOf(commentUploadInfo.id)) - Log.d(STEP_TAG,"Seed a discussion for ${course.name} course.") - val discussionTopic = createDiscussion(course, student) + Log.d(STEP_TAG,"Seed a discussion for '${course.name}' course.") + val discussionTopic = DiscussionTopicsApi.createDiscussion(course.id, student.token) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -102,7 +102,7 @@ class FilesE2ETest: StudentTest() { Randomizer.randomTextFileName(Environment.getExternalStorageDirectory().absolutePath)) .apply { createNewFile() } - Log.d(STEP_TAG,"Add some random content to the ${discussionAttachmentFile.name} file.") + Log.d(STEP_TAG,"Add some random content to the '${discussionAttachmentFile.name}' file.") FileWriter(discussionAttachmentFile, true).apply { write(Randomizer.randomTextFileContents()) flush() @@ -111,7 +111,7 @@ class FilesE2ETest: StudentTest() { Log.d(PREPARATION_TAG,"Use real API (rather than seeding) to create a reply to our discussion that contains an attachment.") tryWeave { - awaitApiResponse { + awaitApiResponse { DiscussionManager.postToDiscussionTopic( canvasContext = CanvasContext.emptyCourseContext(id = course.id), topicId = discussionTopic.id, @@ -124,22 +124,22 @@ class FilesE2ETest: StudentTest() { Log.v(PREPARATION_TAG, "Discussion post error: $it") } - Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menubar.") + Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menu bar.") leftSideNavigationDrawerPage.clickFilesMenu() Log.d(STEP_TAG,"Assert that there is a directory called 'Submissions' is displayed.") fileListPage.assertItemDisplayed("Submissions") - Log.d(STEP_TAG,"Select 'Submissions' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Select 'Submissions' directory. Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.selectItem("Submissions") - Log.d(STEP_TAG,"Assert that ${course.name} course is displayed.") + Log.d(STEP_TAG,"Assert that '${course.name}' course is displayed.") fileListPage.assertItemDisplayed(course.name) - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") fileListPage.selectItem(course.name) - Log.d(STEP_TAG,"Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.assertItemDisplayed(submissionUploadInfo.fileName) Log.d(STEP_TAG,"Navigate back to File List Page.") @@ -148,37 +148,37 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that there is a directory called 'unfiled' is displayed.") fileListPage.assertItemDisplayed("unfiled") // Our discussion attachment goes under "unfiled" - Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.selectItem("unfiled") fileListPage.assertItemDisplayed(discussionAttachmentFile.name) Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to Assignments Page.") courseBrowserPage.selectAssignments() - Log.d(STEP_TAG,"Click on ${assignment.name} assignment.") + Log.d(STEP_TAG,"Click on '${assignment.name}' assignment.") assignmentListPage.clickAssignment(assignment) Log.d(STEP_TAG,"Navigate to Submission Details Page and open Files Tab.") assignmentDetailsPage.goToSubmissionDetails() submissionDetailsPage.openFiles() - Log.d(STEP_TAG,"Assert that ${submissionUploadInfo.fileName} file has been displayed.") + Log.d(STEP_TAG,"Assert that '${submissionUploadInfo.fileName}' file has been displayed.") submissionDetailsPage.assertFileDisplayed(submissionUploadInfo.fileName) - Log.d(STEP_TAG,"Open Comments Tab. Assert that ${commentUploadInfo.fileName} file is displayed as a comment by ${student.name} student.") + Log.d(STEP_TAG,"Open Comments Tab. Assert that '${commentUploadInfo.fileName}' file is displayed as a comment by '${student.name}' student.") submissionDetailsPage.openComments() submissionDetailsPage.assertCommentAttachmentDisplayed(commentUploadInfo.fileName, student) Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(4) - Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menubar.") + Log.d(STEP_TAG,"Navigate to 'Files' menu in user left-side menu bar.") leftSideNavigationDrawerPage.clickFilesMenu() Log.d(STEP_TAG,"Assert that there is a directory called 'unfiled' is displayed.") @@ -194,18 +194,18 @@ class FilesE2ETest: StudentTest() { fileListPage.assertItemNotDisplayed("unfiled") fileListPage.searchable.pressSearchBackButton() - Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") + Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that '${discussionAttachmentFile.name}' file is displayed on the File List Page.") fileListPage.selectItem("unfiled") fileListPage.assertItemDisplayed(discussionAttachmentFile.name) val newFileName = "newTextFileName.txt" - Log.d(STEP_TAG,"Rename ${discussionAttachmentFile.name} file to: $newFileName.") + Log.d(STEP_TAG,"Rename '${discussionAttachmentFile.name}' file to: '$newFileName'.") fileListPage.renameFile(discussionAttachmentFile.name, newFileName) - Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: $newFileName.") + Log.d(STEP_TAG,"Assert that the file is displayed with it's new file name: '$newFileName'.") fileListPage.assertItemDisplayed(newFileName) - Log.d(STEP_TAG,"Delete $newFileName file.") + Log.d(STEP_TAG,"Delete '$newFileName' file.") fileListPage.deleteFile(newFileName) Log.d(STEP_TAG,"Assert that empty view is displayed after deletion.") @@ -224,54 +224,4 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName' is displayed.") fileListPage.assertItemDisplayed(testFolderName) } - - private fun commentOnSubmission( - student: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - commentUploadInfo: AttachmentApiModel - ) { - SubmissionsApi.commentOnSubmission( - studentToken = student.token, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(commentUploadInfo.id) - ) - } - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = false, - submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), - allowedExtensions = listOf("txt"), - teacherToken = teacher.token - ) - ) - - private fun submitAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - submissionUploadInfo: AttachmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_UPLOAD, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(submissionUploadInfo.id), - studentToken = student.token - ) - } - - private fun createDiscussion( - course: CourseApiModel, - student: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = student.token - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt index c176601e99..253bc5dcdf 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/GradesE2ETest.kt @@ -12,9 +12,6 @@ import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.QuizAnswer import com.instructure.dataseeding.model.QuizQuestion @@ -31,6 +28,7 @@ import org.junit.Test @HiltAndroidTest class GradesE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -46,9 +44,9 @@ class GradesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment = createAssignment(course, teacher) - val assignment2 = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, gradingType = GradingType.PERCENT, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601) + val assignment2 = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, gradingType = GradingType.PERCENT, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601) Log.d(PREPARATION_TAG,"Create a quiz with some questions.") val quizQuestions = makeQuizQuestions() @@ -60,7 +58,7 @@ class GradesE2ETest: StudentTest() { tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Navigate to Grades Page.") @@ -71,7 +69,7 @@ class GradesE2ETest: StudentTest() { val assignmentMatcher = withText(assignment.name) val quizMatcher = withText(quiz.title) - Log.d(STEP_TAG,"Refresh the page. Assert that the ${assignment.name} assignment and ${quiz.title} quiz are displayed and there is no grade for them.") + Log.d(STEP_TAG,"Refresh the page. Assert that the '${assignment.name}' assignment and '${quiz.title}' quiz are displayed and there is no grade for them.") courseGradesPage.refresh() courseGradesPage.assertItemDisplayed(assignmentMatcher) courseGradesPage.assertGradeNotDisplayed(assignmentMatcher) @@ -81,7 +79,7 @@ class GradesE2ETest: StudentTest() { Log.d(STEP_TAG,"Check in the 'What-If Score' checkbox.") courseGradesPage.toggleWhatIf() - Log.d(STEP_TAG,"Enter '12' as a what-if grade for ${assignment.name} assignment.") + Log.d(STEP_TAG,"Enter '12' as a what-if grade for '${assignment.name}' assignment.") courseGradesPage.enterWhatIfGrade(assignmentMatcher, "12") Log.d(STEP_TAG,"Assert that 'Total Grade' contains the score '80%'.") @@ -94,10 +92,10 @@ class GradesE2ETest: StudentTest() { courseGradesPage.assertTotalGrade(withText(R.string.noGradeText)) Log.d(PREPARATION_TAG,"Seed a submission for '${assignment.name}' assignment.") - submitAssignment(course, assignment, student) + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment.id, SubmissionType.ONLINE_TEXT_ENTRY) Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignment.name}' assignment.") - gradeSubmission(teacher, course, assignment, student, "9",false) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment.id, student.id, postedGrade = "9") Log.d(STEP_TAG,"Refresh the page. Assert that the assignment's score is '60%'.") courseGradesPage.refresh() @@ -114,10 +112,10 @@ class GradesE2ETest: StudentTest() { courseGradesPage.refreshUntilAssertTotalGrade(containsTextCaseInsensitive("60")) Log.d(PREPARATION_TAG,"Seed a submission for '${assignment2.name}' assignment.") - submitAssignment(course, assignment2, student) + SubmissionsApi.submitCourseAssignment(course.id, student.token, assignment2.id, SubmissionType.ONLINE_TEXT_ENTRY) Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignment2.name}' assignment.") - gradeSubmission(teacher, course, assignment2, student, "10", excused = false) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment2.id, student.id, postedGrade = "10") Log.d(STEP_TAG,"Assert that we can see the correct score at the '${assignment2.name}' assignment (66.67%) and at the total score as well (63.33%).") courseGradesPage.refresh() @@ -128,13 +126,13 @@ class GradesE2ETest: StudentTest() { courseGradesPage.refreshUntilAssertTotalGrade(containsTextCaseInsensitive("63.33")) Log.d(PREPARATION_TAG,"Grade the previously seeded submission for '${assignment.name}' assignment.") - gradeSubmission(teacher, course, assignment, student, excused = true) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment.id, student.id, excused = true) courseGradesPage.refresh() Log.d(STEP_TAG,"Assert that we can see the correct score (66.67%).") courseGradesPage.refreshUntilAssertTotalGrade(containsTextCaseInsensitive("66.67")) - gradeSubmission(teacher, course, assignment, student, "9",false) + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment.id, student.id, postedGrade = "9") courseGradesPage.refresh() Log.d(STEP_TAG,"Assert that we can see the correct score (63.33%).") @@ -194,54 +192,4 @@ class GradesE2ETest: StudentTest() { ) ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ): AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = true, - dueAt = 1.days.fromNow.iso8601, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - gradingType = GradingType.PERCENT, - pointsPossible = 15.0 - ) - ) - } - - private fun submitAssignment( - course: CourseApiModel, - assignment: AssignmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_TEXT_ENTRY, - courseId = course.id, - assignmentId = assignment.id, - fileIds = mutableListOf(), - studentToken = student.token - ) - } - - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - assignment: AssignmentApiModel, - student: CanvasUserApiModel, - postedGrade: String? = null, - excused: Boolean, - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = assignment.id, - studentId = student.id, - postedGrade = postedGrade, - excused = excused - ) - } - } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt index 6be6ca1fed..e14c4b2683 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt @@ -28,7 +28,6 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.ConversationsApi import com.instructure.dataseeding.api.GroupsApi -import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData @@ -46,6 +45,7 @@ class InboxE2ETest: StudentTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) fun testInboxSelectedButtonActionsE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 2, teachers = 1, courses = 1) val teacher = data.teachersList[0] @@ -53,9 +53,11 @@ class InboxE2ETest: StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] + Log.d(PREPARATION_TAG, "Create a course group category and a group based on that category.") val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) val group = GroupsApi.createGroup(groupCategory.id, teacher.token) - Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} and ${student2.name} students to the group: ${group.name}.") + + Log.d(PREPARATION_TAG, "Create group membership for '${student1.name}' and '${student2.name}' students to the group: '${group.name}'.") GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) GroupsApi.createGroupMembership(group.id, student2.id, teacher.token) @@ -68,19 +70,19 @@ class InboxE2ETest: StudentTest() { dashboardPage.clickInboxTab() inboxPage.assertInboxEmpty() - Log.d(PREPARATION_TAG,"Seed an email from the teacher to ${student1.name} and ${student2.name} students.") - val seededConversation = createConversation(teacher, student1, student2)[0] + Log.d(PREPARATION_TAG,"Seed an email from the teacher to '${student1.name}' and '${student2.name}' students.") + val seededConversation = ConversationsApi.createConversation(teacher.token, listOf(student1.id.toString(), student2.id.toString()))[0] Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") refresh() inboxPage.assertHasConversation() inboxPage.assertConversationDisplayed(seededConversation) - Log.d(STEP_TAG,"Select ${seededConversation.subject} conversation. Assert that is has not been starred already.") + Log.d(STEP_TAG,"Select '${seededConversation.subject}' conversation. Assert that is has not been starred already.") inboxPage.openConversation(seededConversation) inboxConversationPage.assertNotStarred() - Log.d(STEP_TAG,"Toggle Starred to mark ${seededConversation.subject} conversation as favourite. Assert that it has became starred.") + Log.d(STEP_TAG,"Toggle Starred to mark '${seededConversation.subject}' conversation as favourite. Assert that it has became starred.") inboxConversationPage.toggleStarred() inboxConversationPage.assertStarred() @@ -88,25 +90,25 @@ class InboxE2ETest: StudentTest() { Espresso.pressBack() // To main inbox page inboxPage.assertConversationStarred(seededConversation.subject) - Log.d(STEP_TAG,"Select ${seededConversation.subject} conversation. Mark as Unread by clicking on the 'More Options' menu, 'Mark as Unread' menu point.") + Log.d(STEP_TAG,"Select '${seededConversation.subject}' conversation. Mark as Unread by clicking on the 'More Options' menu, 'Mark as Unread' menu point.") inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) inboxPage.openConversation(seededConversation) inboxConversationPage.markUnread() //After select 'Mark as Unread', we will be navigated back to Inbox Page - Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation has been marked as unread.") + Log.d(STEP_TAG,"Assert that '${seededConversation.subject}' conversation has been marked as unread.") inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) - Log.d(STEP_TAG,"Select ${seededConversation.subject} conversation. Archive it by clicking on the 'More Options' menu, 'Archive' menu point.") + Log.d(STEP_TAG,"Select '${seededConversation.subject}' conversation. Archive it by clicking on the 'More Options' menu, 'Archive' menu point.") inboxPage.openConversation(seededConversation) inboxConversationPage.archive() //After select 'Archive', we will be navigated back to Inbox Page - Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation has removed from 'All' tab.") //TODO: Discuss this logic if it's ok if we don't show Archived messages on 'All' tab... + Log.d(STEP_TAG,"Assert that '${seededConversation.subject}' conversation has removed from 'All' tab.") //TODO: Discuss this logic if it's ok if we don't show Archived messages on 'All' tab... inboxPage.assertConversationNotDisplayed(seededConversation) Log.d(STEP_TAG,"Select 'Archived' conversation filter.") inboxPage.filterInbox("Archived") - Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation is displayed by the 'Archived' filter.") + Log.d(STEP_TAG,"Assert that '${seededConversation.subject}' conversation is displayed by the 'Archived' filter.") inboxPage.assertConversationDisplayed(seededConversation) Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation. Assert that the selected number of conversations on the toolbar is 1." + @@ -114,18 +116,17 @@ class InboxE2ETest: StudentTest() { inboxPage.selectConversation(seededConversation) inboxPage.assertSelectedConversationNumber("1") inboxPage.clickUnArchive() - inboxPage.assertInboxEmpty() inboxPage.assertConversationNotDisplayed(seededConversation.subject) sleep(2000) - Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seededConversation.subject} conversation is displayed.") + Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that '${seededConversation.subject}' conversation is displayed.") inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and star it." + - "Assert that the selected number of conversations on the toolbar is 1 and the conversation is starred.") + Log.d(STEP_TAG, "Select the conversation '${seededConversation.subject}' and unstar it." + + "Assert that the selected number of conversations on the toolbar is 1 and the conversation is not starred.") inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.assertSelectedConversationNumber("1") inboxPage.clickUnstar() @@ -134,7 +135,7 @@ class InboxE2ETest: StudentTest() { inboxPage.assertConversationNotStarred(seededConversation.subject) } - Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and archive it. Assert that it has not displayed in the 'INBOX' scope.") + Log.d(STEP_TAG, "Select the conversation '${seededConversation.subject}' and archive it. Assert that it has not displayed in the 'INBOX' scope.") inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.clickArchive() inboxPage.assertConversationNotDisplayed(seededConversation.subject) @@ -153,7 +154,7 @@ class InboxE2ETest: StudentTest() { inboxPage.filterInbox("Starred") inboxPage.assertConversationNotDisplayed(seededConversation.subject) - Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seededConversation.subject} conversation is NOT displayed because it is archived yet.") + Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that '${seededConversation.subject}' conversation is NOT displayed because it is archived yet.") inboxPage.filterInbox("Inbox") inboxPage.assertConversationNotDisplayed(seededConversation.subject) @@ -192,9 +193,11 @@ class InboxE2ETest: StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] + Log.d(PREPARATION_TAG, "Create a course group category and a group based on that category.") val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) val group = GroupsApi.createGroup(groupCategory.id, teacher.token) - Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} and ${student2.name} students to the group: ${group.name}.") + + Log.d(PREPARATION_TAG, "Create group membership for '${student1.name}' and '${student2.name}' students to the group: '${group.name}'.") GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) GroupsApi.createGroupMembership(group.id, student2.id, teacher.token) @@ -207,8 +210,8 @@ class InboxE2ETest: StudentTest() { dashboardPage.clickInboxTab() inboxPage.assertInboxEmpty() - Log.d(PREPARATION_TAG,"Seed an email from the teacher to ${student1.name} and ${student2.name} students.") - val seededConversation = createConversation(teacher, student1, student2)[0] + Log.d(PREPARATION_TAG,"Seed an email from the teacher to '${student1.name}' and '${student2.name}' students.") + val seededConversation = ConversationsApi.createConversation(teacher.token, listOf(student1.id.toString(), student2.id.toString()))[0] Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") refresh() @@ -220,7 +223,7 @@ class InboxE2ETest: StudentTest() { val newMessageSubject = "Hey There" val newMessage = "Just checking in" - Log.d(STEP_TAG,"Create a new message with subject: $newMessageSubject, and message: $newMessage") + Log.d(STEP_TAG,"Create a new message with subject: '$newMessageSubject', and message: '$newMessage'") newMessagePage.populateMessage(course, student2, newMessageSubject, newMessage) Log.d(STEP_TAG,"Click on 'Send' button.") @@ -231,7 +234,7 @@ class InboxE2ETest: StudentTest() { val newGroupMessageSubject = "Group Message" val newGroupMessage = "Testing Group ${group.name}" - Log.d(STEP_TAG,"Create a new message with subject: $newGroupMessageSubject, and message: $newGroupMessage") + Log.d(STEP_TAG,"Create a new message with subject: '$newGroupMessageSubject', and message: '$newGroupMessage'") newMessagePage.populateGroupMessage(group, student2, newGroupMessageSubject, newGroupMessage) Log.d(STEP_TAG,"Click on 'Send' button.") @@ -243,7 +246,7 @@ class InboxE2ETest: StudentTest() { inboxPage.goToDashboard() dashboardPage.waitForRender() - Log.d(STEP_TAG,"Log out with ${student1.name} student.") + Log.d(STEP_TAG,"Log out with '${student1.name}' student.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG,"Login with user: ${student2.name}, login id: ${student2.loginId}.") @@ -256,13 +259,13 @@ class InboxE2ETest: StudentTest() { inboxPage.assertConversationDisplayed(newMessageSubject) inboxPage.assertConversationDisplayed("Group Message") - Log.d(STEP_TAG,"Select $newGroupMessageSubject conversation.") + Log.d(STEP_TAG,"Select '$newGroupMessageSubject' conversation.") inboxPage.openConversation(newMessageSubject) val newReplyMessage = "This is a quite new reply message." Log.d(STEP_TAG,"Reply to $newGroupMessageSubject conversation with '$newReplyMessage' message. Assert that the reply is displayed.") inboxConversationPage.replyToMessage(newReplyMessage) - Log.d(STEP_TAG,"Delete $newReplyMessage reply and assert is has been deleted.") + Log.d(STEP_TAG,"Delete '$newReplyMessage' reply and assert is has been deleted.") inboxConversationPage.deleteMessage(newReplyMessage) inboxConversationPage.assertMessageNotDisplayed(newReplyMessage) @@ -272,7 +275,7 @@ class InboxE2ETest: StudentTest() { inboxPage.assertConversationDisplayed(seededConversation) inboxPage.assertConversationDisplayed("Group Message") - Log.d(STEP_TAG, "Navigate to 'INBOX' scope and seledct '$newGroupMessageSubject' conversation.") + Log.d(STEP_TAG, "Navigate to 'INBOX' scope and select '$newGroupMessageSubject' conversation.") inboxPage.filterInbox("Inbox") inboxPage.selectConversation(newGroupMessageSubject) @@ -286,6 +289,7 @@ class InboxE2ETest: StudentTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) fun testInboxSwipeGesturesE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 2, teachers = 1, courses = 1) val teacher = data.teachersList[0] @@ -293,9 +297,11 @@ class InboxE2ETest: StudentTest() { val student1 = data.studentsList[0] val student2 = data.studentsList[1] + Log.d(PREPARATION_TAG, "Create a course group category and a group based on that category.") val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) val group = GroupsApi.createGroup(groupCategory.id, teacher.token) - Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} and ${student2.name} students to the group: ${group.name}.") + + Log.d(PREPARATION_TAG, "Create group membership for '${student1.name}' and '${student2.name}' students to the group: '${group.name}'.") GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) GroupsApi.createGroupMembership(group.id, student2.id, teacher.token) @@ -308,8 +314,8 @@ class InboxE2ETest: StudentTest() { dashboardPage.clickInboxTab() inboxPage.assertInboxEmpty() - Log.d(PREPARATION_TAG,"Seed an email from the teacher to ${student1.name} and ${student2.name} students.") - val seededConversation = createConversation(teacher, student1, student2)[0] + Log.d(PREPARATION_TAG,"Seed an email from the teacher to '${student1.name}' and '${student2.name}' students.") + val seededConversation = ConversationsApi.createConversation(teacher.token, listOf(student1.id.toString(), student2.id.toString()))[0] Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") refresh() @@ -405,7 +411,7 @@ class InboxE2ETest: StudentTest() { inboxPage.openConversationWithRecipients(recipientList) inboxConversationPage.assertMessageDisplayed(questionText) - Log.d(STEP_TAG,"Log out with ${student.name} student.") + Log.d(STEP_TAG,"Log out with '${student.name}' student.") Espresso.pressBack() leftSideNavigationDrawerPage.logout() @@ -423,13 +429,4 @@ class InboxE2ETest: StudentTest() { inboxConversationPage.assertMessageDisplayed(questionText) inboxConversationPage.assertNoSubjectDisplayed() } - - private fun createConversation( - teacher: CanvasUserApiModel, - student1: CanvasUserApiModel, - student2: CanvasUserApiModel - ) = ConversationsApi.createConversation( - token = teacher.token, - recipients = listOf(student1.id.toString(), student2.id.toString()) - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt index c9d3d7b60a..12526c1bac 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt @@ -39,6 +39,7 @@ import org.junit.Test @HiltAndroidTest class LoginE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -59,7 +60,7 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(student1) - Log.d(STEP_TAG, "Log out with ${student1.name} student.") + Log.d(STEP_TAG, "Log out with '${student1.name}' student.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG, "Login with user: ${student2.name}, login id: ${student2.loginId}.") @@ -83,15 +84,15 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that ${student1.name} and ${student2.name} students are displayed within the previous login section.") + Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that '${student1.name}' and '${student2.name}' students are displayed within the previous login section.") loginLandingPage.assertDisplaysPreviousLogins() loginLandingPage.assertPreviousLoginUserDisplayed(student1.name) loginLandingPage.assertPreviousLoginUserDisplayed(student2.name) - Log.d(STEP_TAG, "Remove ${student1.name} student from the previous login section.") + Log.d(STEP_TAG, "Remove '${student1.name}' student from the previous login section.") loginLandingPage.removeUserFromPreviousLogins(student1.name) - Log.d(STEP_TAG, "Login with the previous user, ${student2.name}, with one click, by clicking on the user's name on the bottom.") + Log.d(STEP_TAG, "Login with the previous user, '${student2.name}', with one click, by clicking on the user's name on the bottom.") loginLandingPage.loginWithPreviousUser(student2) Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") @@ -100,18 +101,17 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Change User' button on the left-side menu.") leftSideNavigationDrawerPage.clickChangeUserMenu() - Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that ${student1.name} and ${student2.name} students are displayed within the previous login section.") + Log.d(STEP_TAG, "Assert that the previously logins has been displayed. Assert that '${student1.name}' and '${student2.name}' students are displayed within the previous login section.") loginLandingPage.assertDisplaysPreviousLogins() loginLandingPage.assertPreviousLoginUserDisplayed(student2.name) - Log.d(STEP_TAG, "Remove ${student2.name} student from the previous login section.") + Log.d(STEP_TAG, "Remove '${student2.name}' student from the previous login section.") loginLandingPage.removeUserFromPreviousLogins(student2.name) - Log.d(STEP_TAG, "Assert that none of the students, ${student1.name} and ${student2.name} are displayed and not even the 'Previous Logins' label is displayed.") + Log.d(STEP_TAG, "Assert that none of the students, '${student1.name}' and '${student2.name}' are displayed and not even the 'Previous Logins' label is displayed.") loginLandingPage.assertPreviousLoginUserNotExist(student1.name) loginLandingPage.assertPreviousLoginUserNotExist(student2.name) loginLandingPage.assertNotDisplaysPreviousLogins() - } @E2E @@ -138,7 +138,6 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(student2) - } @E2E @@ -162,37 +161,37 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") loginWithUser(student) - Log.d(STEP_TAG,"Validate ${student.name} user's role as a Student.") + Log.d(STEP_TAG,"Validate '${student.name}' user's role as a Student.") validateUserAndRole(student, course, "Student") Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${student.name} student.") + Log.d(STEP_TAG,"Log out with '${student.name}' student.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG,"Login with user: ${teacher.name}, login id: ${teacher.loginId}.") loginWithUser(teacher, true) - Log.d(STEP_TAG,"Validate ${teacher.name} user's role as a Teacher.") + Log.d(STEP_TAG,"Validate '${teacher.name}' user's role as a Teacher.") validateUserAndRole(teacher, course, "Teacher") Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${teacher.name} teacher.") + Log.d(STEP_TAG,"Log out with '${teacher.name}' teacher.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG,"Login with user: ${ta.name}, login id: ${ta.loginId}.") loginWithUser(ta, true) - Log.d(STEP_TAG,"Validate ${ta.name} user's role as a TA.") + Log.d(STEP_TAG,"Validate '${ta.name}' user's role as a TA (Teacher Assistant).") validateUserAndRole(ta, course, "TA") Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${ta.name} teacher assistant.") + Log.d(STEP_TAG,"Log out with '${ta.name}' teacher assistant.") leftSideNavigationDrawerPage.logout() Log.d(STEP_TAG,"Login with user: ${parent.name}, login id: ${parent.loginId}.") @@ -201,7 +200,7 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(parent) - Log.d(STEP_TAG,"Log out with ${parent.name} parent.") + Log.d(STEP_TAG,"Log out with '${parent.name}' parent.") leftSideNavigationDrawerPage.logout() } @@ -261,43 +260,29 @@ class LoginE2ETest : StudentTest() { val enrollmentsService = retrofitClient.create(EnrollmentsApi.EnrollmentsService::class.java) Log.d(PREPARATION_TAG,"Create student, teacher, and a course via API.") - val student = UserApi.createCanvasUser(userService = userService, userDomain = domain) - val teacher = UserApi.createCanvasUser(userService = userService, userDomain = domain) + val student = UserApi.createCanvasUser(userService, domain) + val teacher = UserApi.createCanvasUser(userService, domain) val course = CoursesApi.createCourse(coursesService = coursesService) - Log.d(PREPARATION_TAG,"Enroll ${student.name} student to ${course.name} course.") - enrollUser(course, student, STUDENT_ENROLLMENT, enrollmentsService) + Log.d(PREPARATION_TAG,"Enroll '${student.name}' student to '${course.name}' course.") + EnrollmentsApi.enrollUser(course.id, student.id, STUDENT_ENROLLMENT, enrollmentsService) - Log.d(PREPARATION_TAG,"Enroll ${teacher.name} teacher to ${course.name} course.") - enrollUser(course, teacher, TEACHER_ENROLLMENT, enrollmentsService) + Log.d(PREPARATION_TAG,"Enroll '${teacher.name}' teacher to '${course.name}' course.") + EnrollmentsApi.enrollUser(course.id, teacher.id, TEACHER_ENROLLMENT, enrollmentsService) Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") loginWithUser(student) - Log.d(STEP_TAG,"Attempt to sign into our vanity domain, and validate ${student.name} user's role as a Student.") + Log.d(STEP_TAG,"Attempt to sign into our vanity domain, and validate '${student.name}' user's role as a Student.") validateUserAndRole(student, course,"Student" ) Log.d(STEP_TAG,"Navigate back to Dashboard Page.") ViewUtils.pressBackButton(2) - Log.d(STEP_TAG,"Log out with ${student.name} student.") + Log.d(STEP_TAG,"Log out with '${student.name}' student.") leftSideNavigationDrawerPage.logout() } - private fun enrollUser( - course: CourseApiModel, - student: CanvasUserApiModel, - enrollmentType: String, - enrollmentsService: EnrollmentsApi.EnrollmentsService - ) { - EnrollmentsApi.enrollUser( - courseId = course.id, - userId = student.id, - enrollmentType = enrollmentType, - enrollmentService = enrollmentsService - ) - } - private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { Thread.sleep(5100) //Need to wait > 5 seconds before each login attempt because of new 'too many attempts' login policy on web. @@ -311,7 +296,7 @@ class LoginE2ETest : StudentTest() { loginLandingPage.clickFindMySchoolButton() } - Log.d(STEP_TAG,"Enter domain: ${user.domain}.") + Log.d(STEP_TAG,"Enter domain: '${user.domain}'.") loginFindSchoolPage.enterDomain(user.domain) Log.d(STEP_TAG,"Click on 'Next' button on the Toolbar.") @@ -324,7 +309,7 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on last saved school's button.") loginLandingPage.clickOnLastSavedSchoolButton() - Log.d(STEP_TAG, "Login with ${user.name} user.") + Log.d(STEP_TAG, "Login with '${user.name}' user.") loginSignInPage.loginAs(user) } @@ -333,11 +318,11 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG,"Assert that the Dashboard Page is the landing page and it is loaded successfully.") assertDashboardPageDisplayed(user) - Log.d(STEP_TAG,"Navigate to 'People' Page of ${course.name} course.") + Log.d(STEP_TAG,"Navigate to 'People' Page of '${course.name}' course.") dashboardPage.selectCourse(course) courseBrowserPage.selectPeople() - Log.d(STEP_TAG,"Assert that ${user.name} user's role is: $role.") + Log.d(STEP_TAG,"Assert that '${user.name}' user's role is: $role.") peopleListPage.assertPersonListed(user, role) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt index 862b6a0c87..cf15715843 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt @@ -28,9 +28,7 @@ import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.api.ModulesApi import com.instructure.dataseeding.api.PagesApi import com.instructure.dataseeding.api.QuizzesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.model.ModuleApiModel +import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.ModuleItemTypes import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days @@ -44,6 +42,7 @@ import org.junit.Test @HiltAndroidTest class ModulesE2ETest: StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit @@ -59,51 +58,51 @@ class ModulesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seeding assignment for ${course.name} course.") - val assignment1 = createAssignment(course, true, teacher, 1.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding assignment for '${course.name}' course.") + val assignment1 = AssignmentsApi.createAssignment(course.id, teacher.token, withDescription = true, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seeding another assignment for ${course.name} course.") - val assignment2 = createAssignment(course, true, teacher, 2.days.fromNow.iso8601) + Log.d(PREPARATION_TAG,"Seeding another assignment for '${course.name}' course.") + val assignment2 = AssignmentsApi.createAssignment(course.id, teacher.token, dueAt = 2.days.fromNow.iso8601, withDescription = true, gradingType = GradingType.POINTS, pointsPossible = 15.0, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Create a PUBLISHED quiz for ${course.name} course.") - val quiz1 = createQuiz(course, teacher) + Log.d(PREPARATION_TAG,"Create a PUBLISHED quiz for '${course.name}' course.") + val quiz1 = QuizzesApi.createQuiz(course.id, teacher.token, dueAt = 3.days.fromNow.iso8601) - Log.d(PREPARATION_TAG,"Create a page for ${course.name} course.") - val page1 = createCoursePage(course, teacher) + Log.d(PREPARATION_TAG,"Create a page for '${course.name}' course.") + val page1 = PagesApi.createCoursePage(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Create a discussion topic for ${course.name} course.") - val discussionTopic1 = createDiscussion(course, teacher) + Log.d(PREPARATION_TAG,"Create a discussion topic for '${course.name}' course.") + val discussionTopic1 = DiscussionTopicsApi.createDiscussion(course.id, teacher.token) //Modules start out as unpublished. - Log.d(PREPARATION_TAG,"Create a module for ${course.name} course.") - val module1 = createModule(course, teacher) + Log.d(PREPARATION_TAG,"Create a module for '${course.name}' course.") + val module1 = ModulesApi.createModule(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Create another module for ${course.name} course.") - val module2 = createModule(course, teacher) + Log.d(PREPARATION_TAG,"Create another module for '${course.name}' course.") + val module2 = ModulesApi.createModule(course.id, teacher.token) - Log.d(PREPARATION_TAG,"Associate ${assignment1.name} assignment with ${module1.name} module.") - createModuleItem(course.id, module1.id, teacher, assignment1.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment1.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${assignment1.name}' assignment with '${module1.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module1.id, assignment1.name, ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment1.id.toString()) - Log.d(PREPARATION_TAG,"Associate ${quiz1.title} quiz with ${module1.name} module.") - createModuleItem(course.id, module1.id, teacher, quiz1.title, ModuleItemTypes.QUIZ.stringVal, quiz1.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${quiz1.title}' quiz with '${module1.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module1.id, quiz1.title, ModuleItemTypes.QUIZ.stringVal, contentId = quiz1.id.toString()) - Log.d(PREPARATION_TAG,"Associate ${assignment2.name} assignment with ${module2.name} module.") - createModuleItem(course.id, module2.id, teacher, assignment2.name, ModuleItemTypes.ASSIGNMENT.stringVal, assignment2.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${assignment2.name}' assignment with '${module2.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, assignment2.name, ModuleItemTypes.ASSIGNMENT.stringVal, contentId = assignment2.id.toString()) - Log.d(PREPARATION_TAG,"Associate ${page1.title} page with ${module2.name} module.") - createModuleItem(course.id, module2.id, teacher, page1.title, ModuleItemTypes.PAGE.stringVal, null, page1.url) + Log.d(PREPARATION_TAG,"Associate '${page1.title}' page with '${module2.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, page1.title, ModuleItemTypes.PAGE.stringVal, pageUrl = page1.url) - Log.d(PREPARATION_TAG,"Associate ${discussionTopic1.title} discussion topic with ${module2.name} module.") - createModuleItem(course.id, module2.id, teacher, discussionTopic1.title, ModuleItemTypes.DISCUSSION.stringVal, discussionTopic1.id.toString()) + Log.d(PREPARATION_TAG,"Associate '${discussionTopic1.title}' discussion topic with '${module2.name}' module.") + ModulesApi.createModuleItem(course.id, teacher.token, module2.id, discussionTopic1.title, ModuleItemTypes.DISCUSSION.stringVal, contentId = discussionTopic1.id.toString()) Log.d(STEP_TAG, "Login with user: ${teacher.name}, login id: ${teacher.loginId}.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Assert that ${course.name} course is displayed.") + Log.d(STEP_TAG,"Assert that '${course.name}' course is displayed.") dashboardPage.assertDisplaysCourse(course) - Log.d(STEP_TAG,"Select ${course.name} course.") + Log.d(STEP_TAG,"Select '${course.name}' course.") dashboardPage.selectCourse(course) Log.d(STEP_TAG,"Assert that there are no modules displayed yet because there are not published. Assert that the 'Modules' Tab is not displayed as well.") @@ -117,11 +116,11 @@ class ModulesE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate back to Course Browser Page.") Espresso.pressBack() - Log.d(PREPARATION_TAG,"Publish ${module1.name} module.") - publishModule(course, module1, teacher) + Log.d(PREPARATION_TAG,"Publish '${module1.name}' module.") + ModulesApi.updateModule(course.id, teacher.token, module1.id, published = true) - Log.d(PREPARATION_TAG,"Publish ${module2.name} module.") - publishModule(course, module2, teacher) + Log.d(PREPARATION_TAG,"Publish '${module2.name}' module.") + ModulesApi.updateModule(course.id, teacher.token, module2.id, published = true) Log.d(STEP_TAG,"Refresh the page. Assert that the 'Modules' Tab is displayed.") courseBrowserPage.refresh() @@ -130,13 +129,13 @@ class ModulesE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate to Modules Page.") courseBrowserPage.selectModules() - Log.d(STEP_TAG,"Assert that ${module1.name} module is displayed with the following items: ${assignment1.name} assignment, ${quiz1.title} quiz.") + Log.d(STEP_TAG,"Assert that '${module1.name}' module is displayed with the following items: '${assignment1.name}' assignment, '${quiz1.title}' quiz.") modulesPage.assertModuleDisplayed(module1) modulesPage.assertModuleItemDisplayed(module1, assignment1.name) modulesPage.assertModuleItemDisplayed(module1, quiz1.title) - Log.d(STEP_TAG,"Assert that ${module2.name} module is displayed with the following items: ${assignment2.name} assignment," + - " ${page1.title} page, ${discussionTopic1.title} discussion topic.") + Log.d(STEP_TAG,"Assert that '${module2.name}' module is displayed with the following items: '${assignment2.name}' assignment," + + " '${page1.title}' page, '${discussionTopic1.title}' discussion topic.") modulesPage.assertModuleDisplayed(module2) modulesPage.assertModuleItemDisplayed(module2, assignment2.name) modulesPage.assertModuleItemDisplayed(module2, page1.title) @@ -150,97 +149,9 @@ class ModulesE2ETest: StudentTest() { modulesPage.clickOnModuleExpandCollapseIcon(module2.name) modulesPage.assertModulesAndItemsCount(7) // 2 modules titles, 2 module items in first module, 3 items in second module - Log.d(STEP_TAG, "Assert that ${assignment1.name} module item is displayed and open it. Assert that the Assignment Details page is displayed with the corresponding assignment title.") + Log.d(STEP_TAG, "Assert that '${assignment1.name}' module item is displayed and open it. Assert that the Assignment Details page is displayed with the corresponding assignment title.") modulesPage.assertAndClickModuleItem(module1.name, assignment1.name, true) assignmentDetailsPage.assertPageObjects() assignmentDetailsPage.assertAssignmentTitle(assignment1.name) } - - private fun publishModule( - course: CourseApiModel, - module1: ModuleApiModel, - teacher: CanvasUserApiModel - ) { - ModulesApi.updateModule( - courseId = course.id, - id = module1.id, - published = true, - teacherToken = teacher.token - ) - } - - private fun createModuleItem( - courseId: Long, - moduleId: Long, - teacher: CanvasUserApiModel, - title: String, - moduleItemType: String, - contentId: String?, - pageUrl: String? = null - ) { - ModulesApi.createModuleItem( - courseId = courseId, - moduleId = moduleId, - teacherToken = teacher.token, - title = title, - type = moduleItemType, - contentId = contentId, - pageUrl = pageUrl - ) - } - - private fun createModule( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = ModulesApi.createModule( - courseId = course.id, - teacherToken = teacher.token, - unlockAt = null - ) - - private fun createDiscussion( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = DiscussionTopicsApi.createDiscussion( - courseId = course.id, - token = teacher.token - ) - - private fun createCoursePage( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = PagesApi.createCoursePage( - courseId = course.id, - published = true, - frontPage = false, - token = teacher.token - ) - - private fun createQuiz( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) = QuizzesApi.createQuiz( - QuizzesApi.CreateQuizRequest( - courseId = course.id, - withDescription = true, - dueAt = 3.days.fromNow.iso8601, - token = teacher.token, - published = true - ) - ) - - private fun createAssignment( - course: CourseApiModel, - withDescription: Boolean, - teacher: CanvasUserApiModel, - dueAt: String - ) = AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - withDescription = withDescription, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - teacherToken = teacher.token, - dueAt = dueAt - ) - ) } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt index 83723950af..5278f57e4e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/NotificationsE2ETest.kt @@ -21,16 +21,12 @@ import androidx.test.espresso.NoMatchingViewException import com.instructure.canvas.espresso.E2E import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority -import com.instructure.canvas.espresso.ReleaseExclude import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.QuizzesApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.AssignmentApiModel -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GradingType import com.instructure.dataseeding.model.QuizAnswer import com.instructure.dataseeding.model.QuizQuestion @@ -47,11 +43,11 @@ import java.lang.Thread.sleep @HiltAndroidTest class NotificationsE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit override fun enableAndConfigureAccessibilityChecks() = Unit - @ReleaseExclude("The notifications API sometimes is slow and the test is breaking because the notifications aren't show up in time.") @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) @@ -63,10 +59,10 @@ class NotificationsE2ETest : StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seed an assignment for ${course.name} course.") - val testAssignment = createAssignment(course, teacher) + Log.d(PREPARATION_TAG,"Seed an assignment for '${course.name}' course.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) - Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course with some questions.") + Log.d(PREPARATION_TAG,"Seed a quiz for '${course.name}' course with some questions.") val quizQuestions = makeQuizQuestions() Log.d(PREPARATION_TAG,"Create and publish a quiz with the previously seeded questions.") @@ -111,11 +107,11 @@ class NotificationsE2ETest : StudentTest() { run submitAndGradeRepeat@{ repeat(10) { try { - Log.d(PREPARATION_TAG, "Submit ${testAssignment.name} assignment with student: ${student.name}.") - submitAssignment(course, testAssignment, student) + Log.d(PREPARATION_TAG, "Submit '${testAssignment.name}' assignment with student: '${student.name}'.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, testAssignment.id, SubmissionType.ONLINE_TEXT_ENTRY) - Log.d(PREPARATION_TAG, "Grade the submission of ${student.name} student for assignment: ${testAssignment.name}.") - gradeSubmission(teacher, course, testAssignment, student) + Log.d(PREPARATION_TAG, "Grade the submission of '${student.name}' student for assignment: '${testAssignment.name}'.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, testAssignment.id, student.id, postedGrade = "13") Log.d(STEP_TAG, "Refresh the Notifications Page. Assert that there is a notification about the submission grading appearing.") sleep(3000) //Let the submission api do it's job @@ -129,36 +125,6 @@ class NotificationsE2ETest : StudentTest() { } } - private fun gradeSubmission( - teacher: CanvasUserApiModel, - course: CourseApiModel, - testAssignment: AssignmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.gradeSubmission( - teacherToken = teacher.token, - courseId = course.id, - assignmentId = testAssignment.id, - studentId = student.id, - postedGrade = "13", - excused = false - ) - } - - private fun submitAssignment( - course: CourseApiModel, - testAssignment: AssignmentApiModel, - student: CanvasUserApiModel - ) { - SubmissionsApi.submitCourseAssignment( - submissionType = SubmissionType.ONLINE_TEXT_ENTRY, - courseId = course.id, - assignmentId = testAssignment.id, - studentToken = student.token, - fileIds = emptyList().toMutableList() - ) - } - private fun makeQuizQuestions() = listOf( QuizQuestion( questionText = "What's your favorite color?", @@ -181,21 +147,4 @@ class NotificationsE2ETest : StudentTest() { ) ) ) - - private fun createAssignment( - course: CourseApiModel, - teacher: CanvasUserApiModel - ) : AssignmentApiModel { - return AssignmentsApi.createAssignment( - AssignmentsApi.CreateAssignmentRequest( - courseId = course.id, - submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), - gradingType = GradingType.POINTS, - teacherToken = teacher.token, - pointsPossible = 15.0, - dueAt = 1.days.fromNow.iso8601 - ) - ) - } - } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt index 584fa2f705..8903a2c4cb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt @@ -25,9 +25,6 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.dataseeding.api.PagesApi -import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.dataseeding.util.Randomizer import com.instructure.student.ui.pages.WebViewTextCheck import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData @@ -53,16 +50,16 @@ class PagesE2ETest: StudentTest() { val course = data.coursesList[0] Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for '${course.name}' course.") - val pageUnpublished = createCoursePage(course, teacher, published = false, frontPage = false) + val pageUnpublished = PagesApi.createCoursePage(course.id, teacher.token, published = false) Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for '${course.name}' course.") - val pagePublished = createCoursePage(course, teacher, published = true, frontPage = false, editingRoles = "teachers,students", body = "