Commit 381d5e7372
381d5e7372ca3471ae4b392fdfe4d8eb84da7d25
parent: da1c8a11b6
Unsigned
cmc <hello@cleberg.net> · 2026-09-07 17:19 UTC
Issue templates in the compose sheet (!70)
issue templates loads with the issue list; when the repository has any, the New Issue sheet offers a template menu that fills the body. A failed read leaves the sheet without the menu. No live-suite step: the fixture repository has no templates.
Layout: unified · split
gitbay/Issues/IssueCreateViewModel.swift
+16 −1
| @@ -1,13 +1,21 @@ |
| 1 | 1 | import Foundation |
| 2 | 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 | 12 | @Observable |
| 6 | 13 | @MainActor |
| 7 | 14 | final class IssueCreateViewModel { |
| 8 | 15 | |
| 9 | 16 | private(set) var working = false |
| 10 | 17 | private(set) var errorMessage: String? |
| 18 | private(set) var templates: [IssueTemplate] = [] |
| 11 | 19 | var format: String? |
| 12 | 20 | |
| 13 | 21 | private let client: GitbayClient |
| @@ -22,6 +30,13 @@ final class IssueCreateViewModel { |
| 22 | 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 | 40 | /// Returns the new issue's number, or nil with `errorMessage` set. |
| 26 | 41 | func create(title: String, body: String) async -> Int64? { |
| 27 | 42 | working = true |
gitbay/Views/Issues/IssueListView.swift
+3 −1
| @@ -85,7 +85,8 @@ struct IssueListView: View { |
| 85 | 85 | errorMessage: createModel.errorMessage, |
| 86 | 86 | title: $draftTitle, |
| 87 | 87 | bodyText: $draftBody, |
| 88 | | format: $draftFormat |
| 88 | format: $draftFormat, |
| 89 | templates: createModel.templates |
| 89 | 90 | ) { |
| 90 | 91 | createModel.format = draftFormat |
| 91 | 92 | Task { |
| @@ -100,6 +101,7 @@ struct IssueListView: View { |
| 100 | 101 | } |
| 101 | 102 | } |
| 102 | 103 | .task { await model.load() } |
| 104 | .task { await createModel.loadTemplates() } |
| 103 | 105 | .refreshable { await model.load() } |
| 104 | 106 | } |
| 105 | 107 | } |
gitbay/Views/Shared/ComposeSheet.swift
+15 −1
| @@ -14,13 +14,16 @@ struct ComposeSheet: View { |
| 14 | 14 | /// when the value changed and the picker kept whatever it first saw. |
| 15 | 15 | @Binding var format: String |
| 16 | 16 | let showsFormat: Bool |
| 17 | /// Issue templates to start the body from; empty hides the menu. |
| 18 | let templates: [IssueTemplate] |
| 17 | 19 | let onSubmit: () -> Void |
| 18 | 20 | |
| 19 | 21 | /// A nil `format` hides the picker entirely — the release editor has |
| 20 | 22 | /// no parity row and passes nothing, leaving that call site untouched. |
| 21 | 23 | init(heading: String, submitLabel: String, working: Bool, errorMessage: String?, |
| 22 | 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 | 27 | self.heading = heading |
| 25 | 28 | self.submitLabel = submitLabel |
| 26 | 29 | self.working = working |
| @@ -29,6 +32,7 @@ struct ComposeSheet: View { |
| 29 | 32 | _bodyText = bodyText |
| 30 | 33 | _format = format ?? .constant("md") |
| 31 | 34 | showsFormat = format != nil |
| 35 | self.templates = templates |
| 32 | 36 | self.onSubmit = onSubmit |
| 33 | 37 | } |
| 34 | 38 | |
| @@ -46,6 +50,16 @@ struct ComposeSheet: View { |
| 46 | 50 | .accessibilityIdentifier("compose-title") |
| 47 | 51 | } |
| 48 | 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 | 63 | TextEditor(text: $bodyText) |
| 50 | 64 | .frame(minHeight: 160) |
| 51 | 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 | 95 | @MainActor |
| 65 | 96 | struct MRCreateViewModelTests { |
| 66 | 97 | |