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 | 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 | |