Org mark on repository label and milestone lists !96
10 files changed, +106 −87
Layout: unified · split
gitbay/Account/AccountViewModel.swift +6 −22
| @@ -58,10 +58,6 @@ final class AccountViewModel { | |||
| 58 | /// Whether activity mail is on (v1.16.0); nil until read, or on a | 58 | /// Whether activity mail is on (v1.16.0); nil until read, or on a |
| 59 | /// server without the setting. Inbox rows are filed either way. | 59 | /// server without the setting. Inbox rows are filed either way. |
| 60 | private(set) var mailOn: Bool? | 60 | private(set) var mailOn: Bool? |
| 61 | /// Whether the account watches every issue and merge request on the | ||
| 62 | /// repositories it can write to (v1.19.0); nil until read, or on a | ||
| 63 | /// server whose `show` has no `watch`. | ||
| 64 | private(set) var watchOn: Bool? | ||
| 65 | /// The account's addresses; nil until read, or on a server without | 61 | /// The account's addresses; nil until read, or on a server without |
| 66 | /// `email list`. | 62 | /// `email list`. |
| 67 | private(set) var emails: [EmailAddress]? | 63 | private(set) var emails: [EmailAddress]? |
| @@ -87,35 +83,23 @@ final class AccountViewModel { | |||
| 87 | 83 | ||
| 88 | // MARK: - Notifications | 84 | // MARK: - Notifications |
| 89 | 85 | ||
| 90 | nonisolated private struct NotificationSettings: Decodable, Sendable { | 86 | nonisolated private struct MailSetting: Decodable, Sendable { |
| 91 | let mail: Bool | 87 | let mail: Bool |
| 92 | let watch: Bool? | ||
| 93 | } | 88 | } |
| 94 | 89 | ||
| 95 | func loadNotificationPreferences() async { | 90 | func loadMailPreference() async { |
| 96 | let settings = try? await client.read( | 91 | mailOn = (try? await client.read( |
| 97 | ["notifications", "settings", "show"], as: NotificationSettings.self) | 92 | ["notifications", "settings", "show"], as: MailSetting.self))?.mail |
| 98 | mailOn = settings?.mail | ||
| 99 | watchOn = settings?.watch | ||
| 100 | } | 93 | } |
| 101 | 94 | ||
| 102 | /// `notifications settings mail on|off`, then read the value back. | 95 | /// `notifications settings mail on|off`, then read the value back. |
| 103 | func setMail(_ on: Bool) async { | 96 | func setMail(_ on: Bool) async { |
| 104 | await setPreference("mail", on) | ||
| 105 | } | ||
| 106 | |||
| 107 | /// `notifications settings watch on|off`, then read the value back. | ||
| 108 | func setWatch(_ on: Bool) async { | ||
| 109 | await setPreference("watch", on) | ||
| 110 | } | ||
| 111 | |||
| 112 | private func setPreference(_ name: String, _ on: Bool) async { | ||
| 113 | working = true | 97 | working = true |
| 114 | actionError = nil | 98 | actionError = nil |
| 115 | defer { working = false } | 99 | defer { working = false } |
| 116 | do { | 100 | do { |
| 117 | try await client.run(["notifications", "settings", name, on ? "on" : "off"]) | 101 | try await client.run(["notifications", "settings", "mail", on ? "on" : "off"]) |
| 118 | await loadNotificationPreferences() | 102 | await loadMailPreference() |
| 119 | } catch let error as GitbayError { | 103 | } catch let error as GitbayError { |
| 120 | actionError = error.userFacingMessage | 104 | actionError = error.userFacingMessage |
| 121 | } catch { | 105 | } catch { |
gitbay/Issues/IssueLabel.swift +23
| @@ -10,9 +10,32 @@ nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable { | |||
| 10 | /// `#rrggbb` when set, absent otherwise — `label set` normalises to | 10 | /// `#rrggbb` when set, absent otherwise — `label set` normalises to |
| 11 | /// lowercase with the hash, so any other shape never reached the store. | 11 | /// lowercase with the hash, so any other shape never reached the store. |
| 12 | let color: String? | 12 | let color: String? |
| 13 | /// True for a label the repository sees through its org (v1.19.0). | ||
| 14 | /// The repository cannot recolour or remove it; `org label` does. | ||
| 15 | /// Absent on the wire for a repository row and on older servers. | ||
| 16 | let org: Bool | ||
| 13 | let issues: Int | 17 | let issues: Int |
| 14 | 18 | ||
| 15 | var id: String { name } | 19 | var id: String { name } |
| 20 | |||
| 21 | init(name: String, color: String?, org: Bool = false, issues: Int) { | ||
| 22 | self.name = name | ||
| 23 | self.color = color | ||
| 24 | self.org = org | ||
| 25 | self.issues = issues | ||
| 26 | } | ||
| 27 | |||
| 28 | private enum CodingKeys: String, CodingKey { | ||
| 29 | case name, color, org, issues | ||
| 30 | } | ||
| 31 | |||
| 32 | init(from decoder: Decoder) throws { | ||
| 33 | let c = try decoder.container(keyedBy: CodingKeys.self) | ||
| 34 | name = try c.decode(String.self, forKey: .name) | ||
| 35 | color = try c.decodeIfPresent(String.self, forKey: .color) | ||
| 36 | org = try c.decodeIfPresent(Bool.self, forKey: .org) ?? false | ||
| 37 | issues = try c.decode(Int.self, forKey: .issues) | ||
| 38 | } | ||
| 16 | } | 39 | } |
| 17 | 40 | ||
| 18 | /// Chip colours for labels, matching what the web paints | 41 | /// Chip colours for labels, matching what the web paints |
gitbay/Issues/Milestone.swift +20
| @@ -6,8 +6,28 @@ nonisolated struct Milestone: Decodable, Sendable, Hashable, Identifiable { | |||
| 6 | let description: String? | 6 | let description: String? |
| 7 | let due: String? | 7 | let due: String? |
| 8 | let state: String | 8 | let state: String |
| 9 | /// True for a milestone the repository sees through its org | ||
| 10 | /// (v1.19.0). The repository cannot close or reopen it; `org | ||
| 11 | /// milestone` does. Absent on the wire for a repository row and on | ||
| 12 | /// older servers. | ||
| 13 | let org: Bool | ||
| 9 | let open: Int | 14 | let open: Int |
| 10 | let closed: Int | 15 | let closed: Int |
| 11 | 16 | ||
| 12 | var id: String { title } | 17 | var id: String { title } |
| 18 | |||
| 19 | private enum CodingKeys: String, CodingKey { | ||
| 20 | case title, description, due, state, org, open, closed | ||
| 21 | } | ||
| 22 | |||
| 23 | init(from decoder: Decoder) throws { | ||
| 24 | let c = try decoder.container(keyedBy: CodingKeys.self) | ||
| 25 | title = try c.decode(String.self, forKey: .title) | ||
| 26 | description = try c.decodeIfPresent(String.self, forKey: .description) | ||
| 27 | due = try c.decodeIfPresent(String.self, forKey: .due) | ||
| 28 | state = try c.decode(String.self, forKey: .state) | ||
| 29 | org = try c.decodeIfPresent(Bool.self, forKey: .org) ?? false | ||
| 30 | open = try c.decode(Int.self, forKey: .open) | ||
| 31 | closed = try c.decode(Int.self, forKey: .closed) | ||
| 32 | } | ||
| 13 | } | 33 | } |
gitbay/Views/Account/AccountView.swift +5 −12
| @@ -57,7 +57,7 @@ struct AccountView: View { | |||
| 57 | .navigationTitle("Account") | 57 | .navigationTitle("Account") |
| 58 | .navigationBarTitleDisplayMode(.inline) | 58 | .navigationBarTitleDisplayMode(.inline) |
| 59 | .task { await model.load() } | 59 | .task { await model.load() } |
| 60 | .task { await model.loadNotificationPreferences() } | 60 | .task { await model.loadMailPreference() } |
| 61 | .task { await model.loadEmails() } | 61 | .task { await model.loadEmails() } |
| 62 | .confirmationDialog( | 62 | .confirmationDialog( |
| 63 | "Remove \(removingEmail?.address ?? "")?", | 63 | "Remove \(removingEmail?.address ?? "")?", |
| @@ -77,7 +77,7 @@ struct AccountView: View { | |||
| 77 | } | 77 | } |
| 78 | .refreshable { | 78 | .refreshable { |
| 79 | await model.load() | 79 | await model.load() |
| 80 | await model.loadNotificationPreferences() | 80 | await model.loadMailPreference() |
| 81 | await model.loadEmails() | 81 | await model.loadEmails() |
| 82 | } | 82 | } |
| 83 | .sheet(isPresented: $addingSSH) { | 83 | .sheet(isPresented: $addingSSH) { |
| @@ -142,9 +142,8 @@ struct AccountView: View { | |||
| 142 | 142 | ||
| 143 | // MARK: - Sections | 143 | // MARK: - Sections |
| 144 | 144 | ||
| 145 | /// `notifications settings mail` and `watch`. The inbox is filed | 145 | /// `notifications settings mail`: the mail half of notifications. The |
| 146 | /// either way, and login links still arrive. Watch lands with | 146 | /// inbox is filed either way, and login links still arrive. |
| 147 | /// v1.19.0; an older server shows mail alone. | ||
| 148 | @ViewBuilder | 147 | @ViewBuilder |
| 149 | private var notificationsSection: some View { | 148 | private var notificationsSection: some View { |
| 150 | if let mailOn = model.mailOn { | 149 | if let mailOn = model.mailOn { |
| @@ -153,16 +152,10 @@ struct AccountView: View { | |||
| 153 | await model.setMail($0) | 152 | await model.setMail($0) |
| 154 | } | 153 | } |
| 155 | .disabled(model.working) | 154 | .disabled(model.working) |
| 156 | if let watchOn = model.watchOn { | ||
| 157 | SettingToggle(title: "Watch writable repositories", serverValue: watchOn) { | ||
| 158 | await model.setWatch($0) | ||
| 159 | } | ||
| 160 | .disabled(model.working) | ||
| 161 | } | ||
| 162 | } header: { | 155 | } header: { |
| 163 | Text("Notifications") | 156 | Text("Notifications") |
| 164 | } footer: { | 157 | } footer: { |
| 165 | Text("Mail off keeps the inbox and stops the mail; login links are not activity and still arrive. Watching makes you a recipient of every issue and merge request on repositories you can write to.") | 158 | Text("Off keeps the inbox and stops the mail. Login links are not activity and still arrive.") |
| 166 | } | 159 | } |
| 167 | } | 160 | } |
| 168 | } | 161 | } |
gitbay/Views/Issues/LabelListView.swift +14 −8
| @@ -87,16 +87,22 @@ private struct LabelRow: View { | |||
| 87 | .font(.gbSans(.caption)) | 87 | .font(.gbSans(.caption)) |
| 88 | .foregroundStyle(.secondary) | 88 | .foregroundStyle(.secondary) |
| 89 | Spacer() | 89 | Spacer() |
| 90 | Menu { | 90 | // An org label is managed with `org label`; the repository's |
| 91 | ForEach(LabelPalette.palette, id: \.self) { hex in | 91 | // own commands refuse it, so it gets the mark and no menu. |
| 92 | Button(hex) { recolour(String(hex.dropFirst())) } | 92 | if label.org { |
| 93 | GBChip("org", .secondary) | ||
| 94 | } else { | ||
| 95 | Menu { | ||
| 96 | ForEach(LabelPalette.palette, id: \.self) { hex in | ||
| 97 | Button(hex) { recolour(String(hex.dropFirst())) } | ||
| 98 | } | ||
| 99 | Button("Clear colour") { recolour("") } | ||
| 100 | Button("Remove", role: .destructive) { remove() } | ||
| 101 | } label: { | ||
| 102 | Image(systemName: "ellipsis.circle") | ||
| 93 | } | 103 | } |
| 94 | Button("Clear colour") { recolour("") } | 104 | .disabled(working) |
| 95 | Button("Remove", role: .destructive) { remove() } | ||
| 96 | } label: { | ||
| 97 | Image(systemName: "ellipsis.circle") | ||
| 98 | } | 105 | } |
| 99 | .disabled(working) | ||
| 100 | } | 106 | } |
| 101 | } | 107 | } |
| 102 | } | 108 | } |
gitbay/Views/Issues/MilestoneListView.swift +9 −1
| @@ -30,7 +30,12 @@ struct MilestoneListView: View { | |||
| 30 | ForEach(model.state.value ?? []) { milestone in | 30 | ForEach(model.state.value ?? []) { milestone in |
| 31 | MilestoneRow(milestone: milestone) | 31 | MilestoneRow(milestone: milestone) |
| 32 | .swipeActions { | 32 | .swipeActions { |
| 33 | if milestone.state == "open" { | 33 | // An org milestone is closed and reopened with |
| 34 | // `org milestone`; the repository's commands | ||
| 35 | // refuse it. | ||
| 36 | if milestone.org { | ||
| 37 | EmptyView() | ||
| 38 | } else if milestone.state == "open" { | ||
| 34 | Button("Close") { | 39 | Button("Close") { |
| 35 | Task { await model.close(milestone.title) } | 40 | Task { await model.close(milestone.title) } |
| 36 | } | 41 | } |
| @@ -136,6 +141,9 @@ private struct MilestoneRow: View { | |||
| 136 | if milestone.state != "open" { | 141 | if milestone.state != "open" { |
| 137 | GBChip(milestone.state, .gbDone) | 142 | GBChip(milestone.state, .gbDone) |
| 138 | } | 143 | } |
| 144 | if milestone.org { | ||
| 145 | GBChip("org", .secondary) | ||
| 146 | } | ||
| 139 | Spacer() | 147 | Spacer() |
| 140 | if let due = milestone.due, !due.isEmpty { | 148 | if let due = milestone.due, !due.isEmpty { |
| 141 | Text(due) | 149 | Text(due) |
gitbayTests/AccountTests.swift +3 −27
| @@ -48,15 +48,13 @@ struct AccountViewModelTests { | |||
| 48 | /// v1.16.0: `notifications settings show` says whether activity mail | 48 | /// v1.16.0: `notifications settings show` says whether activity mail |
| 49 | /// is on; `notifications settings mail on|off` flips it. The read is | 49 | /// is on; `notifications settings mail on|off` flips it. The read is |
| 50 | /// separate from the keys, and an older server leaves it unknown. | 50 | /// separate from the keys, and an older server leaves it unknown. |
| 51 | /// A server before v1.19.0 answers without `watch`, which stays nil. | ||
| 52 | @Test func mailPreferenceLoadsAndFlips() async throws { | 51 | @Test func mailPreferenceLoadsAndFlips() async throws { |
| 53 | let (model, stub) = try await loadedModel() | 52 | let (model, stub) = try await loadedModel() |
| 54 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show")) | 53 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show")) |
| 55 | 54 | ||
| 56 | await model.loadNotificationPreferences() | 55 | await model.loadMailPreference() |
| 57 | 56 | ||
| 58 | #expect(model.mailOn == true) | 57 | #expect(model.mailOn == true) |
| 59 | #expect(model.watchOn == nil) | ||
| 60 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | 58 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 61 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show")) | 59 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show")) |
| 62 | 60 | ||
| @@ -68,35 +66,13 @@ struct AccountViewModelTests { | |||
| 68 | #expect(model.mailOn == false) | 66 | #expect(model.mailOn == false) |
| 69 | } | 67 | } |
| 70 | 68 | ||
| 71 | /// v1.19.0: `show` also reports `watch`, and `notifications settings | 69 | @Test func anOlderServerLeavesTheMailPreferenceUnknown() async throws { |
| 72 | /// watch on|off` flips it. | ||
| 73 | @Test func watchPreferenceLoadsAndFlips() async throws { | ||
| 74 | let (model, stub) = try await loadedModel() | ||
| 75 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}"#, match: "argv=settings&argv=show")) | ||
| 76 | |||
| 77 | await model.loadNotificationPreferences() | ||
| 78 | |||
| 79 | #expect(model.mailOn == true) | ||
| 80 | #expect(model.watchOn == false) | ||
| 81 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 82 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":true},"exit_code":0}"#, match: "argv=settings&argv=show")) | ||
| 83 | |||
| 84 | await model.setWatch(true) | ||
| 85 | |||
| 86 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 87 | let (argv, _) = try argvOf(write) | ||
| 88 | #expect(argv == ["notifications", "settings", "watch", "on"]) | ||
| 89 | #expect(model.watchOn == true) | ||
| 90 | } | ||
| 91 | |||
| 92 | @Test func anOlderServerLeavesTheNotificationPreferencesUnknown() async throws { | ||
| 93 | let (model, stub) = try await loadedModel() | 70 | let (model, stub) = try await loadedModel() |
| 94 | stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show")) | 71 | stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show")) |
| 95 | 72 | ||
| 96 | await model.loadNotificationPreferences() | 73 | await model.loadMailPreference() |
| 97 | 74 | ||
| 98 | #expect(model.mailOn == nil) | 75 | #expect(model.mailOn == nil) |
| 99 | #expect(model.watchOn == nil) | ||
| 100 | #expect(model.actionError == nil) | 76 | #expect(model.actionError == nil) |
| 101 | } | 77 | } |
| 102 | 78 | ||
gitbayTests/LabelTests.swift +9
| @@ -74,6 +74,15 @@ struct IssueLabelDecodingTests { | |||
| 74 | #expect(labels[1].issues == 11) | 74 | #expect(labels[1].issues == 11) |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | /// v1.19.0: a row the repository sees through its org carries | ||
| 78 | /// `org: true`; repository rows and older servers omit the key. | ||
| 79 | @Test func anOrgRowIsMarkedAndARepoRowIsNot() throws { | ||
| 80 | let json = Data(#"[{"name":"bug","org":true,"issues":4},{"name":"ci","issues":1}]"#.utf8) | ||
| 81 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) | ||
| 82 | #expect(labels[0].org) | ||
| 83 | #expect(!labels[1].org) | ||
| 84 | } | ||
| 85 | |||
| 77 | @Test func theIdIsTheName() throws { | 86 | @Test func theIdIsTheName() throws { |
| 78 | let json = Data(#"[{"name":"bug","issues":0}]"#.utf8) | 87 | let json = Data(#"[{"name":"bug","issues":0}]"#.utf8) |
| 79 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) | 88 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) |
gitbayTests/RefsMilestoneTests.swift +17
| @@ -99,6 +99,23 @@ struct MilestoneListViewModelTests { | |||
| 99 | "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open") | 99 | "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open") |
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | /// v1.19.0: a milestone the repository sees through its org carries | ||
| 103 | /// `org: true`; repository rows and older servers omit the key. | ||
| 104 | @Test func anOrgRowIsMarkedAndARepoRowIsNot() async throws { | ||
| 105 | let (client, stub) = try makeClient() | ||
| 106 | stub.enqueue(.init(status: 200, json: """ | ||
| 107 | {"protocol_version":1,"data":[\ | ||
| 108 | {"title":"v2","state":"open","org":true,"open":1,"closed":0},\ | ||
| 109 | {"title":"v1.0.0","state":"open","open":3,"closed":9}],"exit_code":0} | ||
| 110 | """)) | ||
| 111 | let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 112 | |||
| 113 | await model.load() | ||
| 114 | |||
| 115 | let milestones = try #require(model.state.value) | ||
| 116 | #expect(milestones.map(\.org) == [true, false]) | ||
| 117 | } | ||
| 118 | |||
| 102 | @Test func changingTheFilterReloadsWithThatState() async throws { | 119 | @Test func changingTheFilterReloadsWithThatState() async throws { |
| 103 | let (client, stub) = try makeClient() | 120 | let (client, stub) = try makeClient() |
| 104 | stub.enqueue(.init(status: 200, json: milestonesJSON)) | 121 | stub.enqueue(.init(status: 200, json: milestonesJSON)) |
gitbayUITests/LiveSmokeUITests.swift −17
| @@ -860,23 +860,6 @@ extension LiveSmokeUITests { | |||
| 860 | flipMail() | 860 | flipMail() |
| 861 | XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore") | 861 | XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore") |
| 862 | 862 | ||
| 863 | // The watch preference (v1.19.0) flips the same way. | ||
| 864 | let watch = app.switches["Watch writable repositories"].firstMatch | ||
| 865 | XCTAssertTrue(scrollTo(watch), "watch toggle not reachable") | ||
| 866 | let innerWatch = watch.switches.firstMatch | ||
| 867 | let flipWatch: () -> Void = { | ||
| 868 | if innerWatch.exists && innerWatch != watch { | ||
| 869 | innerWatch.tap() | ||
| 870 | } else { | ||
| 871 | watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap() | ||
| 872 | } | ||
| 873 | } | ||
| 874 | let watchWasOn = (watch.value as? String) == "1" | ||
| 875 | flipWatch() | ||
| 876 | XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist") | ||
| 877 | flipWatch() | ||
| 878 | XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore") | ||
| 879 | |||
| 880 | // A bogus verification code is refused, not swallowed. | 863 | // A bogus verification code is refused, not swallowed. |
| 881 | let code = app.descendants(matching: .any) | 864 | let code = app.descendants(matching: .any) |
| 882 | .matching(identifier: "email-code").firstMatch | 865 | .matching(identifier: "email-code").firstMatch |