Commit da1c8a11b6
Unsigned
Layout: unified · split
gitbay/Issues/MilestoneListViewModel.swift +40 −1
| @@ -1,7 +1,8 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 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 | @Observable | 6 | @Observable |
| 6 | @MainActor | 7 | @MainActor |
| 7 | final class MilestoneListViewModel { | 8 | final class MilestoneListViewModel { |
| @@ -12,6 +13,8 @@ final class MilestoneListViewModel { | |||
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | private(set) var state: LoadState<[Milestone]> = .loading | 15 | private(set) var state: LoadState<[Milestone]> = .loading |
| 16 | private(set) var actionError: String? | ||
| 17 | private(set) var working = false | ||
| 15 | var filter: StateFilter = .open { | 18 | var filter: StateFilter = .open { |
| 16 | didSet { | 19 | didSet { |
| 17 | guard filter != oldValue else { return } | 20 | guard filter != oldValue else { return } |
| @@ -40,4 +43,40 @@ final class MilestoneListViewModel { | |||
| 40 | state = .from(error) | 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 | struct MilestoneListView: View { | 4 | struct MilestoneListView: View { |
| 5 | 5 | ||
| 6 | @State private var model: MilestoneListViewModel | 6 | @State private var model: MilestoneListViewModel |
| 7 | @State private var creating = false | ||
| 7 | 8 | ||
| 8 | init(client: GitbayClient, repo: String) { | 9 | init(client: GitbayClient, repo: String) { |
| 9 | _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo)) | 10 | _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo)) |
| @@ -20,18 +21,105 @@ struct MilestoneListView: View { | |||
| 20 | .listRowBackground(Color.clear) | 21 | .listRowBackground(Color.clear) |
| 21 | .listRowInsets(EdgeInsets()) | 22 | .listRowInsets(EdgeInsets()) |
| 22 | 23 | ||
| 24 | if let error = model.actionError { | ||
| 25 | Section { | ||
| 26 | GBNotice(error, .gbWarn) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 23 | ForEach(model.state.value ?? []) { milestone in | 30 | ForEach(model.state.value ?? []) { milestone in |
| 24 | MilestoneRow(milestone: milestone) | 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 | .overlay { LoadStateOverlay(state: model.state) } | 45 | .overlay { LoadStateOverlay(state: model.state) } |
| 28 | .navigationTitle("Milestones") | 46 | .navigationTitle("Milestones") |
| 29 | .navigationBarTitleDisplayMode(.inline) | 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 | .task { await model.load() } | 62 | .task { await model.load() } |
| 31 | .refreshable { await model.load() } | 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 | private struct MilestoneRow: View { | 123 | private struct MilestoneRow: View { |
| 36 | let milestone: Milestone | 124 | let milestone: Milestone |
| 37 | 125 | ||
gitbayTests/RefsMilestoneTests.swift +60
| @@ -123,6 +123,66 @@ struct MilestoneListViewModelTests { | |||
| 123 | let second = try #require(stub.seen.dropFirst().first) | 123 | let second = try #require(stub.seen.dropFirst().first) |
| 124 | #expect(second.url.query()?.contains("argv=closed") == true) | 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 | @MainActor | 188 | @MainActor |