diff --git a/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift b/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift index c5847fd29c..cad27d0d15 100644 --- a/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift +++ b/Core/Core/Common/CommonModels/Analytics/PendoAnalyticsTracker.swift @@ -44,10 +44,20 @@ public final class PendoAnalyticsTracker { // MARK: Initialization + public final class PendoManagerMock: PendoManagerWrapper { + public init() {} + public func initWith(_ url: URL) { } + public func setup(_ appKey: String) { } + public func startSession(_ visitorId: String?, accountId: String?, visitorData: [AnyHashable: Any]?, accountData: [AnyHashable: Any]?) { } + public func endSession() { } + public func track(_ event: String, properties: [AnyHashable: Any]?) { } + } + public init( environment: AppEnvironment, interactor: AnalyticsMetadataInteractor = AnalyticsMetadataInteractorLive(), - pendoManager: PendoManagerWrapper = PendoManager.shared(), +// pendoManager: PendoManagerWrapper = PendoManager.shared(), + pendoManager: PendoManagerWrapper = PendoManagerMock(), pendoApiKey: String? = nil ) { self.environment = environment diff --git a/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift b/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift index 4f163a273e..78627797c3 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/SessionDefaults.swift @@ -19,6 +19,9 @@ import UIKit public struct SessionDefaults: Equatable { + + // MARK: - Public Interface + /** This is a shared session storage with an empty string as `sessionID`. Can be used for testing/preview/fallback purposes. @@ -26,6 +29,40 @@ public struct SessionDefaults: Equatable { public static let fallback = SessionDefaults(sessionID: "") public let sessionID: String + /// The underlying UserDefaults instance used for storage. + /// Automatically configured to use the app group suite for sharing data between app and extensions. + public var userDefaults: UserDefaults { + UserDefaults(suiteName: Bundle.main.appGroupID()) ?? .standard + } + + /// The session-specific storage dictionary, keyed by the current session ID. + /// All session data is stored under this dictionary to ensure proper user isolation. + public var sessionDefaults: [String: Any]? { + get { userDefaults.dictionary(forKey: sessionID) } + set { userDefaults.set(newValue, forKey: sessionID) } + } + + public mutating func reset() { + sessionDefaults = nil + } + + /// Provides direct access to session-specific storage using a key-value pattern. + /// Values are automatically scoped to the current user session. + public subscript(key: String) -> Any? { + get { return sessionDefaults?[key] } + set { + var defaults = sessionDefaults ?? [:] + if let value = newValue { + defaults[key] = value + } else { + defaults.removeValue(forKey: key) + } + sessionDefaults = defaults + } + } + + // MARK: - Mixed Feature Settings + /** This property is used by the file share extension to automatically select the course of the last viewed file in the app. The use-case is that the user views the assignment's file in the app, saves it to iOS Photos app, annotates it there and shares it back to the assignment. */ public var submitAssignmentCourseID: String? { get { return self["submitAssignmentCourseID"] as? String } @@ -241,32 +278,6 @@ public struct SessionDefaults: Equatable { } } - public mutating func reset() { - sessionDefaults = nil - } - - private subscript(key: String) -> Any? { - get { return sessionDefaults?[key] } - set { - var defaults = sessionDefaults ?? [:] - if let value = newValue { - defaults[key] = value - } else { - defaults.removeValue(forKey: key) - } - sessionDefaults = defaults - } - } - - private var userDefaults: UserDefaults { - return UserDefaults(suiteName: Bundle.main.appGroupID()) ?? .standard - } - - private var sessionDefaults: [String: Any]? { - get { return userDefaults.dictionary(forKey: sessionID) } - set { userDefaults.set(newValue, forKey: sessionID) } - } - // MARK: - Grades public var selectedSortByOptionIDs: [String: String]? { get { self["selectedSortByOptionIDs"] as? [String: String] } diff --git a/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json b/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json index da4a164c91..73c00596a7 100644 --- a/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json +++ b/Core/Core/Resources/Assets.xcassets/Secrets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Student/Student/LearnerDashboard/LearnerDashboardAssembly.swift b/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift similarity index 90% rename from Student/Student/LearnerDashboard/LearnerDashboardAssembly.swift rename to Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift index fb16ce5b4c..6d51fabf8c 100644 --- a/Student/Student/LearnerDashboard/LearnerDashboardAssembly.swift +++ b/Student/Student/LearnerDashboard/Container/LearnerDashboardAssembly.swift @@ -19,7 +19,9 @@ enum LearnerDashboardAssembly { static func makeInteractor() -> LearnerDashboardInteractor { - LearnerDashboardInteractorLive() + LearnerDashboardInteractorLive( + widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel + ) } static func makeViewModel(interactor: LearnerDashboardInteractor) -> LearnerDashboardViewModel { diff --git a/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift new file mode 100644 index 0000000000..b6ebd17135 --- /dev/null +++ b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift @@ -0,0 +1,58 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import Foundation + +protocol LearnerDashboardInteractor { + func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> +} + +final class LearnerDashboardInteractorLive: LearnerDashboardInteractor { + private let userDefaults: SessionDefaults + private let widgetViewModelFactory: (WidgetConfig) -> any LearnerWidgetViewModel + + init( + userDefaults: SessionDefaults = AppEnvironment.shared.userDefaults ?? .fallback, + widgetViewModelFactory: @escaping (WidgetConfig) -> any LearnerWidgetViewModel + ) { + self.userDefaults = userDefaults + self.widgetViewModelFactory = widgetViewModelFactory + } + + func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> { + Just(()) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .map { [userDefaults, widgetViewModelFactory] _ in + let configs: [WidgetConfig] + if let savedWidgets = userDefaults.learnerDashboardWidgetConfigs { + configs = savedWidgets.filter { $0.isVisible }.sorted() + } else { + configs = LearnerDashboardWidgetAssembly.makeDefaultWidgetConfigs() + } + + let viewModels = configs.map { widgetViewModelFactory($0) } + let fullWidth = viewModels.filter { $0.isFullWidth } + let grid = viewModels.filter { !$0.isFullWidth } + + return (fullWidth, grid) + } + .eraseToAnyPublisher() + } +} diff --git a/Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift similarity index 85% rename from Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift rename to Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift index 01af95357f..95128b2b0b 100644 --- a/Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift +++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift @@ -33,7 +33,10 @@ struct LearnerDashboardScreen: View { state: viewModel.state, config: viewModel.screenConfig, refreshAction: { completion in - viewModel.refresh(ignoreCache: true, completion: completion) + viewModel.refresh( + ignoreCache: true, + completion: completion + ) } ) { _ in content @@ -57,7 +60,11 @@ struct LearnerDashboardScreen: View { @ViewBuilder private var content: some View { - SwiftUI.EmptyView() + LearnerDashboardWidgetLayout( + fullWidthWidgets: viewModel.fullWidthWidgets, + gridWidgets: viewModel.gridWidgets + ) + .paddingStyle(.all, .standard) } } @@ -65,11 +72,7 @@ struct LearnerDashboardScreen: View { #Preview { let controller = CoreHostingController( - LearnerDashboardScreen( - viewModel: LearnerDashboardViewModel( - interactor: LearnerDashboardInteractorLive() - ) - ) + LearnerDashboardAssembly.makeScreen() ) CoreNavigationController(rootViewController: controller) } diff --git a/Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift similarity index 69% rename from Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift rename to Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift index 3ccbc089dd..bd3b4e2a4a 100644 --- a/Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift +++ b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift @@ -25,9 +25,12 @@ import Observation @Observable final class LearnerDashboardViewModel { private(set) var state: InstUI.ScreenState = .loading + private(set) var fullWidthWidgets: [any LearnerWidgetViewModel] = [] + private(set) var gridWidgets: [any LearnerWidgetViewModel] = [] let screenConfig = InstUI.BaseScreenConfig( refreshable: true, + showsScrollIndicators: false, emptyPandaConfig: .init( scene: SpacePanda(), title: String(localized: "Welcome to Canvas!", bundle: .student), @@ -48,17 +51,35 @@ final class LearnerDashboardViewModel { ) { self.interactor = interactor self.mainScheduler = mainScheduler - refresh(ignoreCache: false) + + loadWidgets() + } + + private func loadWidgets() { + interactor.loadWidgets() + .receive(on: mainScheduler) + .sink { [weak self] result in + guard let self else { return } + self.fullWidthWidgets = result.fullWidth + self.gridWidgets = result.grid + if !result.fullWidth.isEmpty || !result.grid.isEmpty { + self.state = .data + } + self.refresh(ignoreCache: false) + } + .store(in: &subscriptions) } func refresh(ignoreCache: Bool, completion: (() -> Void)? = nil) { - interactor.refresh(ignoreCache: ignoreCache) + let allWidgets = fullWidthWidgets + gridWidgets + let publishers = allWidgets.map { $0.refresh(ignoreCache: ignoreCache) } + + Publishers.MergeMany(publishers) + .collect() .receive(on: mainScheduler) .sink { [weak self] _ in - guard let self else { return } - self.state = .empty + guard self != nil else { return } completion?() - } receiveValue: { _ in } .store(in: &subscriptions) } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift new file mode 100644 index 0000000000..6b1ee75253 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift @@ -0,0 +1,57 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import SwiftUI + +protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == LearnerDashboardWidgetIdentifier { + associatedtype ViewType: View + + var id: LearnerDashboardWidgetIdentifier { get } + + /// User configurable widget settings. + var config: WidgetConfig { get } + + /// Non-editable, widget specific property used for layouting. + /// Full width widgets are put at the top of the screen outside of the widget grid. + var isFullWidth: Bool { get } + + var isEditable: Bool { get } + + /// The state helps the dashboard screen to decide if the empty state should be shown or not. + var state: InstUI.ScreenState { get } + + /// Used by the layout to detect when widget size might change and trigger smooth animations. + /// Override this property to include any size-affecting properties (e.g., text.count). + /// This is required because widget view models are stored in an array and SwiftUI can't observe + /// individual view model changes in the array. + /// Default implementation returns state. + var layoutIdentifier: AnyHashable { get } + + func makeView() -> ViewType + + /// When pull to refresh is performed on the dashboard each widget is asked to refresh their content. + func refresh(ignoreCache: Bool) -> AnyPublisher +} + +extension LearnerWidgetViewModel { + var layoutIdentifier: AnyHashable { + AnyHashable(state) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfig.swift new file mode 100644 index 0000000000..1abc715035 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfig.swift @@ -0,0 +1,40 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import Foundation + +extension SessionDefaults { + private var learnerDashboardWidgetConfigsKey: String { "learnerDashboardWidgetConfigs" } + + var learnerDashboardWidgetConfigs: [WidgetConfig]? { + get { + guard let data = self[learnerDashboardWidgetConfigsKey] as? Data else { + return nil + } + return try? JSONDecoder().decode([WidgetConfig].self, from: data) + } + set { + if let newValue, let data = try? JSONEncoder().encode(newValue) { + self[learnerDashboardWidgetConfigsKey] = data + } else { + self[learnerDashboardWidgetConfigsKey] = nil + } + } + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift new file mode 100644 index 0000000000..cfd44e555c --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift @@ -0,0 +1,39 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +struct WidgetConfig: Codable, Comparable, Identifiable { + let id: LearnerDashboardWidgetIdentifier + var order: Int + var isVisible: Bool + /// Widget-specific settings encoded into a JSON to be persisted. + var settings: String? + + static func < (lhs: WidgetConfig, rhs: WidgetConfig) -> Bool { + lhs.order < rhs.order + } +} + +extension Array where Element == WidgetConfig { + func partitionedByLayout( + isFullWidth: (WidgetConfig) -> Bool + ) -> (fullWidth: [WidgetConfig], grid: [WidgetConfig]) { + let fullWidth = filter { isFullWidth($0) }.sorted() + let grid = filter { !isFullWidth($0) }.sorted() + return (fullWidth, grid) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetPlaceholderData.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetPlaceholderData.swift new file mode 100644 index 0000000000..17365aa9c0 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetPlaceholderData.swift @@ -0,0 +1,38 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Foundation + +enum WidgetPlaceholderData { + static let short = "Lorem ipsum dolor sit amet" + static let medium = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt rhoncus" + static let long = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam tincidunt rhoncus\ + rutrum. Donec tempus vulputate posuere. Aenean blandit nunc vitae tempus sodales.\ + In vehicula venenatis tempus. In pharetra aliquet neque, non viverra massa sodales eget.\ + Etiam hendrerit tincidunt placerat. Suspendisse et lacus a metus tempor gravida. + New line! + """ + + static func long(_ multiplier: Int) -> String { + guard multiplier > 0 else { return long } + + return Array(repeating: long, count: multiplier) + .joined(separator: "\n") + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift new file mode 100644 index 0000000000..97efff2b89 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift @@ -0,0 +1,50 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI +import Core + +struct LearnerDashboardCard: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + // The purpose of this layout is to keep the widget's border + // on screen while the content's size changes. Instead of the border + // fading with the states, it stays on screen and just resizes + // to the new content's size. + ZStack { + content + } + .elevation(.cardLarge, background: .backgroundLightest) + } +} + +#if DEBUG + +#Preview { + LearnerDashboardCard { + Text(verbatim: "Hello") + .padding(50) + } +} + +#endif diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift new file mode 100644 index 0000000000..a7fd4dc03f --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift @@ -0,0 +1,52 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import SwiftUI + +struct LearnerDashboardTitledWidget: View { + let title: String + let content: Content + + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack( + alignment: .leading, + spacing: InstUI.Styles.Padding.sectionHeaderVertical.rawValue + ) { + Text(title) + .font(.regular14, lineHeight: .fit) + .foregroundColor(.textDarkest) + content + } + } +} + +#if DEBUG + +#Preview { + LearnerDashboardTitledWidget("Weekly Summary") { + Text(verbatim: InstUI.PreviewData.loremIpsumShort) + } +} + +#endif diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetErrorView.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetErrorView.swift new file mode 100644 index 0000000000..2cf5b5e105 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetErrorView.swift @@ -0,0 +1,70 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI +import Core + +struct LearnerDashboardWidgetErrorView: View { + let onRetry: () -> Void + + var body: some View { + HStack( + alignment: .top, + spacing: InstUI.Styles.Padding.standard.rawValue + ) { + Image("PandaUnsupported", bundle: .core) + .scaledIcon(size: 40) + + VStack(alignment: .leading, spacing: 2) { + Text("Oops, Something went wrong", bundle: .student) + .font(.semibold16, lineHeight: .fit) + .foregroundColor(.textDarkest) + + Text("We weren't able to load this content.\nTry again, or come back later.", bundle: .student) + .font(.regular14, lineHeight: .fit) + .foregroundColor(.textDark) + .paddingStyle(.bottom, .cellAccessoryPadding) + + Button(action: onRetry) { + HStack(spacing: 6) { + Text("Refresh", bundle: .student) + .font(.semibold14, lineHeight: .fit) + Image.refreshSolid + .scaledIcon(size: 16) + } + .foregroundColor(.textLightest) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(Brand.shared.primary)) + .cornerRadius(100) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + } +} + +#if DEBUG + +#Preview { + LearnerDashboardWidgetErrorView(onRetry: {}) + .border(Color.black) +} + +#endif diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift new file mode 100644 index 0000000000..8460943a6a --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift @@ -0,0 +1,109 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import SwiftUI + +struct LearnerDashboardWidgetLayout: View { + let fullWidthWidgets: [any LearnerWidgetViewModel] + let gridWidgets: [any LearnerWidgetViewModel] + @State private var containerWidth: CGFloat = 0 + + var body: some View { + VStack(spacing: InstUI.Styles.Padding.standard.rawValue) { + fullWidthSection() + gridSection(columnCount: columns(for: containerWidth)) + } + .animation(.smooth, value: fullWidthWidgets.map { $0.layoutIdentifier }) + .animation(.smooth, value: gridWidgets.map { $0.layoutIdentifier }) + .onWidthChange { width in + // Don't animate the first appearance + withAnimation(containerWidth == 0 ? .none : .smooth) { + containerWidth = width + } + } + } + + @ViewBuilder + private func fullWidthSection() -> some View { + ForEach(fullWidthWidgets, id: \.id) { viewModel in + LearnerDashboardWidgetAssembly.makeView(for: viewModel) + } + } + + @ViewBuilder + private func gridSection(columnCount: Int) -> some View { + if !gridWidgets.isEmpty { + HStack(alignment: .top, spacing: InstUI.Styles.Padding.standard.rawValue) { + ForEach(0.. some View { + LazyVStack(spacing: InstUI.Styles.Padding.standard.rawValue) { + ForEach(Array(gridWidgets.enumerated()), id: \.offset) { index, viewModel in + if index % columnCount == columnIndex { + LearnerDashboardWidgetAssembly.makeView(for: viewModel) + } + } + } + } + + private func columns(for width: CGFloat) -> Int { + switch width { + case ..<600: 1 + case 600..<840: 2 + default: 3 + } + } +} + +#if DEBUG + +#Preview { + let fullWidthWidget = LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil) + ) + let widget1 = LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget1, order: 1, isVisible: true, settings: nil) + ) + let widget2 = LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil) + ) + let widget3 = LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil) + ) + + _ = fullWidthWidget.refresh(ignoreCache: false) + _ = widget1.refresh(ignoreCache: false) + _ = widget2.refresh(ignoreCache: false) + _ = widget3.refresh(ignoreCache: false) + + return ScrollView { + LearnerDashboardWidgetLayout( + fullWidthWidgets: [fullWidthWidget], + gridWidgets: [widget1, widget2, widget3] + ) + .paddingStyle(.horizontal, .standard) + } +} + +#endif diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift new file mode 100644 index 0000000000..ec8b8902d4 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift @@ -0,0 +1,38 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import SwiftUI + +struct FullWidthWidgetView: View { + @State var viewModel: FullWidthWidgetViewModel + + var body: some View { + if viewModel.state != .loading { + LearnerDashboardTitledWidget("Full Width Widget") { + LearnerDashboardCard { + Text(verbatim: WidgetPlaceholderData.medium) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + } + } + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift new file mode 100644 index 0000000000..5ff045fcf6 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift @@ -0,0 +1,59 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import SwiftUI + +@Observable +final class FullWidthWidgetViewModel: LearnerWidgetViewModel { + typealias ViewType = FullWidthWidgetView + + let config: WidgetConfig + var id: LearnerDashboardWidgetIdentifier { config.id } + let isFullWidth = true + let isEditable = false + var state: InstUI.ScreenState = .loading + + init(config: WidgetConfig) { + self.config = config + } + + func makeView() -> FullWidthWidgetView { + FullWidthWidgetView(viewModel: self) + } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + Future { [weak self] promise in + guard let self else { + promise(.success(())) + return + } + + let delay: TimeInterval = self.state == .loading ? 2.0 : 0.0 + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + withAnimation(.smooth) { + self.state = .data + } + promise(.success(())) + } + } + .eraseToAnyPublisher() + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift new file mode 100644 index 0000000000..13a35c3527 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift @@ -0,0 +1,59 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI + +enum LearnerDashboardWidgetAssembly { + + static func makeDefaultWidgetConfigs() -> [WidgetConfig] { + [ + WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil), + WidgetConfig(id: .widget1, order: 1, isVisible: true, settings: nil), + WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil), + WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil) + ] + } + + static func makeWidgetViewModel(config: WidgetConfig) -> any LearnerWidgetViewModel { + switch config.id { + case .fullWidthWidget: FullWidthWidgetViewModel(config: config) + case .widget1: Widget1ViewModel(config: config) + case .widget2: Widget2ViewModel(config: config) + case .widget3: Widget3ViewModel(config: config) + } + } + + @ViewBuilder + static func makeView(for viewModel: any LearnerWidgetViewModel) -> some View { + switch viewModel { + case let vm as FullWidthWidgetViewModel: + vm.makeView() + case let vm as Widget1ViewModel: + vm.makeView() + case let vm as Widget2ViewModel: + vm.makeView() + case let vm as Widget3ViewModel: + vm.makeView() + default: + SwiftUI.EmptyView() + .onAppear { + assertionFailure("Unknown widget view model type") + } + } + } +} diff --git a/Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift similarity index 61% rename from Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift rename to Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift index 917cd84524..f250f94326 100644 --- a/Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift +++ b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift @@ -16,20 +16,9 @@ // along with this program. If not, see . // -import Combine -import Core -import Foundation - -protocol LearnerDashboardInteractor { - func refresh(ignoreCache: Bool) -> AnyPublisher -} - -final class LearnerDashboardInteractorLive: LearnerDashboardInteractor { - - func refresh(ignoreCache: Bool) -> AnyPublisher { - Just(()) - .delay(for: .seconds(2), scheduler: DispatchQueue.global(qos: .userInitiated)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } +enum LearnerDashboardWidgetIdentifier: String, Codable, CaseIterable { + case fullWidthWidget + case widget1 + case widget2 + case widget3 } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift new file mode 100644 index 0000000000..4454769c13 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift @@ -0,0 +1,40 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import SwiftUI + +struct Widget1View: View { + @State var viewModel: Widget1ViewModel + + var body: some View { + LearnerDashboardTitledWidget("Widget 1") { + LearnerDashboardCard { + VStack(alignment: .leading) { + Text(viewModel.text) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + .redacted(reason: viewModel.state == .data ? [] : .placeholder) + } + } + .animation(.smooth, value: viewModel.state) + .animation(.smooth, value: viewModel.text) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift new file mode 100644 index 0000000000..f3d93bde3c --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift @@ -0,0 +1,75 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import SwiftUI + +@Observable +final class Widget1ViewModel: LearnerWidgetViewModel { + typealias ViewType = Widget1View + + var text = WidgetPlaceholderData.long(1) + let config: WidgetConfig + var id: LearnerDashboardWidgetIdentifier { config.id } + let isFullWidth = false + let isEditable = false + var state: InstUI.ScreenState = .loading + + var layoutIdentifier: AnyHashable { + struct Identifier: Hashable { + let state: InstUI.ScreenState + let textCount: Int + } + return AnyHashable(Identifier(state: state, textCount: text.count)) + } + + private var timerCancellable: AnyCancellable? + + init(config: WidgetConfig) { + self.config = config + startTextTimer() + } + + private func startTextTimer() { + timerCancellable = Timer.publish(every: 2, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self = self else { return } + var newText: String + repeat { + newText = WidgetPlaceholderData.long(Int.random(in: 1...4)) + } while newText.count == self.text.count + self.text = newText + } + } + + func makeView() -> Widget1View { + Widget1View(viewModel: self) + } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + Just(()) + .delay(for: 3, scheduler: RunLoop.main) + .map { [weak self] in + self?.state = .data + return () + } + .eraseToAnyPublisher() + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift new file mode 100644 index 0000000000..6d574ac1aa --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift @@ -0,0 +1,47 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import SwiftUI + +struct Widget2View: View { + @State var viewModel: Widget2ViewModel + + var body: some View { + LearnerDashboardTitledWidget("Widget 2") { + LearnerDashboardCard { + switch viewModel.state { + case .empty, .data, .loading: + dataView + case .error: + LearnerDashboardWidgetErrorView(onRetry: viewModel.refresh) + } + } + } + .animation(.smooth, value: viewModel.state) + } + + private var dataView: some View { + VStack(alignment: .leading) { + Text(verbatim: WidgetPlaceholderData.long(3)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift new file mode 100644 index 0000000000..3c3878bdb4 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift @@ -0,0 +1,57 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import SwiftUI + +@Observable +final class Widget2ViewModel: LearnerWidgetViewModel { + typealias ViewType = Widget2View + + let config: WidgetConfig + var id: LearnerDashboardWidgetIdentifier { config.id } + let isFullWidth = false + let isEditable = false + var state: InstUI.ScreenState = .data + private var subscriptions = Set() + + init(config: WidgetConfig) { + self.config = config + } + + func makeView() -> Widget2View { + Widget2View(viewModel: self) + } + + func refresh() { + state = .loading + refresh(ignoreCache: true) + .sink() + .store(in: &subscriptions) + } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + Just(()) + .delay(for: 2, scheduler: RunLoop.main) + .map { [weak self] in + self?.state = .error + } + .eraseToAnyPublisher() + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift similarity index 59% rename from Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift rename to Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift index 4058e689a2..30ae733256 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift @@ -16,20 +16,23 @@ // along with this program. If not, see . // -@testable import Student -import XCTest +import Core +import SwiftUI -final class LearnerDashboardInteractorLiveTests: StudentTestCase { +struct Widget3View: View { - func test_refresh_whenIgnoreCacheIsTrue_shouldFinish() { - let testee = LearnerDashboardInteractorLive() + @State var viewModel: Widget3ViewModel - XCTAssertFinish(testee.refresh(ignoreCache: true), timeout: 3) - } - - func test_refresh_whenIgnoreCacheIsFalse_shouldFinish() { - let testee = LearnerDashboardInteractorLive() - - XCTAssertFinish(testee.refresh(ignoreCache: false), timeout: 3) + var body: some View { + LearnerDashboardTitledWidget("Widget 3") { + LearnerDashboardCard { + VStack(alignment: .leading) { + Text(verbatim: WidgetPlaceholderData.long(2)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + } + } } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift new file mode 100644 index 0000000000..34cc767ea4 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift @@ -0,0 +1,44 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import SwiftUI + +@Observable +final class Widget3ViewModel: LearnerWidgetViewModel { + typealias ViewType = Widget3View + + let config: WidgetConfig + var id: LearnerDashboardWidgetIdentifier { config.id } + let isFullWidth = false + let isEditable = false + var state: InstUI.ScreenState = .data + + init(config: WidgetConfig) { + self.config = config + } + + func makeView() -> Widget3View { + Widget3View(viewModel: self) + } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + Just(()).eraseToAnyPublisher() + } +} diff --git a/Student/Student/Localizable.xcstrings b/Student/Student/Localizable.xcstrings index b25efa8118..a9245a3a24 100644 --- a/Student/Student/Localizable.xcstrings +++ b/Student/Student/Localizable.xcstrings @@ -89128,6 +89128,9 @@ } } } + }, + "Oops, Something went wrong" : { + }, "Oops!" : { "comment" : "Error message title\n Error title\n Masquerade error title", @@ -100927,6 +100930,9 @@ } } } + }, + "Refresh" : { + }, "Refresh Button" : { "extractionState" : "manual", @@ -143002,6 +143008,9 @@ } } } + }, + "We weren't able to load this content.\nTry again, or come back later." : { + }, "Website Address" : { "localizations" : { diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift similarity index 73% rename from Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift rename to Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift index 11e2c1f0f2..742f6d3151 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift @@ -20,11 +20,9 @@ import Combine @testable import Student final class LearnerDashboardInteractorMock: LearnerDashboardInteractor { - var refreshIgnoreCacheValue: Bool? - var refreshPublisher = PassthroughSubject() + var loadWidgetsPublisher = PassthroughSubject<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never>() - func refresh(ignoreCache: Bool) -> AnyPublisher { - refreshIgnoreCacheValue = ignoreCache - return refreshPublisher.eraseToAnyPublisher() + func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> { + loadWidgetsPublisher.eraseToAnyPublisher() } } diff --git a/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift new file mode 100644 index 0000000000..6802114e5c --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift @@ -0,0 +1,170 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +@testable import Core +@testable import Student +import XCTest + +final class LearnerDashboardInteractorLiveTests: StudentTestCase { + + private var testee: LearnerDashboardInteractorLive! + private var userDefaults: SessionDefaults! + private var subscriptions: Set! + + override func setUp() { + super.setUp() + userDefaults = SessionDefaults(sessionID: "test-session") + subscriptions = [] + } + + override func tearDown() { + userDefaults.reset() + userDefaults = nil + testee = nil + subscriptions = nil + super.tearDown() + } + + // MARK: - Load widgets with no saved configs + + func test_loadWidgets_withNoSavedConfigs_shouldUseDefaultConfigs() { + testee = LearnerDashboardInteractorLive( + userDefaults: userDefaults, + widgetViewModelFactory: makeViewModelFactory() + ) + + let expectation = expectation(description: "loadWidgets") + var receivedFullWidth: [any LearnerWidgetViewModel]? + var receivedGrid: [any LearnerWidgetViewModel]? + + testee.loadWidgets() + .sink { result in + receivedFullWidth = result.fullWidth + receivedGrid = result.grid + expectation.fulfill() + } + .store(in: &subscriptions) + + wait(for: [expectation], timeout: 5) + + XCTAssertEqual(receivedFullWidth?.count, 1) + XCTAssertEqual(receivedFullWidth?.first?.id, .fullWidthWidget) + XCTAssertEqual(receivedGrid?.count, 3) + XCTAssertEqual(receivedGrid?[0].id, .widget1) + XCTAssertEqual(receivedGrid?[1].id, .widget3) + XCTAssertEqual(receivedGrid?[2].id, .widget2) + } + + // MARK: - Load widgets with saved configs + + func test_loadWidgets_withSavedConfigs_shouldFilterVisibleAndSort() { + userDefaults.learnerDashboardWidgetConfigs = [ + WidgetConfig(id: .widget3, order: 5, isVisible: true), + WidgetConfig(id: .widget1, order: 20, isVisible: false), + WidgetConfig(id: .widget2, order: 10, isVisible: true) + ] + testee = LearnerDashboardInteractorLive( + userDefaults: userDefaults, + widgetViewModelFactory: makeViewModelFactory() + ) + + let expectation = expectation(description: "loadWidgets") + var receivedFullWidth: [any LearnerWidgetViewModel]? + var receivedGrid: [any LearnerWidgetViewModel]? + + testee.loadWidgets() + .sink { result in + receivedFullWidth = result.fullWidth + receivedGrid = result.grid + expectation.fulfill() + } + .store(in: &subscriptions) + + wait(for: [expectation], timeout: 5) + + XCTAssertEqual(receivedFullWidth?.count, 0) + XCTAssertEqual(receivedGrid?.count, 2) + XCTAssertEqual(receivedGrid?[0].id, .widget3) + XCTAssertEqual(receivedGrid?[1].id, .widget2) + } + + func test_loadWidgets_shouldSeparateFullWidthFromGridWidgets() { + userDefaults.learnerDashboardWidgetConfigs = [ + WidgetConfig(id: .widget1, order: 20, isVisible: true), + WidgetConfig(id: .fullWidthWidget, order: 5, isVisible: true), + WidgetConfig(id: .widget2, order: 10, isVisible: true) + ] + testee = LearnerDashboardInteractorLive( + userDefaults: userDefaults, + widgetViewModelFactory: makeViewModelFactory() + ) + + let expectation = expectation(description: "loadWidgets") + var receivedFullWidth: [any LearnerWidgetViewModel]? + var receivedGrid: [any LearnerWidgetViewModel]? + + testee.loadWidgets() + .sink { result in + receivedFullWidth = result.fullWidth + receivedGrid = result.grid + expectation.fulfill() + } + .store(in: &subscriptions) + + wait(for: [expectation], timeout: 5) + + XCTAssertEqual(receivedFullWidth?.count, 1) + XCTAssertEqual(receivedFullWidth?.first?.id, .fullWidthWidget) + XCTAssertEqual(receivedGrid?.count, 2) + XCTAssertEqual(receivedGrid?[0].id, .widget2) + XCTAssertEqual(receivedGrid?[1].id, .widget1) + } + + // MARK: - Private helpers + + private func makeViewModelFactory() -> (WidgetConfig) -> any LearnerWidgetViewModel { + return { config in + MockWidgetViewModel(config: config) + } + } +} + +private final class MockWidgetViewModel: LearnerWidgetViewModel { + typealias ViewType = Never + + let id: LearnerDashboardWidgetIdentifier + let config: WidgetConfig + let isFullWidth: Bool + let isEditable = false + let state: InstUI.ScreenState = .data + + init(config: WidgetConfig) { + self.config = config + self.id = config.id + self.isFullWidth = config.id == .fullWidthWidget + } + + func makeView() -> Never { + fatalError("Not implemented") + } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + Just(()).eraseToAnyPublisher() + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift new file mode 100644 index 0000000000..fb5e8427aa --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift @@ -0,0 +1,192 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import CombineSchedulers +@testable import Core +@testable import Student +import XCTest + +final class LearnerDashboardViewModelTests: XCTestCase { + + private var testee: LearnerDashboardViewModel! + private var interactor: LearnerDashboardInteractorMock! + private var scheduler: TestSchedulerOf! + + override func setUp() { + super.setUp() + scheduler = DispatchQueue.test + interactor = LearnerDashboardInteractorMock() + } + + override func tearDown() { + testee = nil + interactor = nil + scheduler = nil + super.tearDown() + } + + // MARK: - Initialization + + func test_init_shouldLoadWidgets() { + let fullWidthWidget = MockWidgetViewModel( + id: .fullWidthWidget, + isFullWidth: true + ) + let gridWidget = MockWidgetViewModel( + id: .widget1, + isFullWidth: false + ) + + testee = LearnerDashboardViewModel( + interactor: interactor, + mainScheduler: scheduler.eraseToAnyScheduler() + ) + interactor.loadWidgetsPublisher.send(( + fullWidth: [fullWidthWidget], + grid: [gridWidget] + )) + scheduler.advance() + + XCTAssertEqual(testee.fullWidthWidgets.count, 1) + XCTAssertEqual(testee.fullWidthWidgets.first?.id, .fullWidthWidget) + XCTAssertEqual(testee.gridWidgets.count, 1) + XCTAssertEqual(testee.gridWidgets.first?.id, .widget1) + } + + // MARK: - Screen config + + func test_screenConfig_shouldBeConfiguredCorrectly() { + testee = LearnerDashboardViewModel( + interactor: interactor, + mainScheduler: scheduler.eraseToAnyScheduler() + ) + + XCTAssertEqual(testee.screenConfig.refreshable, true) + XCTAssertEqual(testee.screenConfig.showsScrollIndicators, false) + XCTAssertEqual(testee.screenConfig.emptyPandaConfig.scene is SpacePanda, true) + XCTAssertEqual( + testee.screenConfig.emptyPandaConfig.title, + String(localized: "Welcome to Canvas!", bundle: .student) + ) + } + + // MARK: - State management + + func test_init_withNoWidgets_shouldKeepLoadingState() { + testee = LearnerDashboardViewModel( + interactor: interactor, + mainScheduler: scheduler.eraseToAnyScheduler() + ) + interactor.loadWidgetsPublisher.send((fullWidth: [], grid: [])) + scheduler.advance() + + XCTAssertEqual(testee.state, .loading) + } + + func test_init_withWidgets_shouldSetDataState() { + let widget = MockWidgetViewModel(id: .widget1, isFullWidth: false) + + testee = LearnerDashboardViewModel( + interactor: interactor, + mainScheduler: scheduler.eraseToAnyScheduler() + ) + interactor.loadWidgetsPublisher.send((fullWidth: [], grid: [widget])) + scheduler.advance() + + XCTAssertEqual(testee.state, .data) + } + + // MARK: - Refresh + + func test_refresh_shouldCallRefreshOnAllWidgets() { + let widget1 = MockWidgetViewModel(id: .widget1, isFullWidth: false) + let widget2 = MockWidgetViewModel(id: .widget2, isFullWidth: false) + let fullWidthWidget = MockWidgetViewModel(id: .fullWidthWidget, isFullWidth: true) + + testee = LearnerDashboardViewModel( + interactor: interactor, + mainScheduler: scheduler.eraseToAnyScheduler() + ) + interactor.loadWidgetsPublisher.send(( + fullWidth: [fullWidthWidget], + grid: [widget1, widget2] + )) + scheduler.advance() + + testee.refresh(ignoreCache: true) + scheduler.advance() + + XCTAssertEqual(widget1.refreshCalled, true) + XCTAssertEqual(widget1.refreshIgnoreCache, true) + XCTAssertEqual(widget2.refreshCalled, true) + XCTAssertEqual(widget2.refreshIgnoreCache, true) + XCTAssertEqual(fullWidthWidget.refreshCalled, true) + XCTAssertEqual(fullWidthWidget.refreshIgnoreCache, true) + } + + func test_refresh_shouldCallCompletionWhenAllWidgetsFinish() { + let widget = MockWidgetViewModel(id: .widget1, isFullWidth: false) + + testee = LearnerDashboardViewModel( + interactor: interactor, + mainScheduler: scheduler.eraseToAnyScheduler() + ) + interactor.loadWidgetsPublisher.send((fullWidth: [], grid: [widget])) + scheduler.advance() + + let expectation = expectation(description: "refresh completion") + testee.refresh(ignoreCache: false) { + expectation.fulfill() + } + scheduler.advance() + + wait(for: [expectation], timeout: 5) + XCTAssertEqual(widget.refreshCalled, true) + XCTAssertEqual(widget.refreshIgnoreCache, false) + } +} + +private final class MockWidgetViewModel: LearnerWidgetViewModel { + typealias ViewType = Never + + let id: LearnerDashboardWidgetIdentifier + let config: WidgetConfig + let isFullWidth: Bool + let isEditable = false + let state: InstUI.ScreenState = .data + + var refreshCalled = false + var refreshIgnoreCache: Bool? + + init(id: LearnerDashboardWidgetIdentifier, isFullWidth: Bool) { + self.id = id + self.isFullWidth = isFullWidth + self.config = WidgetConfig(id: id, order: 7, isVisible: true) + } + + func makeView() -> Never { + fatalError("Not implemented") + } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + refreshCalled = true + refreshIgnoreCache = ignoreCache + return Just(()).eraseToAnyPublisher() + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift deleted file mode 100644 index 3f261ea599..0000000000 --- a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// This file is part of Canvas. -// 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 Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// 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 Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -// - -import Combine -import CombineSchedulers -@testable import Core -@testable import Student -import XCTest - -final class LearnerDashboardViewModelTests: StudentTestCase { - private var interactor: LearnerDashboardInteractorMock! - private var scheduler: TestSchedulerOf! - private var testee: LearnerDashboardViewModel! - - override func setUp() { - super.setUp() - interactor = LearnerDashboardInteractorMock() - scheduler = DispatchQueue.test - testee = LearnerDashboardViewModel( - interactor: interactor, - mainScheduler: scheduler.eraseToAnyScheduler() - ) - } - - func test_initialState_shouldBeLoading() { - XCTAssertEqual(testee.state, .loading) - } - - func test_screenConfig() { - let expectedTitle = String(localized: "Welcome to Canvas!", bundle: .student) - - XCTAssertEqual(testee.screenConfig.refreshable, true) - XCTAssertEqual(testee.screenConfig.emptyPandaConfig.scene is SpacePanda, true) - XCTAssertEqual(testee.screenConfig.emptyPandaConfig.title, expectedTitle) - } - - func test_refresh_shouldSetStateToEmpty() { - let expectation = expectation(description: "Refresh should complete") - - // WHEN - testee.refresh(ignoreCache: true) { - expectation.fulfill() - } - - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) - scheduler.advance() - - // THEN - wait(for: [expectation], timeout: 1) - XCTAssertEqual(testee.state, .empty) - } - - func test_refresh_whenIgnoreCacheIsTrue_shouldCallInteractorWithCorrectParameter() { - let expectedIgnoreCache = true - - // WHEN - testee.refresh(ignoreCache: expectedIgnoreCache) - - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) - scheduler.advance() - - // THEN - XCTAssertEqual(interactor.refreshIgnoreCacheValue, expectedIgnoreCache) - } - - func test_refresh_whenIgnoreCacheIsFalse_shouldCallInteractorWithCorrectParameter() { - let expectedIgnoreCache = false - - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) - scheduler.advance() - - // WHEN - testee.refresh(ignoreCache: expectedIgnoreCache) - - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) - scheduler.advance() - - // THEN - XCTAssertEqual(interactor.refreshIgnoreCacheValue, expectedIgnoreCache) - } -} diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfigTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfigTests.swift new file mode 100644 index 0000000000..19551ed636 --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfigTests.swift @@ -0,0 +1,101 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +@testable import Core +@testable import Student +import XCTest + +final class SessionDefaultsWidgetConfigTests: XCTestCase { + + private var testee: SessionDefaults! + + override func setUp() { + super.setUp() + testee = SessionDefaults(sessionID: "test-session") + } + + override func tearDown() { + testee.reset() + testee = nil + super.tearDown() + } + + // MARK: - Get + + func test_getter_whenNoDataStored_shouldReturnNil() { + XCTAssertEqual(testee.learnerDashboardWidgetConfigs, nil) + } + + func test_getter_whenInvalidDataStored_shouldReturnNil() { + testee["learnerDashboardWidgetConfigs"] = Data("invalid json".utf8) + + XCTAssertEqual(testee.learnerDashboardWidgetConfigs, nil) + } + + func test_getter_whenValidDataStored_shouldDecodeAndReturnConfigs() { + let configs = [ + WidgetConfig(id: .widget1, order: 7, isVisible: true, settings: "some settings"), + WidgetConfig(id: .widget2, order: 42, isVisible: false, settings: nil) + ] + let data = try! JSONEncoder().encode(configs) + testee["learnerDashboardWidgetConfigs"] = data + + let result = testee.learnerDashboardWidgetConfigs + + XCTAssertEqual(result?.count, 2) + XCTAssertEqual(result?[0].id, .widget1) + XCTAssertEqual(result?[0].order, 7) + XCTAssertEqual(result?[0].isVisible, true) + XCTAssertEqual(result?[0].settings, "some settings") + XCTAssertEqual(result?[1].id, .widget2) + XCTAssertEqual(result?[1].order, 42) + XCTAssertEqual(result?[1].isVisible, false) + XCTAssertEqual(result?[1].settings, nil) + } + + // MARK: - Set + + func test_setter_withValidConfigs_shouldEncodeAndStore() { + let configs = [ + WidgetConfig(id: .widget1, order: 7, isVisible: true, settings: "some settings"), + WidgetConfig(id: .widget3, order: 100, isVisible: false, settings: nil) + ] + + testee.learnerDashboardWidgetConfigs = configs + + let storedData = testee["learnerDashboardWidgetConfigs"] as? Data + XCTAssertNotEqual(storedData, nil) + + let decoded = try! JSONDecoder().decode([WidgetConfig].self, from: storedData!) + XCTAssertEqual(decoded.count, 2) + XCTAssertEqual(decoded[0].id, .widget1) + XCTAssertEqual(decoded[0].order, 7) + XCTAssertEqual(decoded[1].id, .widget3) + XCTAssertEqual(decoded[1].order, 100) + } + + func test_setter_withNil_shouldRemoveStoredData() { + let configs = [WidgetConfig(id: .widget1, order: 7, isVisible: true)] + testee.learnerDashboardWidgetConfigs = configs + XCTAssertNotNil(testee["learnerDashboardWidgetConfigs"]) + + testee.learnerDashboardWidgetConfigs = nil + + XCTAssertNil(testee["learnerDashboardWidgetConfigs"]) + } +} diff --git a/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/WidgetConfigTests.swift b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/WidgetConfigTests.swift new file mode 100644 index 0000000000..6834621973 --- /dev/null +++ b/Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/WidgetConfigTests.swift @@ -0,0 +1,104 @@ +// +// This file is part of Canvas. +// 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 Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +@testable import Student +import XCTest + +final class WidgetConfigTests: XCTestCase { + + // MARK: - Comparable + + func test_comparable_shouldCompareByOrder() { + // WHEN lhs.order < rhs.order + var lhs = WidgetConfig(id: .widget1, order: 5, isVisible: true) + var rhs = WidgetConfig(id: .widget2, order: 10, isVisible: true) + // THEN + XCTAssertEqual(lhs < rhs, true) + + // WHEN lhs.order > rhs.order + lhs = WidgetConfig(id: .widget1, order: 15, isVisible: true) + rhs = WidgetConfig(id: .widget2, order: 10, isVisible: true) + // THEN + XCTAssertEqual(lhs < rhs, false) + + // WHEN lhs.order == rhs.order + lhs = WidgetConfig(id: .widget1, order: 10, isVisible: true) + rhs = WidgetConfig(id: .widget2, order: 10, isVisible: true) + // THEN + XCTAssertEqual(lhs < rhs, false) + } + + // MARK: - Array extension + + func test_partitionedByLayout_shouldSeparateAndSortWidgets() { + let widgets = [ + WidgetConfig(id: .widget1, order: 20, isVisible: true), + WidgetConfig(id: .fullWidthWidget, order: 5, isVisible: true), + WidgetConfig(id: .widget2, order: 10, isVisible: true), + WidgetConfig(id: .widget3, order: 30, isVisible: true) + ] + + let result = widgets.partitionedByLayout { config in + config.id == .fullWidthWidget + } + + XCTAssertEqual(result.fullWidth.count, 1) + XCTAssertEqual(result.fullWidth.first?.id, .fullWidthWidget) + XCTAssertEqual(result.grid.count, 3) + XCTAssertEqual(result.grid[0].id, .widget2) + XCTAssertEqual(result.grid[1].id, .widget1) + XCTAssertEqual(result.grid[2].id, .widget3) + } + + func test_partitionedByLayout_withAllFullWidth_shouldReturnAllInFullWidthArray() { + let widgets = [ + WidgetConfig(id: .widget1, order: 20, isVisible: true), + WidgetConfig(id: .widget2, order: 10, isVisible: true) + ] + + let result = widgets.partitionedByLayout { _ in true } + + XCTAssertEqual(result.fullWidth.count, 2) + XCTAssertEqual(result.fullWidth[0].id, .widget2) + XCTAssertEqual(result.fullWidth[1].id, .widget1) + XCTAssertEqual(result.grid.count, 0) + } + + func test_partitionedByLayout_withAllGrid_shouldReturnAllInGridArray() { + let widgets = [ + WidgetConfig(id: .widget1, order: 20, isVisible: true), + WidgetConfig(id: .widget2, order: 10, isVisible: true) + ] + + let result = widgets.partitionedByLayout { _ in false } + + XCTAssertEqual(result.fullWidth.count, 0) + XCTAssertEqual(result.grid.count, 2) + XCTAssertEqual(result.grid[0].id, .widget2) + XCTAssertEqual(result.grid[1].id, .widget1) + } + + func test_partitionedByLayout_withEmptyArray_shouldReturnEmptyArrays() { + let widgets: [WidgetConfig] = [] + + let result = widgets.partitionedByLayout { _ in true } + + XCTAssertEqual(result.fullWidth.count, 0) + XCTAssertEqual(result.grid.count, 0) + } +} diff --git a/scripts/coverage/config.json b/scripts/coverage/config.json index 14a2101e1b..eda63002f4 100644 --- a/scripts/coverage/config.json +++ b/scripts/coverage/config.json @@ -36,6 +36,12 @@ "Student\/Submissions\/SubmissionButton\/SubmissionButtonPresenter.swift", "Student\/Student\/Assignments\/AssignmentDetails\/SubAssignmentsCard\/ViewModel\/StudentSubAssignmentsCardItem.swift", "Student\/Widgets\/", + "Student\/Student\/LearnerDashboard\/Widgets\/Common\/Model\/LearnerWidgetViewModel.swift", + "Student\/Student\/LearnerDashboard\/Widgets\/Common\/Model\/WidgetPlaceholderData.swift", + "Student\/Student\/LearnerDashboard\/Widgets\/FullWidthWidget\/", + "Student\/Student\/LearnerDashboard\/Widgets\/Widget1\/", + "Student\/Student\/LearnerDashboard\/Widgets\/Widget2\/", + "Student\/Student\/LearnerDashboard\/Widgets\/Widget3\/", "\/Teacher\/SpeedGrader\/", "Parent\/", "Horizon\/Horizon\/Sources\/Features\/LearningObjects\/Assignment\/AssignmentDetails\/View\/LTIQuiz\/OnAppear.swift",