diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift index 94c09005e4f2..51ba13f082d4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift @@ -67,7 +67,7 @@ final class BlogDashboardViewController: UIViewController { func update(blog: Blog) { self.blog = blog viewModel.blog = blog - viewModel.applySnapshotForInitialData() + viewModel.loadCardsFromCache() viewModel.loadCards() } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersistence.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersistence.swift new file mode 100644 index 000000000000..950911e7c18e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersistence.swift @@ -0,0 +1,29 @@ +import Foundation + +class BlogDashboardPersistence { + func persist(cards: NSDictionary, for wpComID: Int) { + do { + let directory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let fileURL = directory.appendingPathComponent(filename(for: wpComID)) + let data = try JSONSerialization.data(withJSONObject: cards, options: []) + try data.write(to: fileURL, options: .atomic) + } catch { + // In case of an error, nothing is done + } + } + + func getCards(for wpComID: Int) -> NSDictionary? { + do { + let directory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let fileURL = directory.appendingPathComponent(filename(for: wpComID)) + let data = try Data(contentsOf: fileURL) + return try JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary + } catch { + return nil + } + } + + private func filename(for blogID: Int) -> String { + "cards_\(blogID).json" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift index d3445e8be565..0ac624de1955 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift @@ -2,60 +2,84 @@ import Foundation import WordPressKit class BlogDashboardService { - let remoteService: DashboardServiceRemote + private let remoteService: DashboardServiceRemote + private let persistence: BlogDashboardPersistence - init(managedObjectContext: NSManagedObjectContext, remoteService: DashboardServiceRemote? = nil) { + init(managedObjectContext: NSManagedObjectContext, remoteService: DashboardServiceRemote? = nil, persistence: BlogDashboardPersistence = BlogDashboardPersistence()) { self.remoteService = remoteService ?? DashboardServiceRemote(wordPressComRestApi: WordPressComRestApi.defaultApi(in: managedObjectContext, localeKey: WordPressComRestApi.LocaleKeyV2)) + self.persistence = persistence } - func fetch(wpComID: Int, completion: @escaping (DashboardSnapshot) -> Void) { + /// Fetch cards from remote + func fetch(wpComID: Int, completion: @escaping (DashboardSnapshot) -> Void, failure: (() -> Void)? = nil) { let cardsToFetch: [String] = DashboardCard.remoteCases.map { $0.rawValue } remoteService.fetch(cards: cardsToFetch, forBlogID: wpComID, success: { [weak self] cards in - var snapshot = DashboardSnapshot() + self?.persistence.persist(cards: cards, for: wpComID) - DashboardCard.allCases.forEach { card in + guard let snapshot = self?.parse(cards) else { + return + } + + completion(snapshot) + + }, failure: { _ in + failure?() + }) + } - if card.isRemote { + /// Fetch cards from local + func fetchLocal(wpComID: Int) -> DashboardSnapshot { + if let cards = persistence.getCards(for: wpComID) { + let snapshot = parse(cards) + return snapshot + } - if card == .posts, - let posts = cards[DashboardCard.posts.rawValue] as? NSDictionary, - let (sections, items) = self?.parsePostCard(posts) { - snapshot.appendSections(sections) - sections.enumerated().forEach { key, section in - snapshot.appendItems([items[key]], toSection: section) - } - } else { + return DashboardSnapshot() + } +} - if let viewModel = cards[card.rawValue] { - let section = DashboardCardSection(id: card.rawValue) - let item = DashboardCardModel(id: card, cellViewModel: viewModel as? NSDictionary) +private extension BlogDashboardService { + func parse(_ cards: NSDictionary) -> DashboardSnapshot { + var snapshot = DashboardSnapshot() - snapshot.appendSections([section]) - snapshot.appendItems([item], toSection: section) - } + DashboardCard.allCases.forEach { card in - } + if card.isRemote { + if card == .posts, + let posts = cards[DashboardCard.posts.rawValue] as? NSDictionary { + let (sections, items) = parsePostCard(posts) + snapshot.appendSections(sections) + sections.enumerated().forEach { key, section in + snapshot.appendItems([items[key]], toSection: section) + } } else { - let section = DashboardCardSection(id: card.rawValue) - let item = DashboardCardModel(id: card) + if let viewModel = cards[card.rawValue] { + let section = DashboardCardSection(id: card.rawValue) + let item = DashboardCardModel(id: card, cellViewModel: viewModel as? NSDictionary) + + snapshot.appendSections([section]) + snapshot.appendItems([item], toSection: section) + } - snapshot.appendSections([section]) - snapshot.appendItems([item], toSection: section) } + + } else { + let section = DashboardCardSection(id: card.rawValue) + let item = DashboardCardModel(id: card) + + snapshot.appendSections([section]) + snapshot.appendItems([item], toSection: section) } - completion(snapshot) - }, failure: { _ in + } - }) + return snapshot } -} -private extension BlogDashboardService { /// Posts are a special case: they might not be a 1-1 relation /// If the user has draft and scheduled posts, we show two cards /// One for each. This function takes care of this diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift index 6abd0a8b1e82..2d0c48cce60d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift @@ -42,9 +42,7 @@ class BlogDashboardViewModel { /// Apply the initial configuration when the view loaded func viewDidLoad() { - // This is necessary when using an IntrinsicCollectionView - // Otherwise, the collection view will never update its height - applySnapshotForInitialData() + loadCardsFromCache() } /// Call the API to return cards for the current blog @@ -58,11 +56,17 @@ class BlogDashboardViewModel { service.fetch(wpComID: dotComID, completion: { [weak self] snapshot in self?.viewController?.stopLoading() self?.apply(snapshot: snapshot) + }, failure: { [weak self] in + self?.viewController?.stopLoading() }) } - func applySnapshotForInitialData() { - let snapshot = DashboardSnapshot() + func loadCardsFromCache() { + guard let dotComID = blog.dotComID?.intValue else { + return + } + + let snapshot = service.fetchLocal(wpComID: dotComID) apply(snapshot: snapshot) } diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index ea6078a54df3..a3477ce52209 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -1465,6 +1465,8 @@ 8BBBCE702717651200B277AC /* JetpackModuleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */; }; 8BBBCE712717651200B277AC /* JetpackModuleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */; }; 8BBBEBB224B8F8C0005E358E /* ReaderCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBEBB124B8F8C0005E358E /* ReaderCardTests.swift */; }; + 8BBC778B27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */; }; + 8BBC778C27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */; }; 8BC12F72231FEBA1004DDA72 /* PostCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F71231FEBA1004DDA72 /* PostCoordinatorTests.swift */; }; 8BC12F7523201917004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F732320181E004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift */; }; 8BC12F7723201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F7623201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift */; }; @@ -1490,6 +1492,7 @@ 8BDC4C39249BA5CA00DE0A2D /* ReaderCSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */; }; 8BE69512243E674300FF492F /* PrepublishingHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE69511243E674300FF492F /* PrepublishingHeaderViewTests.swift */; }; 8BE7C84123466927006EDE70 /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE7C84023466927006EDE70 /* I18n.swift */; }; + 8BE9AB8827B6B5A300708E45 /* BlogDashboardPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE9AB8727B6B5A300708E45 /* BlogDashboardPersistenceTests.swift */; }; 8BEE845A27B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BEE845927B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json */; }; 8BEE846227B1E0540001A93C /* DashboardCardSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEE845E27B1DE040001A93C /* DashboardCardSection.swift */; }; 8BEE846327B1E0560001A93C /* DashboardCardSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEE845E27B1DE040001A93C /* DashboardCardSection.swift */; }; @@ -6124,6 +6127,7 @@ 8BB185D424B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReaderCard+CoreDataClass.swift"; sourceTree = ""; }; 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackModuleHelper.swift; sourceTree = ""; }; 8BBBEBB124B8F8C0005E358E /* ReaderCardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardTests.swift; sourceTree = ""; }; + 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersistence.swift; sourceTree = ""; }; 8BC12F71231FEBA1004DDA72 /* PostCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCoordinatorTests.swift; sourceTree = ""; }; 8BC12F732320181E004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostService+MarkAsFailedAndDraftIfNeeded.swift"; sourceTree = ""; }; 8BC12F7623201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostService+MarkAsFailedAndDraftIfNeededTests.swift"; sourceTree = ""; }; @@ -6149,6 +6153,7 @@ 8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCSS.swift; sourceTree = ""; }; 8BE69511243E674300FF492F /* PrepublishingHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PrepublishingHeaderViewTests.swift; path = WordPressTest/PrepublishingHeaderViewTests.swift; sourceTree = SOURCE_ROOT; }; 8BE7C84023466927006EDE70 /* I18n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = I18n.swift; sourceTree = ""; }; + 8BE9AB8727B6B5A300708E45 /* BlogDashboardPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersistenceTests.swift; sourceTree = ""; }; 8BEE845927B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "dashboard-200-with-drafts-and-scheduled.json"; sourceTree = ""; }; 8BEE845E27B1DE040001A93C /* DashboardCardSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCardSection.swift; sourceTree = ""; }; 8BEE846027B1DE0E0001A93C /* DashboardCardModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCardModel.swift; sourceTree = ""; }; @@ -11374,6 +11379,7 @@ isa = PBXGroup; children = ( 8B6214E227B1B2F3001DF7B6 /* BlogDashboardService.swift */, + 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */, ); path = Service; sourceTree = ""; @@ -11382,6 +11388,7 @@ isa = PBXGroup; children = ( 8B6214E527B1B446001DF7B6 /* BlogDashboardServiceTests.swift */, + 8BE9AB8727B6B5A300708E45 /* BlogDashboardPersistenceTests.swift */, ); path = Dashboard; sourceTree = ""; @@ -18140,6 +18147,7 @@ 5D6C4B081B603E03005E3C43 /* WPContentSyncHelper.swift in Sources */, 73C8F06021BEED9100DDDF7E /* SiteAssemblyStep.swift in Sources */, 82C420761FE44BD900CFB15B /* SiteSettingsViewController+Swift.swift in Sources */, + 8BBC778B27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */, F5E29038243FAB0300C19CA5 /* FilterTableData.swift in Sources */, E6A3384C1BB08E3F00371587 /* ReaderGapMarker.m in Sources */, E1CFC1571E0AC8FF001DF9E9 /* Pattern.swift in Sources */, @@ -19381,6 +19389,7 @@ D848CC1720FF38EA00A9038F /* FormattableCommentRangeTests.swift in Sources */, 246D0A0325E97D5D0028B83F /* Blog+ObjcTests.m in Sources */, 9A9D34FF2360A4E200BC95A3 /* StatsPeriodAsyncOperationTests.swift in Sources */, + 8BE9AB8827B6B5A300708E45 /* BlogDashboardPersistenceTests.swift in Sources */, B5EFB1C91B333C5A007608A3 /* NotificationSettingsServiceTests.swift in Sources */, 179501CD27A01D4100882787 /* PublicizeAuthorizationURLComponentsTests.swift in Sources */, 4089C51422371EE30031CE78 /* TodayStatsTests.swift in Sources */, @@ -20404,6 +20413,7 @@ FABB24142602FC2C00C8785C /* ThemeBrowserSectionHeaderView.swift in Sources */, FABB24152602FC2C00C8785C /* SiteIconPickerPresenter.swift in Sources */, C79C307D26EA919F00E88514 /* ReferrerDetailsViewModel.swift in Sources */, + 8BBC778C27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */, FABB24172602FC2C00C8785C /* BlogSettings.swift in Sources */, FABB24182602FC2C00C8785C /* WKWebView+UserAgent.swift in Sources */, FABB24192602FC2C00C8785C /* JetpackCapabilitiesService.swift in Sources */, diff --git a/WordPress/WordPressTest/Dashboard/BlogDashboardPersistenceTests.swift b/WordPress/WordPressTest/Dashboard/BlogDashboardPersistenceTests.swift new file mode 100644 index 000000000000..d9ac853afba2 --- /dev/null +++ b/WordPress/WordPressTest/Dashboard/BlogDashboardPersistenceTests.swift @@ -0,0 +1,40 @@ +import XCTest + +@testable import WordPress + +class BlogDashboardPersistenceTests: XCTestCase { + private var persistence: BlogDashboardPersistence! + + override func setUp() { + super.setUp() + + persistence = BlogDashboardPersistence() + } + + func testSaveData() { + persistence.persist(cards: cardsResponse, for: 1234) + + let directory = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let fileURL = directory.appendingPathComponent("cards_1234.json") + let data: Data = try! Data(contentsOf: fileURL) + let cardsDictionary = try! JSONSerialization.jsonObject(with: data, options: []) as! NSDictionary + + XCTAssertEqual(cardsDictionary, cardsResponse) + } + + func testGetCards() { + persistence.persist(cards: cardsResponse, for: 1235) + + let persistedCards = persistence.getCards(for: 1235) + + XCTAssertEqual(persistedCards, cardsResponse) + } +} + +private extension BlogDashboardPersistenceTests { + var cardsResponse: NSDictionary { + let fileURL: URL = Bundle(for: BlogDashboardPersistenceTests.self).url(forResource: "dashboard-200-with-drafts-and-scheduled.json", withExtension: nil)! + let data: Data = try! Data(contentsOf: fileURL) + return try! JSONSerialization.jsonObject(with: data, options: []) as! NSDictionary + } +} diff --git a/WordPress/WordPressTest/Dashboard/BlogDashboardServiceTests.swift b/WordPress/WordPressTest/Dashboard/BlogDashboardServiceTests.swift index defb682b58bb..8e0daf691c61 100644 --- a/WordPress/WordPressTest/Dashboard/BlogDashboardServiceTests.swift +++ b/WordPress/WordPressTest/Dashboard/BlogDashboardServiceTests.swift @@ -6,12 +6,14 @@ import Nimble class BlogDashboardServiceTests: XCTestCase { private var service: BlogDashboardService! private var remoteServiceMock: DashboardServiceRemoteMock! + private var persistenceMock: BlogDashboardPersistenceMock! override func setUp() { super.setUp() remoteServiceMock = DashboardServiceRemoteMock() - service = BlogDashboardService(managedObjectContext: TestContextManager().newDerivedContext(), remoteService: remoteServiceMock) + persistenceMock = BlogDashboardPersistenceMock() + service = BlogDashboardService(managedObjectContext: TestContextManager().newDerivedContext(), remoteService: remoteServiceMock, persistence: persistenceMock) } func testCallServiceWithCorrectIDAndCards() { @@ -118,8 +120,43 @@ class BlogDashboardServiceTests: XCTestCase { waitForExpectations(timeout: 3, handler: nil) } + + func testPersistCardsResponse() { + let expect = expectation(description: "Parse todays stats") + remoteServiceMock.respondWith = .withDraftAndSchedulePosts + + service.fetch(wpComID: 123456) { snapshot in + XCTAssertEqual(self.persistenceMock.didCallPersistWithCards, + self.dictionary(from: "dashboard-200-with-drafts-and-scheduled.json")) + XCTAssertEqual(self.persistenceMock.didCallPersistWithWpComID, 123456) + + expect.fulfill() + } + + waitForExpectations(timeout: 3, handler: nil) + } + + func testFetchCardsFromPersistence() { + persistenceMock.respondWith = dictionary(from: "dashboard-200-with-drafts-and-scheduled.json")! + + let snapshot = service.fetchLocal(wpComID: 123456) + + let draftsSection = snapshot.sectionIdentifiers.first(where: { $0.subtype == "draft" }) + let scheduledSection = snapshot.sectionIdentifiers.first(where: { $0.subtype == "scheduled" }) + XCTAssertNotNil(draftsSection) + XCTAssertNotNil(scheduledSection) + XCTAssertEqual(persistenceMock.didCallGetCardsWithWpComID, 123456) + } + + func dictionary(from file: String) -> NSDictionary? { + let fileURL: URL = Bundle(for: BlogDashboardServiceTests.self).url(forResource: file, withExtension: nil)! + let data: Data = try! Data(contentsOf: fileURL) + return try? JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary + } } +// MARK: - Mocks + class DashboardServiceRemoteMock: DashboardServiceRemote { enum Response: String { case withDraftAndSchedulePosts = "dashboard-200-with-drafts-and-scheduled.json" @@ -144,3 +181,22 @@ class DashboardServiceRemoteMock: DashboardServiceRemote { } } } + +class BlogDashboardPersistenceMock: BlogDashboardPersistence { + var didCallPersistWithCards: NSDictionary? + var didCallPersistWithWpComID: Int? + + override func persist(cards: NSDictionary, for wpComID: Int) { + didCallPersistWithCards = cards + didCallPersistWithWpComID = wpComID + } + + var didCallGetCardsWithWpComID: Int? + var respondWith: NSDictionary = [:] + + override func getCards(for wpComID: Int) -> NSDictionary? { + didCallGetCardsWithWpComID = wpComID + + return respondWith + } +}