Commit 1ce9486b6f
Verified · cmc
Layout: unified · split
gitbay/MRs/MRFilter.swift +4 −2
| @@ -1,7 +1,7 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | /// What `mr list` is narrowed to. The command takes no `--label` and no | 3 | /// What `mr list` is narrowed to. The command takes no `--assignee`, so |
| 4 | /// `--assignee`, so this offers neither. | 4 | /// this offers none. |
| 5 | nonisolated struct MRFilter: Equatable, Sendable { | 5 | nonisolated struct MRFilter: Equatable, Sendable { |
| 6 | 6 | ||
| 7 | enum State: String, CaseIterable, Identifiable, Sendable { | 7 | enum State: String, CaseIterable, Identifiable, Sendable { |
| @@ -20,12 +20,14 @@ nonisolated struct MRFilter: Equatable, Sendable { | |||
| 20 | 20 | ||
| 21 | var state: State = .open | 21 | var state: State = .open |
| 22 | var search = "" | 22 | var search = "" |
| 23 | var label: String? | ||
| 23 | var author: String? | 24 | var author: String? |
| 24 | var milestone: String? | 25 | var milestone: String? |
| 25 | 26 | ||
| 26 | func flags() -> [String] { | 27 | func flags() -> [String] { |
| 27 | var flags = ["--state", state.rawValue] | 28 | var flags = ["--state", state.rawValue] |
| 28 | flags += filterFlag("--search", search) | 29 | flags += filterFlag("--search", search) |
| 30 | flags += filterFlag("--label", label) | ||
| 29 | flags += filterFlag("--author", author) | 31 | flags += filterFlag("--author", author) |
| 30 | flags += filterFlag("--milestone", milestone) | 32 | flags += filterFlag("--milestone", milestone) |
| 31 | return flags | 33 | return flags |
gitbay/MRs/MRListViewModel.swift +13
| @@ -57,6 +57,19 @@ final class MRListViewModel { | |||
| 57 | )) ?? [] | 57 | )) ?? [] |
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | /// The repository's labels for the filter picker and its chips; | ||
| 61 | /// fetched on first use, like the milestones. | ||
| 62 | private(set) var labels: [IssueLabel] = [] | ||
| 63 | private(set) var colors = LabelColors() | ||
| 64 | private var labelsLoaded = false | ||
| 65 | |||
| 66 | func loadLabels() async { | ||
| 67 | guard !labelsLoaded else { return } | ||
| 68 | labelsLoaded = true | ||
| 69 | labels = (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | ||
| 70 | colors = LabelColors(labels) | ||
| 71 | } | ||
| 72 | |||
| 60 | private func scheduleSearchReload() { | 73 | private func scheduleSearchReload() { |
| 61 | reloadTask?.cancel() | 74 | reloadTask?.cancel() |
| 62 | reloadTask = Task { | 75 | reloadTask = Task { |
gitbay/Views/MRs/MRListView.swift +11 −2
| @@ -64,8 +64,17 @@ struct MRListView: View { | |||
| 64 | let state = model.filter.state | 64 | let state = model.filter.state |
| 65 | model.filter = MRFilter(state: state) | 65 | model.filter = MRFilter(state: state) |
| 66 | } | 66 | } |
| 67 | ) {} | 67 | ) { |
| 68 | .task { await model.loadMilestones() } | 68 | LabelFilterSection( |
| 69 | label: Bindable(model).filter.label, | ||
| 70 | labels: model.labels, | ||
| 71 | colors: model.colors | ||
| 72 | ) | ||
| 73 | } | ||
| 74 | .task { | ||
| 75 | await model.loadMilestones() | ||
| 76 | await model.loadLabels() | ||
| 77 | } | ||
| 69 | } | 78 | } |
| 70 | .sheet(isPresented: $composing) { | 79 | .sheet(isPresented: $composing) { |
| 71 | MRCreateSheet(model: createModel) { | 80 | MRCreateSheet(model: createModel) { |
gitbayTests/MRLabelTests.swift +55
| @@ -87,3 +87,58 @@ struct MRLabelTests { | |||
| 87 | #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) | 87 | #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) |
| 88 | } | 88 | } |
| 89 | } | 89 | } |
| 90 | |||
| 91 | struct MRLabelFilterFlagTests { | ||
| 92 | |||
| 93 | /// Same position as in IssueFilter: after --search, before --author. | ||
| 94 | @Test func aLabelSendsTheLabelFlag() { | ||
| 95 | var filter = MRFilter() | ||
| 96 | filter.search = "gate" | ||
| 97 | filter.label = "bug" | ||
| 98 | filter.author = "cmc" | ||
| 99 | #expect(filter.flags() == ["--state", "open", "--search", "gate", "--label", "bug", "--author", "cmc"]) | ||
| 100 | #expect(filter.isNarrowed) | ||
| 101 | } | ||
| 102 | |||
| 103 | @Test func noLabelSendsNoFlag() { | ||
| 104 | #expect(!MRFilter().flags().contains("--label")) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | @MainActor | ||
| 109 | struct MRLabelFilterTests { | ||
| 110 | |||
| 111 | @Test func pickingALabelReloadsWithIt() async throws { | ||
| 112 | let (client, stub) = try makeClient() | ||
| 113 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | ||
| 114 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 115 | await model.load() | ||
| 116 | |||
| 117 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | ||
| 118 | model.filter.label = "bug" | ||
| 119 | await until { | ||
| 120 | guard stub.seen.count == 2 else { return false } | ||
| 121 | if case .loading = model.state { return false } | ||
| 122 | return true | ||
| 123 | } | ||
| 124 | |||
| 125 | let query = try #require(stub.seen.last?.url.query()) | ||
| 126 | #expect(query.contains("argv=--label&argv=bug")) | ||
| 127 | } | ||
| 128 | |||
| 129 | @Test func thePickerOffersTheRepositorysLabels() async throws { | ||
| 130 | let (client, stub) = try makeClient() | ||
| 131 | stub.enqueue(.init(status: 200, json: """ | ||
| 132 | {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2},\ | ||
| 133 | {"name":"ios","issues":0}],"exit_code":0} | ||
| 134 | """, match: "argv=label&argv=list")) | ||
| 135 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 136 | |||
| 137 | await model.loadLabels() | ||
| 138 | await model.loadLabels() | ||
| 139 | |||
| 140 | #expect(model.labels.map(\.name) == ["bug", "ios"]) | ||
| 141 | #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) | ||
| 142 | #expect(stub.seen.count == 1) | ||
| 143 | } | ||
| 144 | } | ||
gitbayUITests/LiveSmokeUITests.swift +49
| @@ -81,6 +81,15 @@ final class LiveSmokeUITests: XCTestCase { | |||
| 81 | closeSmokeMergeRequests() | 81 | closeSmokeMergeRequests() |
| 82 | try openSupersedeFixtures() | 82 | try openSupersedeFixtures() |
| 83 | } | 83 | } |
| 84 | if name.contains("testMRLabelFlow") { | ||
| 85 | try requireFixtureRepo() | ||
| 86 | try requireFixtureBranch() | ||
| 87 | closeSmokeMergeRequests() | ||
| 88 | _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main", | ||
| 89 | "--title", Self.labelledTitle]) | ||
| 90 | try XCTSkipUnless(openSmokeMergeRequests().map { $0.title }.contains(Self.labelledTitle), | ||
| 91 | "could not open the label fixture merge request on \(Self.fixtureRepo)") | ||
| 92 | } | ||
| 84 | } | 93 | } |
| 85 | 94 | ||
| 86 | override func tearDownWithError() throws { | 95 | override func tearDownWithError() throws { |
| @@ -93,6 +102,10 @@ final class LiveSmokeUITests: XCTestCase { | |||
| 93 | if name.contains("testCloseInFavourFlow") { | 102 | if name.contains("testCloseInFavourFlow") { |
| 94 | closeSmokeMergeRequests() | 103 | closeSmokeMergeRequests() |
| 95 | } | 104 | } |
| 105 | if name.contains("testMRLabelFlow") { | ||
| 106 | closeSmokeMergeRequests() | ||
| 107 | _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel]) | ||
| 108 | } | ||
| 96 | } | 109 | } |
| 97 | 110 | ||
| 98 | // MARK: - Fixtures for the v1.2.0 flows | 111 | // MARK: - Fixtures for the v1.2.0 flows |
| @@ -138,6 +151,8 @@ final class LiveSmokeUITests: XCTestCase { | |||
| 138 | 151 | ||
| 139 | static let supersededTitle = "UI smoke: superseded" | 152 | static let supersededTitle = "UI smoke: superseded" |
| 140 | static let successorTitle = "UI smoke: successor" | 153 | static let successorTitle = "UI smoke: successor" |
| 154 | static let labelledTitle = "UI smoke: labels" | ||
| 155 | static let smokeMRLabel = "ui-smoke-mr-label" | ||
| 141 | 156 | ||
| 142 | /// Close in favour needs two open merge requests. The fixture branch | 157 | /// Close in favour needs two open merge requests. The fixture branch |
| 143 | /// gives one (ui-smoke -> main); main has commits ui-smoke lacks, so | 158 | /// gives one (ui-smoke -> main); main has commits ui-smoke lacks, so |
| @@ -1835,4 +1850,38 @@ extension LiveSmokeUITests { | |||
| 1835 | XCTAssertTrue(app.staticTexts[Self.successorTitle].firstMatch.waitForExistence(timeout: 20), | 1850 | XCTAssertTrue(app.staticTexts[Self.successorTitle].firstMatch.waitForExistence(timeout: 20), |
| 1836 | "link did not open the successor") | 1851 | "link did not open the successor") |
| 1837 | } | 1852 | } |
| 1853 | |||
| 1854 | /// A label added from the merge request screen shows as a chip, the | ||
| 1855 | /// list filters to it, and removing it clears the chip. | ||
| 1856 | func testMRLabelFlow() throws { | ||
| 1857 | openRepo(Self.fixtureRepo) | ||
| 1858 | app.staticTexts["Merge Requests"].firstMatch.tap() | ||
| 1859 | let row = app.staticTexts[Self.labelledTitle].firstMatch | ||
| 1860 | XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed") | ||
| 1861 | row.tap() | ||
| 1862 | |||
| 1863 | let field = app.textFields["mr-label-field"].firstMatch | ||
| 1864 | XCTAssertTrue(scrollTo(field), "no label field on the merge request") | ||
| 1865 | field.tap() | ||
| 1866 | field.typeText(Self.smokeMRLabel) | ||
| 1867 | app.buttons["mr-label-add"].firstMatch.tap() | ||
| 1868 | let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch | ||
| 1869 | XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip") | ||
| 1870 | |||
| 1871 | back() | ||
| 1872 | app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap() | ||
| 1873 | let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch | ||
| 1874 | XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker") | ||
| 1875 | menu.tap() | ||
| 1876 | let choice = app.buttons[Self.smokeMRLabel].firstMatch | ||
| 1877 | XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker") | ||
| 1878 | choice.tap() | ||
| 1879 | app.buttons["Done"].firstMatch.tap() | ||
| 1880 | XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR") | ||
| 1881 | |||
| 1882 | row.tap() | ||
| 1883 | XCTAssertTrue(scrollTo(remove), "chip missing on reopen") | ||
| 1884 | remove.tap() | ||
| 1885 | XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") | ||
| 1886 | } | ||
| 1838 | } | 1887 | } |