Commit da1c8a11b6
Unsigned
Layout: unified · split
gitbay/Issues/MilestoneListViewModel.swift +40 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 4 | /// `milestone list <repo> --state <s>` — milestones with their progress. | |
| 4 | /// `milestone list <repo> --state <s>` — milestones with their progress, | |
| 5 | /// plus `milestone create`, `close` and `reopen`. | |
| 5 | 6 | @Observable |
| 6 | 7 | @MainActor |
| 7 | 8 | final class MilestoneListViewModel { |
| @@ -12,6 +13,8 @@ final class MilestoneListViewModel { | ||
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | private(set) var state: LoadState<[Milestone]> = .loading |
| 16 | private(set) var actionError: String? | |
| 17 | private(set) var working = false | |
| 15 | 18 | var filter: StateFilter = .open { |
| 16 | 19 | didSet { |
| 17 | 20 | guard filter != oldValue else { return } |
| @@ -40,4 +43,40 @@ final class MilestoneListViewModel { | ||
| 40 | 43 | state = .from(error) |
| 41 | 44 | } |
| 42 | 45 | } |
| 46 | ||
| 47 | /// `milestone create <repo> <title> [--description <d>] [--due YYYY-MM-DD]`. | |
| 48 | /// Empty description and due are omitted rather than sent blank. | |
| 49 | func create(title: String, description: String, due: String) async -> Bool { | |
| 50 | let title = title.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 51 | guard !title.isEmpty else { return false } | |
| 52 | var argv = ["milestone", "create", repoPath, title] | |
| 53 | let description = description.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 54 | if !description.isEmpty { argv += ["--description", description] } | |
| 55 | let due = due.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 56 | if !due.isEmpty { argv += ["--due", due] } | |
| 57 | await perform(argv) | |
| 58 | return actionError == nil | |
| 59 | } | |
| 60 | ||
| 61 | func close(_ title: String) async { | |
| 62 | await perform(["milestone", "close", repoPath, title]) | |
| 63 | } | |
| 64 | ||
| 65 | func reopen(_ title: String) async { | |
| 66 | await perform(["milestone", "reopen", repoPath, title]) | |
| 67 | } | |
| 68 | ||
| 69 | private func perform(_ argv: [String]) async { | |
| 70 | working = true | |
| 71 | actionError = nil | |
| 72 | defer { working = false } | |
| 73 | do { | |
| 74 | try await client.run(argv) | |
| 75 | await load() | |
| 76 | } catch let error as GitbayError { | |
| 77 | actionError = error.userFacingMessage | |
| 78 | } catch { | |
| 79 | actionError = GitbayError.transport(error).userFacingMessage | |
| 80 | } | |
| 81 | } | |
| 43 | 82 | } |
gitbay/Views/Issues/MilestoneListView.swift +88
| @@ -4,6 +4,7 @@ import SwiftUI | ||
| 4 | 4 | struct MilestoneListView: View { |
| 5 | 5 | |
| 6 | 6 | @State private var model: MilestoneListViewModel |
| 7 | @State private var creating = false | |
| 7 | 8 | |
| 8 | 9 | init(client: GitbayClient, repo: String) { |
| 9 | 10 | _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo)) |
| @@ -20,18 +21,105 @@ struct MilestoneListView: View { | ||
| 20 | 21 | .listRowBackground(Color.clear) |
| 21 | 22 | .listRowInsets(EdgeInsets()) |
| 22 | 23 | |
| 24 | if let error = model.actionError { | |
| 25 | Section { | |
| 26 | GBNotice(error, .gbWarn) | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 23 | 30 | ForEach(model.state.value ?? []) { milestone in |
| 24 | 31 | MilestoneRow(milestone: milestone) |
| 32 | .swipeActions { | |
| 33 | if milestone.state == "open" { | |
| 34 | Button("Close") { | |
| 35 | Task { await model.close(milestone.title) } | |
| 36 | } | |
| 37 | } else { | |
| 38 | Button("Reopen") { | |
| 39 | Task { await model.reopen(milestone.title) } | |
| 40 | } | |
| 41 | } | |
| 42 | } | |
| 25 | 43 | } |
| 26 | 44 | } |
| 27 | 45 | .overlay { LoadStateOverlay(state: model.state) } |
| 28 | 46 | .navigationTitle("Milestones") |
| 29 | 47 | .navigationBarTitleDisplayMode(.inline) |
| 48 | .toolbar { | |
| 49 | ToolbarItem(placement: .topBarTrailing) { | |
| 50 | Button { | |
| 51 | creating = true | |
| 52 | } label: { | |
| 53 | Image(systemName: "plus") | |
| 54 | } | |
| 55 | .disabled(model.working) | |
| 56 | .accessibilityIdentifier("milestone-create-button") | |
| 57 | } | |
| 58 | } | |
| 59 | .sheet(isPresented: $creating) { | |
| 60 | MilestoneCreateSheet(model: model) { creating = false } | |
| 61 | } | |
| 30 | 62 | .task { await model.load() } |
| 31 | 63 | .refreshable { await model.load() } |
| 32 | 64 | } |
| 33 | 65 | } |
| 34 | 66 | |
| 67 | /// `milestone create` — title, optional description, optional due date | |
| 68 | /// typed the way the command takes it. | |
| 69 | private struct MilestoneCreateSheet: View { | |
| 70 | let model: MilestoneListViewModel | |
| 71 | let onCreated: () -> Void | |
| 72 | ||
| 73 | @State private var title = "" | |
| 74 | @State private var description = "" | |
| 75 | @State private var due = "" | |
| 76 | ||
| 77 | var body: some View { | |
| 78 | NavigationStack { | |
| 79 | Form { | |
| 80 | TextField("Title", text: $title) | |
| 81 | .autocorrectionDisabled() | |
| 82 | .accessibilityIdentifier("milestone-title") | |
| 83 | TextField("Description", text: $description, axis: .vertical) | |
| 84 | .lineLimit(1...4) | |
| 85 | .autocorrectionDisabled() | |
| 86 | TextField("Due (YYYY-MM-DD)", text: $due) | |
| 87 | .keyboardType(.numbersAndPunctuation) | |
| 88 | .autocorrectionDisabled() | |
| 89 | if let error = model.actionError { | |
| 90 | Section { | |
| 91 | GBNotice(error, .gbWarn) | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | .navigationTitle("New Milestone") | |
| 96 | .navigationBarTitleDisplayMode(.inline) | |
| 97 | .toolbar { | |
| 98 | ToolbarItem(placement: .cancellationAction) { | |
| 99 | Button("Cancel") { onCreated() } | |
| 100 | .disabled(model.working) | |
| 101 | } | |
| 102 | ToolbarItem(placement: .confirmationAction) { | |
| 103 | if model.working { | |
| 104 | ProgressView() | |
| 105 | } else { | |
| 106 | Button("Create") { | |
| 107 | Task { | |
| 108 | if await model.create(title: title, description: description, due: due) { | |
| 109 | onCreated() | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 114 | .accessibilityIdentifier("milestone-submit") | |
| 115 | } | |
| 116 | } | |
| 117 | } | |
| 118 | } | |
| 119 | .interactiveDismissDisabled(model.working) | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 35 | 123 | private struct MilestoneRow: View { |
| 36 | 124 | let milestone: Milestone |
| 37 | 125 | |
gitbayTests/RefsMilestoneTests.swift +60
| @@ -123,6 +123,66 @@ struct MilestoneListViewModelTests { | ||
| 123 | 123 | let second = try #require(stub.seen.dropFirst().first) |
| 124 | 124 | #expect(second.url.query()?.contains("argv=closed") == true) |
| 125 | 125 | } |
| 126 | ||
| 127 | private func loaded() async throws -> (MilestoneListViewModel, StubProtocol.Box) { | |
| 128 | let (client, stub) = try makeClient() | |
| 129 | stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list")) | |
| 130 | let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay") | |
| 131 | await model.load() | |
| 132 | return (model, stub) | |
| 133 | } | |
| 134 | ||
| 135 | @Test func createSendsTitleDescriptionAndDueThenReloads() async throws { | |
| 136 | let (model, stub) = try await loaded() | |
| 137 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"milestone":"v1.1.0"},"exit_code":0}"#, match: "cmd")) | |
| 138 | stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list")) | |
| 139 | ||
| 140 | let ok = await model.create(title: "v1.1.0", description: "next", due: "2026-10-01") | |
| 141 | ||
| 142 | #expect(ok) | |
| 143 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 144 | #expect(try argvOf(write) == ["milestone", "create", "krz/gitbay", "v1.1.0", | |
| 145 | "--description", "next", "--due", "2026-10-01"]) | |
| 146 | #expect(stub.seen.filter { $0.method == "GET" }.count == 2) | |
| 147 | } | |
| 148 | ||
| 149 | @Test func createOmitsEmptyDescriptionAndDue() async throws { | |
| 150 | let (model, stub) = try await loaded() | |
| 151 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"milestone":"v1.1.0"},"exit_code":0}"#, match: "cmd")) | |
| 152 | stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list")) | |
| 153 | ||
| 154 | _ = await model.create(title: " v1.1.0 ", description: "", due: "") | |
| 155 | ||
| 156 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 157 | #expect(try argvOf(write) == ["milestone", "create", "krz/gitbay", "v1.1.0"]) | |
| 158 | } | |
| 159 | ||
| 160 | @Test func closeAndReopenUseTheirCommands() async throws { | |
| 161 | let (model, stub) = try await loaded() | |
| 162 | for _ in 0..<2 { | |
| 163 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd")) | |
| 164 | stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list")) | |
| 165 | } | |
| 166 | ||
| 167 | await model.close("v1.0.0") | |
| 168 | await model.reopen("v0.5.0") | |
| 169 | ||
| 170 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 171 | #expect(writes == [ | |
| 172 | ["milestone", "close", "krz/gitbay", "v1.0.0"], | |
| 173 | ["milestone", "reopen", "krz/gitbay", "v0.5.0"], | |
| 174 | ]) | |
| 175 | } | |
| 176 | ||
| 177 | @Test func aRefusedCreateSurfacesAndReturnsFalse() async throws { | |
| 178 | let (model, stub) = try await loaded() | |
| 179 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"write access required","exit_code":4}"#, match: "cmd")) | |
| 180 | ||
| 181 | let ok = await model.create(title: "v9", description: "", due: "") | |
| 182 | ||
| 183 | #expect(!ok) | |
| 184 | #expect(model.actionError == "write access required") | |
| 185 | } | |
| 126 | 186 | } |
| 127 | 187 | |
| 128 | 188 | @MainActor |