Add issue label and milestone filters
This commit is contained in:
@@ -6,6 +6,8 @@ final class IssuesViewController: RefreshingTableViewController {
|
||||
private let owner: String
|
||||
private let repository: String
|
||||
private var rows: [IssueRow] = []
|
||||
private var filterOptions: IssueFilterOptions?
|
||||
private var filterTask: Task<Void, Never>?
|
||||
|
||||
init(context: AppContext, owner: String, repository: String) {
|
||||
self.context = context
|
||||
@@ -18,6 +20,8 @@ final class IssuesViewController: RefreshingTableViewController {
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
deinit { filterTask?.cancel() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
|
||||
@@ -26,19 +30,18 @@ final class IssuesViewController: RefreshingTableViewController {
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
loadFilterOptions()
|
||||
loadIssues(refreshing: refreshing)
|
||||
}
|
||||
|
||||
private func loadIssues(refreshing: Bool) {
|
||||
beginLoading(refreshing: refreshing)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
rows = try await context.core.issues(owner: owner, repository: repository)
|
||||
tableView.reloadData()
|
||||
let status = context.core.settings().issueStatus
|
||||
tableView.backgroundView = rows.isEmpty
|
||||
? EmptyBackgroundView(
|
||||
title: "No \(status) issues",
|
||||
detail: "No issues match the selected status."
|
||||
)
|
||||
: nil
|
||||
updateEmptyState()
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
@@ -46,6 +49,24 @@ final class IssuesViewController: RefreshingTableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFilterOptions() {
|
||||
filterTask?.cancel()
|
||||
filterTask = Task {
|
||||
do {
|
||||
let options = try await context.core.issueFilters(
|
||||
owner: owner,
|
||||
repository: repository
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
filterOptions = options
|
||||
updateFilterMenu()
|
||||
updateEmptyState()
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
rows.count
|
||||
}
|
||||
@@ -74,9 +95,11 @@ final class IssuesViewController: RefreshingTableViewController {
|
||||
|
||||
private func updateFilterMenu() {
|
||||
let current = context.core.settings().issueStatus
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: context.symbol("line.3.horizontal.decrease.circle"),
|
||||
menu: UIMenu(children: ["open", "closed"].map { status in
|
||||
let status = UIMenu(
|
||||
title: "Status",
|
||||
image: context.symbol("circle.lefthalf.filled"),
|
||||
options: .singleSelection,
|
||||
children: ["open", "closed"].map { status in
|
||||
UIAction(
|
||||
title: status.capitalized,
|
||||
state: current == status ? .on : .off
|
||||
@@ -85,12 +108,134 @@ final class IssuesViewController: RefreshingTableViewController {
|
||||
do {
|
||||
try self.context.core.setIssueStatus(status: status)
|
||||
self.updateFilterMenu()
|
||||
self.loadContent(refreshing: false)
|
||||
self.loadIssues(refreshing: false)
|
||||
} catch {
|
||||
self.show(error: error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
var children: [UIMenuElement] = [status]
|
||||
if let filterOptions {
|
||||
children.append(milestoneMenu(filterOptions))
|
||||
children.append(labelMenu(filterOptions))
|
||||
} else {
|
||||
children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in })
|
||||
}
|
||||
let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem(
|
||||
image: context.symbol("line.3.horizontal.decrease.circle")
|
||||
)
|
||||
item.menu = UIMenu(children: children)
|
||||
item.accessibilityLabel = "Filter issues"
|
||||
if navigationItem.rightBarButtonItem == nil {
|
||||
navigationItem.rightBarButtonItem = item
|
||||
}
|
||||
updateFilterTint()
|
||||
}
|
||||
|
||||
private func milestoneMenu(_ options: IssueFilterOptions) -> UIMenu {
|
||||
let selected = options.selectedMilestone
|
||||
var milestones = options.milestones
|
||||
if !selected.isEmpty, !milestones.contains(selected) { milestones.append(selected) }
|
||||
let all = UIAction(title: "All Milestones", state: selected.isEmpty ? .on : .off) {
|
||||
[weak self] _ in self?.selectMilestone("")
|
||||
}
|
||||
let actions = milestones.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
.map { milestone in
|
||||
UIAction(
|
||||
title: milestone,
|
||||
state: selected == milestone ? .on : .off
|
||||
) { [weak self] _ in self?.selectMilestone(milestone) }
|
||||
}
|
||||
return UIMenu(
|
||||
title: "Milestone",
|
||||
image: context.symbol("flag"),
|
||||
options: .singleSelection,
|
||||
children: [all] + actions
|
||||
)
|
||||
}
|
||||
|
||||
private func labelMenu(_ options: IssueFilterOptions) -> UIMenu {
|
||||
let available = Set(options.labels)
|
||||
let selected = Set(options.selectedLabels)
|
||||
let labels = available.union(selected)
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
let actions = labels.map { label in
|
||||
UIAction(
|
||||
title: available.contains(label) ? label : "\(label) (Unavailable)",
|
||||
attributes: .keepsMenuPresented,
|
||||
state: selected.contains(label) ? .on : .off
|
||||
) { [weak self] action in
|
||||
guard let self, let options = self.filterOptions else { return }
|
||||
self.filterTask?.cancel()
|
||||
var labels = Set(options.selectedLabels)
|
||||
if labels.remove(label) == nil { labels.insert(label) }
|
||||
do {
|
||||
try self.saveFilters(milestone: options.selectedMilestone, labels: labels)
|
||||
self.filterOptions?.selectedLabels = labels.sorted()
|
||||
action.state = labels.contains(label) ? .on : .off
|
||||
self.updateFilterMenu()
|
||||
self.loadIssues(refreshing: false)
|
||||
} catch {
|
||||
self.show(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return UIMenu(
|
||||
title: "Labels",
|
||||
image: context.symbol("tag"),
|
||||
children: actions.isEmpty
|
||||
? [UIAction(title: "No labels", attributes: .disabled) { _ in }]
|
||||
: actions
|
||||
)
|
||||
}
|
||||
|
||||
private func selectMilestone(_ milestone: String) {
|
||||
guard let options = filterOptions else { return }
|
||||
filterTask?.cancel()
|
||||
do {
|
||||
try saveFilters(milestone: milestone, labels: Set(options.selectedLabels))
|
||||
filterOptions?.selectedMilestone = milestone
|
||||
updateFilterMenu()
|
||||
loadIssues(refreshing: false)
|
||||
} catch {
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveFilters(milestone: String, labels: Set<String>) throws {
|
||||
try context.core.setIssueFilters(
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
milestone: milestone,
|
||||
labels: labels.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
private var filtersActive: Bool {
|
||||
context.core.settings().issueStatus != "open"
|
||||
|| filterOptions?.selectedMilestone.isEmpty == false
|
||||
|| filterOptions?.selectedLabels.isEmpty == false
|
||||
}
|
||||
|
||||
private func updateFilterTint() {
|
||||
navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel
|
||||
navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive
|
||||
? "Filters active"
|
||||
: "Default filters"
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard rows.isEmpty else {
|
||||
tableView.backgroundView = nil
|
||||
return
|
||||
}
|
||||
let status = context.core.settings().issueStatus
|
||||
tableView.backgroundView = EmptyBackgroundView(
|
||||
title: "No \(status) issues",
|
||||
detail: filtersActive
|
||||
? "No issues match the selected filters."
|
||||
: "This repository has no \(status) issues."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user