Commit 5e81daed1a
Verified · cmc
Layout: unified · split
gitbay/Views/Issues/IssueListView.swift +32
| @@ -5,6 +5,7 @@ struct IssueListView: View { | |||
| 5 | @State private var model: IssueListViewModel | 5 | @State private var model: IssueListViewModel |
| 6 | @State private var createModel: IssueCreateViewModel | 6 | @State private var createModel: IssueCreateViewModel |
| 7 | @State private var composing = false | 7 | @State private var composing = false |
| 8 | @State private var filtering = false | ||
| 8 | @State private var draftTitle = "" | 9 | @State private var draftTitle = "" |
| 9 | @State private var draftBody = "" | 10 | @State private var draftBody = "" |
| 10 | 11 | ||
| @@ -32,9 +33,20 @@ struct IssueListView: View { | |||
| 32 | PageFooter(list: model.list) | 33 | PageFooter(list: model.list) |
| 33 | } | 34 | } |
| 34 | .overlay { LoadStateOverlay(state: model.state) } | 35 | .overlay { LoadStateOverlay(state: model.state) } |
| 36 | .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies") | ||
| 35 | .navigationTitle("Issues") | 37 | .navigationTitle("Issues") |
| 36 | .navigationBarTitleDisplayMode(.inline) | 38 | .navigationBarTitleDisplayMode(.inline) |
| 37 | .toolbar { | 39 | .toolbar { |
| 40 | ToolbarItem(placement: .topBarTrailing) { | ||
| 41 | Button { | ||
| 42 | filtering = true | ||
| 43 | } label: { | ||
| 44 | Image(systemName: model.filter.isNarrowed | ||
| 45 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 46 | : "line.3.horizontal.decrease.circle") | ||
| 47 | } | ||
| 48 | .accessibilityIdentifier("issue-filter-button") | ||
| 49 | } | ||
| 38 | ToolbarItem(placement: .topBarTrailing) { | 50 | ToolbarItem(placement: .topBarTrailing) { |
| 39 | Button { | 51 | Button { |
| 40 | composing = true | 52 | composing = true |
| @@ -44,6 +56,26 @@ struct IssueListView: View { | |||
| 44 | .accessibilityIdentifier("issue-create-button") | 56 | .accessibilityIdentifier("issue-create-button") |
| 45 | } | 57 | } |
| 46 | } | 58 | } |
| 59 | .sheet(isPresented: $filtering) { | ||
| 60 | FilterSheet( | ||
| 61 | title: "Filter Issues", | ||
| 62 | author: Bindable(model).filter.author, | ||
| 63 | milestone: Bindable(model).filter.milestone, | ||
| 64 | milestones: model.milestones, | ||
| 65 | onClear: { | ||
| 66 | let state = model.filter.state | ||
| 67 | model.filter = IssueFilter(state: state) | ||
| 68 | } | ||
| 69 | ) { | ||
| 70 | IssueFilterExtra( | ||
| 71 | label: Bindable(model).filter.label, | ||
| 72 | assignee: Bindable(model).filter.assignee, | ||
| 73 | labels: model.labels, | ||
| 74 | colors: model.colors | ||
| 75 | ) | ||
| 76 | } | ||
| 77 | .task { await model.loadMilestones() } | ||
| 78 | } | ||
| 47 | .sheet(isPresented: $composing) { | 79 | .sheet(isPresented: $composing) { |
| 48 | ComposeSheet( | 80 | ComposeSheet( |
| 49 | heading: "New Issue", | 81 | heading: "New Issue", |
gitbay/Views/MRs/MRListView.swift +25
| @@ -5,6 +5,7 @@ struct MRListView: View { | |||
| 5 | @State private var model: MRListViewModel | 5 | @State private var model: MRListViewModel |
| 6 | @State private var createModel: MRCreateViewModel | 6 | @State private var createModel: MRCreateViewModel |
| 7 | @State private var composing = false | 7 | @State private var composing = false |
| 8 | @State private var filtering = false | ||
| 8 | 9 | ||
| 9 | init(client: GitbayClient, repo: String) { | 10 | init(client: GitbayClient, repo: String) { |
| 10 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) | 11 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) |
| @@ -30,9 +31,20 @@ struct MRListView: View { | |||
| 30 | PageFooter(list: model.list) | 31 | PageFooter(list: model.list) |
| 31 | } | 32 | } |
| 32 | .overlay { LoadStateOverlay(state: model.state) } | 33 | .overlay { LoadStateOverlay(state: model.state) } |
| 34 | .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies") | ||
| 33 | .navigationTitle("Merge Requests") | 35 | .navigationTitle("Merge Requests") |
| 34 | .navigationBarTitleDisplayMode(.inline) | 36 | .navigationBarTitleDisplayMode(.inline) |
| 35 | .toolbar { | 37 | .toolbar { |
| 38 | ToolbarItem(placement: .topBarTrailing) { | ||
| 39 | Button { | ||
| 40 | filtering = true | ||
| 41 | } label: { | ||
| 42 | Image(systemName: model.filter.isNarrowed | ||
| 43 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 44 | : "line.3.horizontal.decrease.circle") | ||
| 45 | } | ||
| 46 | .accessibilityIdentifier("mr-filter-button") | ||
| 47 | } | ||
| 36 | ToolbarItem(placement: .topBarTrailing) { | 48 | ToolbarItem(placement: .topBarTrailing) { |
| 37 | Button { | 49 | Button { |
| 38 | composing = true | 50 | composing = true |
| @@ -42,6 +54,19 @@ struct MRListView: View { | |||
| 42 | .accessibilityIdentifier("mr-create-button") | 54 | .accessibilityIdentifier("mr-create-button") |
| 43 | } | 55 | } |
| 44 | } | 56 | } |
| 57 | .sheet(isPresented: $filtering) { | ||
| 58 | FilterSheet( | ||
| 59 | title: "Filter Merge Requests", | ||
| 60 | author: Bindable(model).filter.author, | ||
| 61 | milestone: Bindable(model).filter.milestone, | ||
| 62 | milestones: model.milestones, | ||
| 63 | onClear: { | ||
| 64 | let state = model.filter.state | ||
| 65 | model.filter = MRFilter(state: state) | ||
| 66 | } | ||
| 67 | ) {} | ||
| 68 | .task { await model.loadMilestones() } | ||
| 69 | } | ||
| 45 | .sheet(isPresented: $composing) { | 70 | .sheet(isPresented: $composing) { |
| 46 | MRCreateSheet(model: createModel) { | 71 | MRCreateSheet(model: createModel) { |
| 47 | composing = false | 72 | composing = false |
gitbay/Views/Shared/FilterSheet.swift added +115
| @@ -0,0 +1,115 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// The issue and MR lists' filter sheet. A `Form` with the fields both | ||
| 4 | /// lists share — author and milestone — plus an `extra` slot for the | ||
| 5 | /// rows only issues have, since `mr list` takes neither `--label` nor | ||
| 6 | /// `--assignee`. | ||
| 7 | struct FilterSheet<Extra: View>: View { | ||
| 8 | |||
| 9 | let title: String | ||
| 10 | @Binding var author: String? | ||
| 11 | @Binding var milestone: String? | ||
| 12 | let milestones: [Milestone] | ||
| 13 | let onClear: () -> Void | ||
| 14 | @ViewBuilder var extra: Extra | ||
| 15 | |||
| 16 | @Environment(\.dismiss) private var dismiss | ||
| 17 | |||
| 18 | var body: some View { | ||
| 19 | NavigationStack { | ||
| 20 | Form { | ||
| 21 | extra | ||
| 22 | Section("Author") { | ||
| 23 | TextField("Username", text: $author.orEmpty) | ||
| 24 | .autocorrectionDisabled() | ||
| 25 | .textInputAutocapitalization(.never) | ||
| 26 | .accessibilityIdentifier("filter-author") | ||
| 27 | } | ||
| 28 | Section("Milestone") { | ||
| 29 | Menu { | ||
| 30 | Button("Any milestone") { milestone = nil } | ||
| 31 | Button("No milestone") { milestone = "none" } | ||
| 32 | ForEach(milestones) { entry in | ||
| 33 | Button(entry.title) { milestone = entry.title } | ||
| 34 | } | ||
| 35 | } label: { | ||
| 36 | HStack { | ||
| 37 | Text(milestone ?? "Any milestone") | ||
| 38 | Spacer() | ||
| 39 | Image(systemName: "chevron.up.chevron.down") | ||
| 40 | .font(.gbSans(.caption2)) | ||
| 41 | .foregroundStyle(.secondary) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | .accessibilityIdentifier("filter-milestone-menu") | ||
| 45 | } | ||
| 46 | Section { | ||
| 47 | Button("Clear filters", role: .destructive, action: onClear) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | .navigationTitle(title) | ||
| 51 | .navigationBarTitleDisplayMode(.inline) | ||
| 52 | .toolbar { | ||
| 53 | ToolbarItem(placement: .confirmationAction) { | ||
| 54 | Button("Done") { dismiss() } | ||
| 55 | } | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// The label and assignee rows the issue list's sheet adds through | ||
| 62 | /// `FilterSheet`'s `extra` slot. | ||
| 63 | struct IssueFilterExtra: View { | ||
| 64 | |||
| 65 | @Binding var label: String? | ||
| 66 | @Binding var assignee: String? | ||
| 67 | let labels: [IssueLabel] | ||
| 68 | let colors: LabelColors | ||
| 69 | |||
| 70 | var body: some View { | ||
| 71 | Section("Label") { | ||
| 72 | Menu { | ||
| 73 | Button("Any label") { label = nil } | ||
| 74 | ForEach(labels) { entry in | ||
| 75 | Button { | ||
| 76 | label = entry.name | ||
| 77 | } label: { | ||
| 78 | Text(entry.name) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } label: { | ||
| 82 | HStack { | ||
| 83 | if let label { | ||
| 84 | GBChip(label, colors.color(label)) | ||
| 85 | } else { | ||
| 86 | Text("Any label") | ||
| 87 | } | ||
| 88 | Spacer() | ||
| 89 | Image(systemName: "chevron.up.chevron.down") | ||
| 90 | .font(.gbSans(.caption2)) | ||
| 91 | .foregroundStyle(.secondary) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | .accessibilityIdentifier("filter-label-menu") | ||
| 95 | } | ||
| 96 | Section("Assignee") { | ||
| 97 | TextField("Username", text: $assignee.orEmpty) | ||
| 98 | .autocorrectionDisabled() | ||
| 99 | .textInputAutocapitalization(.never) | ||
| 100 | .accessibilityIdentifier("filter-assignee") | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | extension Binding where Value == String? { | ||
| 106 | /// Bridges an optional filter field to a plain `TextField`: empty | ||
| 107 | /// text writes back `nil` rather than `""`, since the two are | ||
| 108 | /// distinct filter values (no flag sent versus `--flag ""`). | ||
| 109 | var orEmpty: Binding<String> { | ||
| 110 | Binding<String>( | ||
| 111 | get: { self.wrappedValue ?? "" }, | ||
| 112 | set: { self.wrappedValue = $0.isEmpty ? nil : $0 } | ||
| 113 | ) | ||
| 114 | } | ||
| 115 | } | ||