Issue templates in the compose sheet !70
4 files changed, +65 −3
Layout: unified · split
gitbay/Issues/IssueCreateViewModel.swift +16 −1
| @@ -1,13 +1,21 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// `issue create <repo> --title <t> --file -`. | 4 | /// One of `issue templates <repo>` — a body the compose sheet can start from. |
| 5 | nonisolated struct IssueTemplate: Decodable, Sendable, Hashable, Identifiable { | ||
| 6 | let name: String | ||
| 7 | let body: String | ||
| 8 | var id: String { name } | ||
| 9 | } | ||
| 10 | |||
| 11 | /// `issue create <repo> --title <t> --file -`, and the repo's templates. | ||
| 5 | @Observable | 12 | @Observable |
| 6 | @MainActor | 13 | @MainActor |
| 7 | final class IssueCreateViewModel { | 14 | final class IssueCreateViewModel { |
| 8 | 15 | ||
| 9 | private(set) var working = false | 16 | private(set) var working = false |
| 10 | private(set) var errorMessage: String? | 17 | private(set) var errorMessage: String? |
| 18 | private(set) var templates: [IssueTemplate] = [] | ||
| 11 | var format: String? | 19 | var format: String? |
| 12 | 20 | ||
| 13 | private let client: GitbayClient | 21 | private let client: GitbayClient |
| @@ -22,6 +30,13 @@ final class IssueCreateViewModel { | |||
| 22 | let number: Int64 | 30 | let number: Int64 |
| 23 | } | 31 | } |
| 24 | 32 | ||
| 33 | /// `issue templates <repo>`. Templates are a convenience, so a failed | ||
| 34 | /// read leaves the sheet without them rather than surfacing an error. | ||
| 35 | func loadTemplates() async { | ||
| 36 | templates = (try? await client.readList( | ||
| 37 | ["issue", "templates", repoPath], of: IssueTemplate.self)) ?? [] | ||
| 38 | } | ||
| 39 | |||
| 25 | /// Returns the new issue's number, or nil with `errorMessage` set. | 40 | /// Returns the new issue's number, or nil with `errorMessage` set. |
| 26 | func create(title: String, body: String) async -> Int64? { | 41 | func create(title: String, body: String) async -> Int64? { |
| 27 | working = true | 42 | working = true |
gitbay/Views/Issues/IssueListView.swift +3 −1
| @@ -85,7 +85,8 @@ struct IssueListView: View { | |||
| 85 | errorMessage: createModel.errorMessage, | 85 | errorMessage: createModel.errorMessage, |
| 86 | title: $draftTitle, | 86 | title: $draftTitle, |
| 87 | bodyText: $draftBody, | 87 | bodyText: $draftBody, |
| 88 | format: $draftFormat | 88 | format: $draftFormat, |
| 89 | templates: createModel.templates | ||
| 89 | ) { | 90 | ) { |
| 90 | createModel.format = draftFormat | 91 | createModel.format = draftFormat |
| 91 | Task { | 92 | Task { |
| @@ -100,6 +101,7 @@ struct IssueListView: View { | |||
| 100 | } | 101 | } |
| 101 | } | 102 | } |
| 102 | .task { await model.load() } | 103 | .task { await model.load() } |
| 104 | .task { await createModel.loadTemplates() } | ||
| 103 | .refreshable { await model.load() } | 105 | .refreshable { await model.load() } |
| 104 | } | 106 | } |
| 105 | } | 107 | } |
gitbay/Views/Shared/ComposeSheet.swift +15 −1
| @@ -14,13 +14,16 @@ struct ComposeSheet: View { | |||
| 14 | /// when the value changed and the picker kept whatever it first saw. | 14 | /// when the value changed and the picker kept whatever it first saw. |
| 15 | @Binding var format: String | 15 | @Binding var format: String |
| 16 | let showsFormat: Bool | 16 | let showsFormat: Bool |
| 17 | /// Issue templates to start the body from; empty hides the menu. | ||
| 18 | let templates: [IssueTemplate] | ||
| 17 | let onSubmit: () -> Void | 19 | let onSubmit: () -> Void |
| 18 | 20 | ||
| 19 | /// A nil `format` hides the picker entirely — the release editor has | 21 | /// A nil `format` hides the picker entirely — the release editor has |
| 20 | /// no parity row and passes nothing, leaving that call site untouched. | 22 | /// no parity row and passes nothing, leaving that call site untouched. |
| 21 | init(heading: String, submitLabel: String, working: Bool, errorMessage: String?, | 23 | init(heading: String, submitLabel: String, working: Bool, errorMessage: String?, |
| 22 | title: Binding<String>, bodyText: Binding<String>, | 24 | title: Binding<String>, bodyText: Binding<String>, |
| 23 | format: Binding<String>? = nil, onSubmit: @escaping () -> Void) { | 25 | format: Binding<String>? = nil, templates: [IssueTemplate] = [], |
| 26 | onSubmit: @escaping () -> Void) { | ||
| 24 | self.heading = heading | 27 | self.heading = heading |
| 25 | self.submitLabel = submitLabel | 28 | self.submitLabel = submitLabel |
| 26 | self.working = working | 29 | self.working = working |
| @@ -29,6 +32,7 @@ struct ComposeSheet: View { | |||
| 29 | _bodyText = bodyText | 32 | _bodyText = bodyText |
| 30 | _format = format ?? .constant("md") | 33 | _format = format ?? .constant("md") |
| 31 | showsFormat = format != nil | 34 | showsFormat = format != nil |
| 35 | self.templates = templates | ||
| 32 | self.onSubmit = onSubmit | 36 | self.onSubmit = onSubmit |
| 33 | } | 37 | } |
| 34 | 38 | ||
| @@ -46,6 +50,16 @@ struct ComposeSheet: View { | |||
| 46 | .accessibilityIdentifier("compose-title") | 50 | .accessibilityIdentifier("compose-title") |
| 47 | } | 51 | } |
| 48 | Section("Body") { | 52 | Section("Body") { |
| 53 | if !templates.isEmpty { | ||
| 54 | Menu { | ||
| 55 | ForEach(templates) { template in | ||
| 56 | Button(template.name) { bodyText = template.body } | ||
| 57 | } | ||
| 58 | } label: { | ||
| 59 | Label("Template", systemImage: "doc.text") | ||
| 60 | } | ||
| 61 | .accessibilityIdentifier("compose-template-menu") | ||
| 62 | } | ||
| 49 | TextEditor(text: $bodyText) | 63 | TextEditor(text: $bodyText) |
| 50 | .frame(minHeight: 160) | 64 | .frame(minHeight: 160) |
| 51 | .font(.gbSans(.body)) | 65 | .font(.gbSans(.body)) |
gitbayTests/ComposeViewModelTests.swift +31
| @@ -61,6 +61,37 @@ struct IssueCreateViewModelTests { | |||
| 61 | } | 61 | } |
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | @MainActor | ||
| 65 | struct IssueTemplateTests { | ||
| 66 | |||
| 67 | @Test func loadTemplatesReadsTheRepoTemplates() async throws { | ||
| 68 | let (client, stub) = try makeClient() | ||
| 69 | stub.enqueue(.init(status: 200, json: """ | ||
| 70 | {"protocol_version":1,"data":[\ | ||
| 71 | {"name":"bug","body":"## Steps\\n\\n1. "},\ | ||
| 72 | {"name":"feature","body":"## Why\\n"}],"exit_code":0} | ||
| 73 | """)) | ||
| 74 | let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") | ||
| 75 | |||
| 76 | await model.loadTemplates() | ||
| 77 | |||
| 78 | #expect(model.templates.map(\.name) == ["bug", "feature"]) | ||
| 79 | #expect(model.templates.first?.body == "## Steps\n\n1. ") | ||
| 80 | #expect(stub.seen.first?.url.query() == "argv=issue&argv=templates&argv=krz/gitbay") | ||
| 81 | } | ||
| 82 | |||
| 83 | @Test func aTemplateReadFailureLeavesNoTemplatesAndNoError() async throws { | ||
| 84 | let (client, stub) = try makeClient() | ||
| 85 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | ||
| 86 | let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") | ||
| 87 | |||
| 88 | await model.loadTemplates() | ||
| 89 | |||
| 90 | #expect(model.templates.isEmpty) | ||
| 91 | #expect(model.errorMessage == nil) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 64 | @MainActor | 95 | @MainActor |
| 65 | struct MRCreateViewModelTests { | 96 | struct MRCreateViewModelTests { |
| 66 | 97 | ||