Skip to content
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

Hiding dashboard cards: add context menus #20384

Merged
merged 10 commits into from
Apr 3, 2023
3 changes: 2 additions & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
-----
* [**] [internal] Refactor updating account related Core Data operations, which ususally happens during log in and out of the app. [#20394]
* [***] [internal] Refactor uploading photos (from the device photo, the Free Photo library, and other sources) to the WordPress Media Library. Affected areas are where you can choose a photo and upload, including the "Media" screen, adding images to a post, updating site icon, etc. [#20322]
* [**] [WordPress-only] Warns user about sites with only individual plugins not supporting core app features and offers the option to switch to the Jetpack app. [#20408]
* [**] Add a "Personalize Home Tab" button to the bottom of the Home tab that allows changing cards visibility. [#20369]
* [**] Add context menus to "Drafts", "Scheduled Posts", and "Today's Stats" cards with an option to hide these cards. [#20384]
Copy link
Contributor

Choose a reason for hiding this comment

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

The cut-off for 22.1 is on Monday and the feature is still under the feature flag and development.

I think it's safer to move these notes to 22.2 and aim to enable those changes then.

Does that sound good?

CC: @guarani

* [**] [WordPress-only] Warns user about sites with only individual plugins not supporting core app features and offers the option to switch to the Jetpack app. [#20408]

22.0
-----
Expand Down
6 changes: 6 additions & 0 deletions WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ import Foundation
// My Site Dashboard
case dashboardCardShown
case dashboardCardItemTapped
case dashboardCardContextualMenuAccessed
case dashboardCardHideTapped
case mySiteTabTapped
case mySiteSiteMenuShown
case mySiteDashboardShown
Expand Down Expand Up @@ -1033,6 +1035,10 @@ import Foundation
return "my_site_dashboard_card_shown"
case .dashboardCardItemTapped:
return "my_site_dashboard_card_item_tapped"
case .dashboardCardContextualMenuAccessed:
return "my_site_dashboard_contextual_menu_accessed"
case .dashboardCardHideTapped:
return "my_site_dashboard_card_hide_tapped"
case .mySiteTabTapped:
return "my_site_tab_tapped"
case .mySiteSiteMenuShown:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ extension BlazeCardView {
comment: "Description for the Blaze dashboard card.")
static let hideThis = NSLocalizedString("blaze.dashboard.card.menu.hide",
value: "Hide this",
comment: "Title for a menu action in the context menu on the Jetpack install card.")
comment: "Title for a menu action in the context menu on the Blaze card.")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ class DashboardBlazeCardCell: DashboardCollectionViewCell {
}

let onEllipsisTap: () -> Void = { [weak self] in
BlogDashboardAnalytics.trackContextualMenuAccessed(for: .blaze)
BlazeEventsTracker.trackContextualMenuAccessed(for: .dashboardCard)
}

let onHideThisTap: UIActionHandler = { [weak self] _ in
BlogDashboardAnalytics.trackHideTapped(for: .blaze)
BlazeEventsTracker.trackHideThisTapped(for: .dashboardCard)
BlazeHelper.hideBlazeCard(for: self?.blog)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,6 @@ class BlogDashboardCardFrameView: UIView {
return topStackView
}()

/// Card's icon image view
private lazy var iconImageView: UIImageView = {
let iconImageView = UIImageView(image: UIImage.gridicon(.posts, size: Constants.iconSize).withRenderingMode(.alwaysTemplate))
iconImageView.tintColor = .label
iconImageView.frame = CGRect(x: 0, y: 0, width: Constants.iconSize.width, height: Constants.iconSize.height)
iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
iconImageView.isAccessibilityElement = false
return iconImageView
}()

/// Card's title
private lazy var titleLabel: UILabel = {
let titleLabel = UILabel()
Expand All @@ -45,19 +35,6 @@ class BlogDashboardCardFrameView: UIView {
return titleLabel
}()

/// Chevron displayed in case there's any action associated
private lazy var chevronImageView: UIImageView = {
let chevronImage = UIImage.gridicon(.chevronRight, size: Constants.iconSize).withRenderingMode(.alwaysTemplate)
let chevronImageView = UIImageView(image: chevronImage.imageFlippedForRightToLeftLayoutDirection())
chevronImageView.frame = CGRect(x: 0, y: 0, width: Constants.iconSize.width, height: Constants.iconSize.height)
chevronImageView.tintColor = .listIcon
chevronImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
chevronImageView.isAccessibilityElement = false
chevronImageView.isHidden = true
chevronImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
return chevronImageView
}()

/// Ellipsis Button displayed on the top right corner of the view.
/// Displayed only when an associated action is set
private(set) lazy var ellipsisButton: UIButton = {
Expand Down Expand Up @@ -102,19 +79,10 @@ class BlogDashboardCardFrameView: UIView {
}
}

/// The icon to be displayed at the header
var icon: UIImage? {
didSet {
iconImageView.image = icon?.withRenderingMode(.alwaysTemplate)
iconImageView.isHidden = icon == nil
}
}

/// Closure to be called when anywhere in the view is tapped.
/// If set, the chevron image is displayed.
var onViewTap: (() -> Void)? {
didSet {
updateChevronImageState()
addViewTapGestureIfNeeded()
}
}
Expand All @@ -124,10 +92,8 @@ class BlogDashboardCardFrameView: UIView {
/// If set, the chevron image is displayed.
var onHeaderTap: (() -> Void)? {
didSet {
updateChevronImageState()
addHeaderTapGestureIfNeeded()
}

}

/// Closure to be called when the ellipsis button is tapped..
Expand Down Expand Up @@ -177,7 +143,7 @@ class BlogDashboardCardFrameView: UIView {
headerStackView.isHidden = true
buttonContainerStackView.isHidden = false

if !ellipsisButton.isHidden || !chevronImageView.isHidden {
if !ellipsisButton.isHidden {
mainStackViewTrailingConstraint?.constant = -Constants.mainStackViewTrailingPadding
}
}
Expand Down Expand Up @@ -228,30 +194,17 @@ class BlogDashboardCardFrameView: UIView {
ellipsisButton.setImage(UIImage.gridicon(.ellipsis).imageWithTintColor(.listIcon), for: .normal)
}

private func updateChevronImageState() {
chevronImageView.isHidden = onViewTap == nil && onHeaderTap == nil
assertOnTapRecognitionCorrectUsage()
}

private func updateEllipsisButtonState() {
ellipsisButton.isHidden = onEllipsisButtonTap == nil
let headerPadding = ellipsisButton.isHidden ?
Constants.headerPaddingWithEllipsisButtonHidden :
Constants.headerPaddingWithEllipsisButtonShown
headerStackView.layoutMargins = headerPadding
assertOnTapRecognitionCorrectUsage()
}

/// Only one of two types of action should be associated with the card.
/// Either ellipsis button tap, or view/header tap
private func assertOnTapRecognitionCorrectUsage() {
let bothTypesUsed = (onViewTap != nil || onHeaderTap != nil) && onEllipsisButtonTap != nil
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These code removals from BlogDashboardCardFrameView are required for this change to work. Previously, setting both the onHeaderTap and onEllipsisButtonTap was unsupported. It is now.

assert(!bothTypesUsed, "Using onViewTap or onHeaderTap alongside onEllipsisButtonTap is not supported and will result in unexpected behavior.")
}

private func addHeaderTapGestureIfNeeded() {
// Reset any previously added gesture recognizers
headerStackView.gestureRecognizers?.forEach {headerStackView.removeGestureRecognizer($0)}
headerStackView.gestureRecognizers?.forEach { headerStackView.removeGestureRecognizer($0) }

// Add gesture recognizer if needed
if onHeaderTap != nil {
Expand All @@ -262,7 +215,7 @@ class BlogDashboardCardFrameView: UIView {

private func addViewTapGestureIfNeeded() {
// Reset any previously added gesture recognizers
self.gestureRecognizers?.forEach {self.removeGestureRecognizer($0)}
self.gestureRecognizers?.forEach { self.removeGestureRecognizer($0) }

// Add gesture recognizer if needed
if onViewTap != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class DashboardPostsListCardCell: UICollectionViewCell, Reusable {

// MARK: Views

private var frameView: BlogDashboardCardFrameView?
private let frameView = BlogDashboardCardFrameView()

lazy var tableView: UITableView = {
let tableView = PostCardTableView()
Expand Down Expand Up @@ -68,14 +68,9 @@ class DashboardPostsListCardCell: UICollectionViewCell, Reusable {
}

private func addSubviews() {
let frameView = BlogDashboardCardFrameView()
frameView.icon = UIImage.gridicon(.posts, size: Constants.iconSize)
frameView.translatesAutoresizingMaskIntoConstraints = false

frameView.add(subview: tableView)

self.frameView = frameView

contentView.addSubview(frameView)
contentView.pinSubviewToAllEdges(frameView, priority: Constants.constraintPriority)
}
Expand Down Expand Up @@ -104,23 +99,37 @@ extension DashboardPostsListCardCell {
assertionFailure("Cell used with wrong card type")
return
}
addContextMenu(card: cardType, blog: blog)

viewModel = PostsCardViewModel(blog: blog, status: status, view: self)
viewModel?.viewDidLoad()
tableView.dataSource = viewModel?.diffableDataSource
viewModel?.refresh()
}

private func addContextMenu(card: DashboardCard, blog: Blog) {
guard FeatureFlag.personalizeHomeTab.enabled else { return }

frameView.onEllipsisButtonTap = {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm curious about your thoughts.

Now every card from the new "Personalize Home Tab" screen has a "Hide this" button in the context menu.

I'm looking at each of the cells which repeat the same pattern of adding ellipsis button handling, hiding action, and tracking code.

Although duplication is not necessarily evil, I'm wondering if in this situation it makes and is possible to have something reused. To describe the card as "personalizable" and then it will share common these functionality and layout traits.

What do you think about that?

A couple more cards require special attention and will be made "personalizable" later.

Is this a counter-argument to my question and it's difficult to reuse the same layout and functionality code between personalizable cards?

Copy link
Contributor Author

@kean kean Mar 31, 2023

Choose a reason for hiding this comment

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

Thanks for bringing this up. I did add some reuse but didn't want to go as far as refactoring the existing cards:

  • Creating a UIAction to hide a card for new cards is fully reused
  • Tracking the hide action is also reused

But there is clearly some duplication in how the context menus get added:

if FeatureFlag.personalizeHomeTab.enabled {
    frameView.onEllipsisButtonTap = {
        BlogDashboardAnalytics.trackContextualMenuAccessed(for: .todaysStats)
    }
    frameView.ellipsisButton.showsMenuAsPrimaryAction = true
    frameView.ellipsisButton.menu = UIMenu(title: "", options: .displayInline, children: [
        BlogDashboardHelpers.makeHideCardAction(for: .todaysStats, siteID: blog.dotComID?.intValue ?? 0)
    ])
}

I could make it reusable fairly easily:

extension DashboardStatsCardCell {
    private func configureCard(for blog: Blog, in viewController: UIViewController) {
            if FeatureFlag.personalizeHomeTab.enabled {
                let menu = UIMenu(title: "", options: .displayInline, children: [
                    BlogDashboardHelpers.makeHideCardAction(for: .todaysStats, siteID: blog.dotComID?.intValue ?? 0)
                ])
                frameView.setContextMenu(menu, card: .todaysStats)
            }
            // ...
    }
}

extension BlogDashboardCardFrameView {
    func setContextMenu(_ menu: UIMenu, card: DashboardCard) {
        onEllipsisButtonTap = {
            BlogDashboardAnalytics.trackContextualMenuAccessed(for: card)
        }
        ellipsisButton.showsMenuAsPrimaryAction = true
        ellipsisButton.menu = menu
    }
}

This way, we'll remove the most obvious instances of duplication, and cards will retain complete control over how their menus are configured.

Should I make this change in this PR? I have some other ideas for refactoring this and also Dashboard.personalizableCards. I was going to open a new PR after getting the features merged first.

Ultimately, I'll want this "Hide this" action to appear automatically based on which cards are personalizable, but that sounds a bit overcomplicated. I do want to look into it a bit more.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I also already used the same approach for adding the ellipsis in #20393, which is consistent with other cards. I think it'll make sense to update them all at once to make it easy to review/test.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the insightful answer and your examples! Those extensions look like good wins to take.

Should I make this change in this PR? I have some other ideas for refactoring this and also Dashboard.personalizableCards. I was going to open a new PR after getting the features merged first.

I think we could do that in other PRs as well. There are already a fair amount of changes, it will be easier to digest additional ones in a separate PR.

BlogDashboardAnalytics.trackContextualMenuAccessed(for: card)
}
frameView.ellipsisButton.showsMenuAsPrimaryAction = true
frameView.ellipsisButton.menu = UIMenu(title: "", options: .displayInline, children: [
BlogDashboardHelpers.makeHideCardAction(for: card, siteID: blog.dotComID?.intValue ?? 0)
])
}

private func configureDraftsList(blog: Blog) {
frameView?.title = Strings.draftsTitle
frameView?.titleHint = Strings.draftsTitleHint
frameView?.onHeaderTap = { [weak self] in
frameView.title = Strings.draftsTitle
frameView.titleHint = Strings.draftsTitleHint
frameView.onHeaderTap = { [weak self] in
self?.presentPostList(with: .draft)
}
}

private func configureScheduledList(blog: Blog) {
frameView?.title = Strings.scheduledTitle
frameView?.onHeaderTap = { [weak self] in
frameView.title = Strings.scheduledTitle
frameView.onHeaderTap = { [weak self] in
self?.presentPostList(with: .scheduled)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable {
let frameView = BlogDashboardCardFrameView()
frameView.translatesAutoresizingMaskIntoConstraints = false
frameView.title = Strings.cardFrameTitle
frameView.icon = Style.frameIconImage

// NOTE: Remove the logic when support for iOS 14 is dropped
if #available (iOS 15.0, *) {
// assign an empty closure so the button appears.
frameView.onEllipsisButtonTap = {}
frameView.onEllipsisButtonTap = {
BlogDashboardAnalytics.trackContextualMenuAccessed(for: .prompts)
}
frameView.ellipsisButton.showsMenuAsPrimaryAction = true
frameView.ellipsisButton.menu = contextMenu
} else {
// Show a fallback implementation using `MenuSheetViewController`.
// iOS 13 doesn't support showing UIMenu programmatically.
// iOS 14 doesn't support `UIDeferredMenuElement.uncached`.
frameView.onEllipsisButtonTap = { [weak self] in
BlogDashboardAnalytics.trackContextualMenuAccessed(for: .prompts)
self?.showMenuSheet()
}
}
Expand Down Expand Up @@ -501,6 +503,7 @@ private extension DashboardPromptsCardCell {
return
}
WPAnalytics.track(.promptsDashboardCardMenuRemove)
BlogDashboardAnalytics.trackHideTapped(for: .prompts)
let service = BlogDashboardPersonalizationService(siteID: siteID)
service.setEnabled(false, for: .prompts)
let notice = Notice(title: Strings.promptRemovedTitle, message: Strings.promptRemovedSubtitle, feedbackType: .success, actionTitle: Strings.undoSkipTitle) { _ in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ final class DashboardQuickStartCardCell: UICollectionViewCell, Reusable, BlogDas
fallthrough

case .newSite:
cardFrameView.icon = UIImage.gridicon(.listOrdered, size: Metrics.iconSize)
configureOnEllipsisButtonTap(sourceRect: cardFrameView.ellipsisButton.frame)
cardFrameView.showHeader()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class DashboardStatsCardCell: UICollectionViewCell, Reusable {
// MARK: Private Variables

private var viewModel: DashboardStatsViewModel?
private var frameView: BlogDashboardCardFrameView?
private let frameView = BlogDashboardCardFrameView()
private var nudgeView: DashboardStatsNudgeView?
private var statsStackView: DashboardStatsStackView?

Expand Down Expand Up @@ -39,11 +39,8 @@ class DashboardStatsCardCell: UICollectionViewCell, Reusable {
}

private func addSubviews() {
let frameView = BlogDashboardCardFrameView()
frameView.title = Strings.statsTitle
frameView.titleHint = Strings.statsTitleHint
frameView.icon = UIImage.gridicon(.statsAlt, size: Constants.iconSize)
self.frameView = frameView

let statsStackview = DashboardStatsStackView()
frameView.add(subview: statsStackview)
Expand Down Expand Up @@ -74,11 +71,20 @@ extension DashboardStatsCardCell: BlogDashboardCardConfigurable {
}

private func configureCard(for blog: Blog, in viewController: UIViewController) {

frameView?.onViewTap = { [weak self] in
frameView.onViewTap = { [weak self] in
self?.showStats(for: blog, from: viewController)
}

if FeatureFlag.personalizeHomeTab.enabled {
frameView.onEllipsisButtonTap = {
BlogDashboardAnalytics.trackContextualMenuAccessed(for: .todaysStats)
}
frameView.ellipsisButton.showsMenuAsPrimaryAction = true
frameView.ellipsisButton.menu = UIMenu(title: "", options: .displayInline, children: [
BlogDashboardHelpers.makeHideCardAction(for: .todaysStats, siteID: blog.dotComID?.intValue ?? 0)
])
}

statsStackView?.views = viewModel?.todaysViews
statsStackView?.visitors = viewModel?.todaysVisitors
statsStackView?.likes = viewModel?.todaysLikes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ class BlogDashboardAnalytics {
}
}
}

static func trackContextualMenuAccessed(for card: DashboardCard) {
WPAnalytics.track(.dashboardCardContextualMenuAccessed, properties: ["card": card.rawValue])
}

static func trackHideTapped(for card: DashboardCard) {
WPAnalytics.track(.dashboardCardHideTapped, properties: ["card": card.rawValue])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

struct BlogDashboardHelpers {
static func makeHideCardAction(for card: DashboardCard, siteID: Int) -> UIAction {
UIAction(
title: Strings.hideThis,
image: UIImage(systemName: "minus.circle"),
attributes: [.destructive],
handler: { _ in
BlogDashboardAnalytics.trackHideTapped(for: card)
BlogDashboardPersonalizationService(siteID: siteID)
.setEnabled(false, for: card)
})
}

private enum Strings {
static let hideThis = NSLocalizedString("blogDashboard.contextMenu.hideThis", value: "Hide this", comment: "Title for the context menu action that hides the dashboard card.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ class JetpackRemoteInstallCardView: UIView {
private lazy var cardFrameView: BlogDashboardCardFrameView = {
let frameView = BlogDashboardCardFrameView()
frameView.translatesAutoresizingMaskIntoConstraints = false
frameView.icon = .none
frameView.onEllipsisButtonTap = {}
frameView.ellipsisButton.showsMenuAsPrimaryAction = true
frameView.ellipsisButton.menu = contextMenu
Expand Down
Loading