Skip to content

Dynamic Dashboard: Reviews Card Reload Functionality #12752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
40184d4
Add functionality to open all reviews from card.
hafizrahman May 15, 2024
ff9c78b
Add initial reloadData function for Reviews card.
hafizrahman May 16, 2024
37193af
Add db loading and display the result.
hafizrahman May 16, 2024
b61d592
Strip HTML from the review text.
hafizrahman May 16, 2024
2eea667
Use the existing ReviewViewModel for data.
hafizrahman May 16, 2024
a47f3bb
Add functionality to fetch products after getting review product IDs,…
hafizrahman May 16, 2024
616b88b
Update visibility handling.
hafizrahman May 16, 2024
42da0b8
Merge branch 'trunk'
hafizrahman May 16, 2024
12d09c8
Make sure to only get the latest three reviews.
hafizrahman May 16, 2024
12708f7
shorten name.
hafizrahman May 16, 2024
cb58f27
Simplify siteID predicate.
hafizrahman May 16, 2024
4e8571f
Set prefix with constant.
hafizrahman May 16, 2024
2b2151b
Add shimmer/redact to review items too when sync is happening.
hafizrahman May 16, 2024
3246160
Delete dummy data
hafizrahman May 16, 2024
34094ec
Update incorrect dummy data usage.
hafizrahman May 16, 2024
4fa99a1
Update func name.
hafizrahman May 16, 2024
4ad2e89
Update logic to get related products locally first, then fetch if nee…
hafizrahman May 17, 2024
23839f6
Fix viewModel not being @ObservedObject
hafizrahman May 17, 2024
3d9286c
Simplify ForEach since we don't need index, just check last item.
hafizrahman May 17, 2024
9aec7bc
Update the way reviewText snippet is shown in the swiftUI way.
hafizrahman May 17, 2024
382f342
More color fix and updates.
hafizrahman May 17, 2024
62be5a9
Various refactor for product fetching.
hafizrahman May 17, 2024
702678f
Merge branch 'trunk'
hafizrahman May 17, 2024
ae19a09
Update review text to use AttributedString.
hafizrahman May 17, 2024
6cb627b
Update WooCommerce/Classes/ViewRelated/Dashboard/Reviews/ReviewsDashb…
hafizrahman May 20, 2024
37fc482
Add fetch limit to the products results controller initializer
hafizrahman May 20, 2024
40bb44a
Refactor remote fetch functions that do not need to return results.
hafizrahman May 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ final class DashboardViewModel: ObservableObject {
group.addTask { [weak self] in
await self?.mostActiveCouponsViewModel.reloadData()
}
group.addTask { [weak self] in
await self?.reviewsViewModel.reloadData()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,14 @@ struct ReviewsDashboardCard: View {
/// Scale of the view based on accessibility changes
@ScaledMetric private var scale: CGFloat = 1.0

private let viewModel: ReviewsDashboardCardViewModel

let dummyData: [ProductReview] = [
ProductReview(siteID: 1, reviewID: 1, productID: 1, dateCreated: Date(),
statusKey: "approved", reviewer: "Sherlock", reviewerEmail: "", reviewerAvatarURL: "",
review: "The best product in the whole world. This is meant to be really long to be more than two lines",
rating: 5, verified: true),
ProductReview(siteID: 1, reviewID: 2, productID: 1, dateCreated: Date(),
statusKey: "hold", reviewer: "Holmes", reviewerEmail: "", reviewerAvatarURL: "",
review: "Amazing!", rating: 5, verified: true),
ProductReview(siteID: 1, reviewID: 3, productID: 1, dateCreated: Date(),
statusKey: "approved", reviewer: "", reviewerEmail: "", reviewerAvatarURL: "",
review: "", rating: 5, verified: true)
]
@State private var showingAllReviews: Bool = false

@ObservedObject private var viewModel: ReviewsDashboardCardViewModel

init(viewModel: ReviewsDashboardCardViewModel) {
self.viewModel = viewModel
}


var body: some View {
VStack(alignment: .leading, spacing: Layout.padding) {
header
Expand All @@ -39,19 +27,29 @@ struct ReviewsDashboardCard: View {
.shimmering(active: viewModel.syncingData)
Divider()

ForEach(Array(dummyData.enumerated()), id: \.element.reviewID) { index, review in
ReviewRow(for: review, isLastItem: index == dummyData.count-1)
}
Divider()
viewAllReviewsButton
.padding(.horizontal, Layout.padding)
if viewModel.data.isNotEmpty {
ForEach(viewModel.data, id: \.review.reviewID) { reviewViewModel in
reviewRow(for: reviewViewModel,
isLastItem: reviewViewModel == viewModel.data.last)
}
.redacted(reason: viewModel.syncingData ? [.placeholder] : [])
.shimmering(active: viewModel.syncingData)

Divider()

viewAllReviewsButton
.padding(.horizontal, Layout.padding)
.redacted(reason: viewModel.syncingData ? [.placeholder] : [])
.shimmering(active: viewModel.syncingData)
}
}
.padding(.vertical, Layout.padding)
.background(Color(.listForeground(modal: false)))
.clipShape(RoundedRectangle(cornerSize: Layout.cornerSize))
.padding(.horizontal, Layout.padding)
LazyNavigationLink(destination: ReviewsView(siteID: viewModel.siteID), isActive: $showingAllReviews) {
EmptyView()
}
}
}

Expand Down Expand Up @@ -106,27 +104,34 @@ private extension ReviewsDashboardCard {
}
}

func ReviewRow(for review: ProductReview, isLastItem: Bool) -> some View {
func reviewRow(for viewModel: ReviewViewModel, isLastItem: Bool) -> some View {
HStack(alignment: .top, spacing: 0) {
Image(systemName: "bubble.fill")
.foregroundStyle(review.status == .hold ? Color.secondary : Color(.wooCommercePurple(.shade60)))
.foregroundStyle(viewModel.review.status == .hold ? Color.secondary : Color(.wooCommercePurple(.shade60)))
.padding(.horizontal, Layout.padding)
.padding(.vertical, Layout.cardPadding)


VStack(alignment: .leading) {
// TODO: use actual product name
authorText(author: review.reviewer, productName: "Fallen Angel Candelabra")
.bodyStyle()
.padding(.trailing, Layout.padding)
reviewText(text: review.review, shouldDisplayStatus: review.status == .hold)
.lineLimit(2)
.subheadlineStyle()
.padding(.trailing, Layout.padding)
.renderedIf(review.review.isNotEmpty)
if review.rating > 0 {
if let subject = viewModel.subject {
Text(subject)
.bodyStyle()
.padding(.trailing, Layout.padding)
}

reviewText(text: viewModel.snippetData.reviewText,
pendingText: viewModel.snippetData.pendingReviewsText,
divider: viewModel.snippetData.dot,
textColor: viewModel.snippetData.textColor,
accentColor: viewModel.snippetData.accentColor,
shouldDisplayStatus: viewModel.shouldDisplayStatus)
.lineLimit(2)
.subheadlineStyle()
.padding(.trailing, Layout.padding)

if viewModel.review.rating > 0 {
HStack(spacing: Layout.starRatingSpacing) {
ForEach(0..<review.rating, id: \.self) { _ in
ForEach(0..<viewModel.review.rating, id: \.self) { _ in
Image(systemName: "star.fill")
.resizable()
.frame(width: Constants.starSize * scale, height: Constants.starSize * scale)
Expand All @@ -141,26 +146,24 @@ private extension ReviewsDashboardCard {
}
}

func authorText(author: String, productName: String) -> some View {
if author.isNotEmpty {
return Text(String.localizedStringWithFormat(Localization.completeAuthorText, author, productName))
} else {
return Text(String.localizedStringWithFormat(Localization.incompleteAuthorText, productName))
}
}

func reviewText(text: String, shouldDisplayStatus: Bool) -> some View {
func reviewText(text: String,
pendingText: String,
divider: String,
textColor: UIColor,
accentColor: UIColor,
shouldDisplayStatus: Bool) -> some View {
if shouldDisplayStatus {
return Text(Localization.pendingReview).foregroundColor(Color(uiColor: .wooOrange)) +
Text(" • " + text)
return Text(pendingText).foregroundColor(Color(uiColor: accentColor)) +
Text(divider + text).foregroundColor(Color(uiColor: textColor))
} else {
return Text(text)
return Text(text).foregroundColor(Color(uiColor: textColor))
}
}


var viewAllReviewsButton: some View {
Button {
/* TODO */
showingAllReviews = true
} label: {
HStack {
Text(Localization.viewAll)
Expand All @@ -171,7 +174,6 @@ private extension ReviewsDashboardCard {
}
.disabled(viewModel.syncingData)
}

}

private extension ReviewsDashboardCard {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,174 @@ final class ReviewsDashboardCardViewModel: ObservableObject {
case approved
}

@Published private(set) var data: [ReviewViewModel] = []
private var reviewProductIDs: [Int64] = []
private var reviewProducts: [Product] = []
@Published private(set) var syncingData = false
@Published private(set) var syncingError: Error?

private let siteID: Int64
private let stores: StoresManager
private let storage: StorageManagerType
private let storageManager: StorageManagerType
private let analytics: Analytics

public let siteID: Int64
public let filters: [ReviewsFilter] = [.all, .hold, .approved]

init(siteID: Int64,
stores: StoresManager = ServiceLocator.stores,
storage: StorageManagerType = ServiceLocator.storageManager,
storageManager: StorageManagerType = ServiceLocator.storageManager,
analytics: Analytics = ServiceLocator.analytics) {
self.siteID = siteID
self.stores = stores
self.storage = storage
self.storageManager = storageManager
self.analytics = analytics

configureProductReviewsResultsController()
}

/// ResultsController for ProductReview
private lazy var productReviewsResultsController: ResultsController<StorageProductReview> = {
let sortDescriptor = NSSortDescriptor(keyPath: \StorageProductReview.dateCreated, ascending: false)
return ResultsController<StorageProductReview>(storageManager: storageManager,
matching: sitePredicate(),
fetchLimit: Constants.numberOfItems,
sortedBy: [sortDescriptor])
}()

/// ResultsController for Product
private lazy var productsResultsController: ResultsController<StorageProduct> = {
let predicate = NSPredicate(format: "siteID == %lld AND productID IN %@", siteID, reviewProductIDs)
return ResultsController<StorageProduct>(storageManager: storageManager, matching: predicate, sortedBy: [])
}()

func dismissReviews() {
// TODO: add tracking
onDismiss?()
}

@MainActor
func reloadData() async {
syncingData = true
syncingError = nil
do {
// Ignoring the result from remote as we're using storage as the single source of truth
_ = try await loadReviews()
} catch {
syncingError = error
}
syncingData = false
}
}

// MARK: - Private helpers
private extension ReviewsDashboardCardViewModel {
/// Predicate to entities that belong to the current store
///
func sitePredicate() -> NSPredicate {
return NSPredicate(format: "siteID == %lld", siteID)
}

@MainActor
func loadReviews() async throws -> [ProductReview] {
try await withCheckedThrowingContinuation { continuation in
stores.dispatch(ProductReviewAction.synchronizeProductReviews(siteID: siteID,
pageNumber: 1,
pageSize: Constants.numberOfItems) { result in
continuation.resume(with: result)
})
}
}

@MainActor
func retrieveProducts(for reviewProductIDs: [Int64]) async throws -> (products: [Product], hasNextPage: Bool) {
try await withCheckedThrowingContinuation { continuation in
stores.dispatch(ProductAction.retrieveProducts(siteID: siteID,
productIDs: reviewProductIDs
) { result in
continuation.resume(with: result)
})
}
}

/// Performs initial fetch from storage and updates results.
func configureProductReviewsResultsController() {
productReviewsResultsController.onDidChangeContent = { [weak self] in
guard let self else { return }
Task {
await self.updateReviewsResults()
}
}
productReviewsResultsController.onDidResetContent = { [weak self] in
guard let self else { return }
Task {
await self.updateReviewsResults()
}
}

do {
try productReviewsResultsController.performFetch()
} catch {
ServiceLocator.crashLogging.logError(error)
}
}

/// Updates data
@MainActor
func updateReviewsResults() async {
let reviews = productReviewsResultsController.fetchedObjects.prefix(Constants.numberOfItems)
reviewProductIDs = reviews.map { $0.productID }

// Load products that matches the review product IDs
if reviewProductIDs.isNotEmpty {
do {
try await fetchProducts()
} catch {
ServiceLocator.crashLogging.logError(error)
}
}

data = reviews
.map { review in
let product = reviewProducts.first { $0.productID == review.productID }
// TODO: also fetch notification
return ReviewViewModel(review: review, product: product, notification: nil)
}
}

/// Get products from storage if available, if not then fetch remotely.
///
@MainActor
private func fetchProducts() async throws {
// Prefer to fetch locally first, if not available then fetch remotely
try productsResultsController.performFetch()
reviewProducts = productsResultsController.fetchedObjects

if reviewProducts.isEmpty {
await loadReviewProducts(for: reviewProductIDs)
try productsResultsController.performFetch()
reviewProducts = productsResultsController.fetchedObjects
}
}

@MainActor
func loadReviewProducts(for reviewProductIDs: [Int64]) async {
syncingData = true
syncingError = nil

do {
// Ignoring the result from remote as we're using storage as the single source of truth
_ = try await retrieveProducts(for: reviewProductIDs)
} catch {
syncingError = error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: I believe that we can display the reviews even if retrieving products fails. Could we ignore the error and just display the reviews without product info?

If we think about it further, we could even load the products silently in the background and update the UI. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a neat suggestion and I can try adding it on the next PR that deals with loading states. I do notice that it can take a while waiting for products and notifications to be fetched, and showing partial result first can be more useful to users. 👍🏼

}
syncingData = false
}
}

private extension ReviewsDashboardCardViewModel {
enum Constants {
static let numberOfItems = 3
}
}

extension ReviewsDashboardCardViewModel.ReviewsFilter {
Expand Down
Loading