Skip to content

Commit

Permalink
Add "Active" and "Inactive" headers to the plugins list (#24098)
Browse files Browse the repository at this point in the history
* Show disclosure indicator in the installed plugins list

* Show active and inactive sections in the installed plugins list

* Add empty views to the installed plugins list

* Simplify view body implementation
  • Loading branch information
crazytonyli authored Feb 27, 2025
1 parent b94a037 commit 5ebee56
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 58 deletions.
4 changes: 4 additions & 0 deletions Modules/Sources/WordPressCore/Plugins/PluginService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ public actor PluginService: PluginServiceProtocol {
try await installedPluginDataStore.list(query: .slug(slug)).first
}

public func installedPlugins(query: PluginDataStoreQuery) async throws -> [InstalledPlugin] {
try await installedPluginDataStore.list(query: query)
}

public func installedPluginsUpdates(query: PluginDataStoreQuery) async -> AsyncStream<Result<[InstalledPlugin], Error>> {
await installedPluginDataStore.listStream(query: query)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public protocol PluginServiceProtocol: Actor {
func newVersionUpdates(query: PluginUpdateChecksDataStoreQuery) async -> AsyncStream<Result<[UpdateCheckPluginInfo], Error>>

func findInstalledPlugin(slug: PluginWpOrgDirectorySlug) async throws -> InstalledPlugin?
func installedPlugins(query: PluginDataStoreQuery) async throws -> [InstalledPlugin]

func resolveIconURL(of slug: PluginWpOrgDirectorySlug, plugin: PluginInformation?) async -> URL?

Expand Down
219 changes: 161 additions & 58 deletions WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,14 @@ struct InstalledPluginsListView: View {
ZStack {
if let error = viewModel.error {
EmptyStateView(error, systemImage: "exclamationmark.triangle.fill")
} else if viewModel.isRefreshing && viewModel.displayingPlugins.isEmpty {
} else if viewModel.isRefreshing && viewModel.sections.isEmpty {
Label { Text(Strings.loading) } icon: { ProgressView() }
} else {
List {
Section {
ForEach(viewModel.displayingPlugins, id: \.self) { plugin in
ZStack {
PluginListItemView(plugin: plugin, updateAvailable: viewModel.updateAvailable.index(forKey: plugin.slug) != nil, service: viewModel.service)
if let slug = plugin.possibleWpOrgDirectorySlug {
// Using `PluginListItemView` as `NavigationLink`'s content would show an disclosure
// indicator on the list cell, which looks a bit off with the ellipsis button on the
// list cell.
// Here we use an empty transparent `NavigationLink` as a workaround to hide the
// disclosure indicator.
NavigationLink { PluginDetailsView(slug: slug, plugin: plugin, service: viewModel.service) } label: { EmptyView() }
.opacity(0.0)
}
}
}
}
.listSectionSeparator(.hidden, edges: .top)
if viewModel.showNoPluginsView {
noPluginsView
} else {
pluginsList
}
.listStyle(.plain)
.refreshable(action: viewModel.refreshItems)
}
}
.navigationTitle(Strings.title)
Expand All @@ -56,9 +40,9 @@ struct InstalledPluginsListView: View {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker(Strings.filterTitle, selection: $viewModel.filter) {
Text(Strings.filterOptionAll).tag(InstalledPluginsListViewModel.PluginFilter.all)
Text(Strings.filterOptionActive).tag(InstalledPluginsListViewModel.PluginFilter.active)
Text(Strings.filterOptionInactive).tag(InstalledPluginsListViewModel.PluginFilter.inactive)
Text(Strings.filterOptionAll).tag(PluginFilter.all)
Text(Strings.filterOptionActive).tag(PluginFilter.active)
Text(Strings.filterOptionInactive).tag(PluginFilter.inactive)
}
} label: {
Image(systemName: "ellipsis.circle")
Expand All @@ -81,48 +65,142 @@ struct InstalledPluginsListView: View {
}
}

private enum Strings {
static let title: String = NSLocalizedString("site.plugins.title", value: "Plugins", comment: "Installed plugins list title")
static let loading: String = NSLocalizedString("site.plugins.loading", value: "Loading installed plugins…", comment: "Message displayed when fetching installed plugins from the site")
static let noPluginInstalled: String = NSLocalizedString("site.plugins.noInstalledPlugins", value: "You haven't installed any plugins yet", comment: "No installed plugins message")
static let filterTitle: String = NSLocalizedString("site.plugins.filter.title", value: "Filter", comment: "Title of the plugin filter picker")
static let filterOptionAll: String = NSLocalizedString("site.plugins.filter.option.all", value: "All", comment: "The plugin fillter option for displaying all plugins")
static let filterOptionActive: String = NSLocalizedString("site.plugins.filter.option.all", value: "Active", comment: "The plugin fillter option for displaying active plugins")
static let filterOptionInactive: String = NSLocalizedString("site.plugins.filter.option.all", value: "Inactive", comment: "The plugin fillter option for displaying inactive plugins")
@ViewBuilder
var noPluginsView: some View {
EmptyStateView {
Image(systemName: "puzzlepiece.extension")
} description: {
Text(viewModel.localizedFilterTitle)
.font(.body)
.foregroundStyle(.primary)
} actions: {
if viewModel.filter == .all {
Button(Strings.addPluginButton, systemImage: "plus") {
presentAddNewPlugin = true
}
.buttonStyle(.borderedProminent)
}
}
}

@ViewBuilder
var pluginsList: some View {
List {
ForEach(viewModel.sections, id: \.self) { section in
Section {
ForEach(section.plugins, id: \.self) { plugin in
NavigationLink {
if let slug = plugin.possibleWpOrgDirectorySlug {
PluginDetailsView(slug: slug, plugin: plugin, service: viewModel.service)
}
} label: {
PluginListItemView(
plugin: plugin,
updateAvailable: viewModel.updateAvailable.index(forKey: plugin.slug) != nil,
service: viewModel.service
)
}
}
} header: {
Text(section.filter.title)
.textCase(nil)
.font(.headline)
.foregroundStyle(.primary)
}
.listSectionSeparator(.hidden, edges: .all)
}
}
.listStyle(.grouped)
.scrollContentBackground(.hidden)
.refreshable(action: viewModel.refreshItems)
}
}

private enum Strings {
static let title: String = NSLocalizedString("site.plugins.title", value: "Plugins", comment: "Installed plugins list title")
static let loading: String = NSLocalizedString("site.plugins.loading", value: "Loading installed plugins…", comment: "Message displayed when fetching installed plugins from the site")
static let noPluginInstalled: String = NSLocalizedString("site.plugins.noInstalledPlugins", value: "You haven't installed any plugins yet", comment: "No installed plugins message")
static let noPluginsActive = NSLocalizedString("site.plugins.empty.active", value: "No active plugins", comment: "Message shown when there are no active plugins on the site")
static let noPluginsInactive = NSLocalizedString("site.plugins.empty.inactive", value: "No inactive plugins", comment: "Message shown when there are no inactive plugins on the site")
static let addPluginButton = NSLocalizedString("site.plugins.empty.addButton", value: "Add Plugin", comment: "Button label to add a new plugin when no plugins are installed")
static let filterTitle: String = NSLocalizedString("site.plugins.filter.title", value: "Filter", comment: "Title of the plugin filter picker")
static let filterOptionAll: String = NSLocalizedString("site.plugins.filter.option.all", value: "All", comment: "The plugin fillter option for displaying all plugins")
static let filterOptionActive: String = NSLocalizedString("site.plugins.filter.option.all", value: "Active", comment: "The plugin fillter option for displaying active plugins")
static let filterOptionInactive: String = NSLocalizedString("site.plugins.filter.option.all", value: "Inactive", comment: "The plugin fillter option for displaying inactive plugins")
}

private struct ListSection: Hashable, Identifiable {
var plugins: [InstalledPlugin]
var filter: PluginFilter

var id: PluginFilter { filter }
}

private enum PluginFilter: Int, Hashable {
// The order here matches the order of grouped plugins
case all
case active
case inactive

var query: PluginDataStoreQuery {
switch self {
case .all:
return .all
case .active:
return .active
case .inactive:
return .inactive
}
}

var title: String {
switch self {
case .all:
return Strings.filterOptionAll
case .active:
return Strings.filterOptionActive
case .inactive:
return Strings.filterOptionInactive
}
}
}

@MainActor
final class InstalledPluginsListViewModel: ObservableObject {
private final class InstalledPluginsListViewModel: ObservableObject {

let service: PluginServiceProtocol
private var initialLoad = false

enum PluginFilter: Hashable {
case all
case active
case inactive

var query: PluginDataStoreQuery {
switch self {
case .all:
return .all
case .active:
return .active
case .inactive:
return .inactive
}
@Published var isRefreshing: Bool = false {
didSet {
Task { await self.updateListContent() }
}
}

@Published var isRefreshing: Bool = false
@Published var filter: PluginFilter = .all
@Published var displayingPlugins: [InstalledPlugin] = []
@Published var showNoPluginsView: Bool = false
@Published var filter: PluginFilter = .all {
didSet {
// Hide "No Plugins" view when switching filters. The property will be updated to the correct value
// in the `performQuery` function.
self.showNoPluginsView = false
}
}
@Published var sections = [ListSection]()
@Published var updateAvailable: [PluginSlug: UpdateCheckPluginInfo] = [:]
@Published var error: String? = nil

@Published var updating: Set<PluginSlug> = []

var localizedFilterTitle: String {
switch filter {
case .all:
Strings.noPluginInstalled
case .active:
Strings.noPluginsActive
case .inactive:
Strings.noPluginsInactive
}
}

init(service: PluginServiceProtocol) {
self.service = service
}
Expand All @@ -139,21 +217,46 @@ final class InstalledPluginsListViewModel: ObservableObject {
isRefreshing = true
defer { isRefreshing = false }

self.showNoPluginsView = false

do {
try await self.service.fetchInstalledPlugins()
} catch {
self.error = (error as? WpApiError)?.errorMessage ?? error.localizedDescription
}
}

func updateListContent() async {
do {
let plugins = try await self.service.installedPlugins(query: filter.query)
updateList(with: .success(plugins))
} catch {
updateList(with: .failure(error))
}
}

func performQuery() async {
for await update in await self.service.installedPluginsUpdates(query: filter.query) {
switch update {
case let .success(plugins):
self.displayingPlugins = plugins
case let .failure(error):
self.error = (error as? WpApiError)?.errorMessage ?? error.localizedDescription
}
for await plugins in await self.service.installedPluginsUpdates(query: filter.query) {
updateList(with: plugins)
}
}

func updateList(with plugins: Result<[InstalledPlugin], Error>) {
switch plugins {
case let .success(plugins):
self.showNoPluginsView = !self.isRefreshing && plugins.isEmpty
self.sections = plugins
.reduce(into: [PluginFilter: [InstalledPlugin]]()) { result, plugin in
let filter: PluginFilter = plugin.isActive ? .active : .inactive
result[filter, default: []].append(plugin)
}
.map { filter, plugins in
ListSection(plugins: plugins, filter: filter)
}
.sorted(using: KeyPathComparator(\ListSection.filter.rawValue))
case let .failure(error):
self.showNoPluginsView = false
self.error = (error as? WpApiError)?.errorMessage ?? error.localizedDescription
}
}

Expand Down

0 comments on commit 5ebee56

Please sign in to comment.