Commit 26e787b104
Verified · cmc
Layout: unified · split
gitbay/Discovery/ProfileViewModel.swift +11 −2
| @@ -123,12 +123,21 @@ final class ProfileViewModel { | |||
| 123 | return actionError == nil | 123 | return actionError == nil |
| 124 | } | 124 | } |
| 125 | 125 | ||
| 126 | private func perform(_ argv: [String]) async { | 126 | /// `profile set` — always the caller's own profile. The about text |
| 127 | /// travels over stdin, never argv, since it is long-form and may | ||
| 128 | /// carry newlines. | ||
| 129 | func saveProfile(_ edit: ProfileEdit) async -> Bool { | ||
| 130 | guard edit.validationError == nil else { return false } | ||
| 131 | await perform(["profile", "set"] + edit.flags(), stdin: edit.aboutStdin) | ||
| 132 | return actionError == nil | ||
| 133 | } | ||
| 134 | |||
| 135 | private func perform(_ argv: [String], stdin: String? = nil) async { | ||
| 127 | working = true | 136 | working = true |
| 128 | actionError = nil | 137 | actionError = nil |
| 129 | defer { working = false } | 138 | defer { working = false } |
| 130 | do { | 139 | do { |
| 131 | try await client.run(argv) | 140 | try await client.run(argv, stdin: stdin) |
| 132 | await load() | 141 | await load() |
| 133 | } catch let error as GitbayError { | 142 | } catch let error as GitbayError { |
| 134 | actionError = error.userFacingMessage | 143 | actionError = error.userFacingMessage |
gitbay/Views/Discovery/ProfileEditSheet.swift added +127
| @@ -0,0 +1,127 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// `profile set` — always the caller's own. Description and website are | ||
| 4 | /// single lines; about is long-form prose in whichever format the | ||
| 5 | /// profile already carries, since switching formats reinterprets text | ||
| 6 | /// that was already written; links are capped at five, matching the | ||
| 7 | /// server. | ||
| 8 | struct ProfileEditSheet: View { | ||
| 9 | |||
| 10 | let model: ProfileViewModel | ||
| 11 | let profile: ProfileViewModel.Profile | ||
| 12 | |||
| 13 | @Environment(\.dismiss) private var dismiss | ||
| 14 | @State private var edit: ProfileEdit | ||
| 15 | |||
| 16 | init(model: ProfileViewModel, profile: ProfileViewModel.Profile) { | ||
| 17 | self.model = model | ||
| 18 | self.profile = profile | ||
| 19 | _edit = State(initialValue: ProfileEdit(from: profile)) | ||
| 20 | } | ||
| 21 | |||
| 22 | /// The server splits a link on the first `|`, so a label carrying one | ||
| 23 | /// would silently push part of itself into the URL. Caught here, | ||
| 24 | /// ahead of `ProfileEdit.validationError`, so the message is specific | ||
| 25 | /// rather than a generic refusal. | ||
| 26 | private var pipeError: String? { | ||
| 27 | edit.links.contains(where: { $0.label.contains("|") }) | ||
| 28 | ? "A link label can't contain \"|\" — that's what separates it from the URL." | ||
| 29 | : nil | ||
| 30 | } | ||
| 31 | |||
| 32 | private var localError: String? { pipeError ?? edit.validationError } | ||
| 33 | |||
| 34 | var body: some View { | ||
| 35 | NavigationStack { | ||
| 36 | Form { | ||
| 37 | Section("Description") { | ||
| 38 | TextField("Description", text: $edit.description) | ||
| 39 | .accessibilityIdentifier("profile-edit-description") | ||
| 40 | } | ||
| 41 | Section("Website") { | ||
| 42 | TextField("https://example.com", text: $edit.website) | ||
| 43 | .keyboardType(.URL) | ||
| 44 | .autocorrectionDisabled() | ||
| 45 | .textInputAutocapitalization(.never) | ||
| 46 | .accessibilityIdentifier("profile-edit-website") | ||
| 47 | } | ||
| 48 | Section { | ||
| 49 | Picker("Format", selection: $edit.aboutFormat) { | ||
| 50 | Text("Markdown").tag("md") | ||
| 51 | Text("Org").tag("org") | ||
| 52 | } | ||
| 53 | .pickerStyle(.segmented) | ||
| 54 | .accessibilityIdentifier("profile-edit-format") | ||
| 55 | TextEditor(text: $edit.about) | ||
| 56 | .font(.gbMono(.caption)) | ||
| 57 | .frame(minHeight: 160) | ||
| 58 | .autocorrectionDisabled() | ||
| 59 | .accessibilityIdentifier("profile-edit-about") | ||
| 60 | } header: { | ||
| 61 | Text("About") | ||
| 62 | } footer: { | ||
| 63 | Text("Changing the format reinterprets text that is already written.") | ||
| 64 | } | ||
| 65 | linksSection | ||
| 66 | if let error = localError ?? model.actionError { | ||
| 67 | Section { | ||
| 68 | GBNotice(error, .gbWarn) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | .navigationTitle("Edit Profile") | ||
| 73 | .navigationBarTitleDisplayMode(.inline) | ||
| 74 | .toolbar { | ||
| 75 | ToolbarItem(placement: .cancellationAction) { | ||
| 76 | Button("Cancel") { dismiss() } | ||
| 77 | } | ||
| 78 | ToolbarItem(placement: .confirmationAction) { | ||
| 79 | if model.working { | ||
| 80 | ProgressView() | ||
| 81 | } else { | ||
| 82 | Button("Save") { save() } | ||
| 83 | .disabled(localError != nil) | ||
| 84 | .accessibilityIdentifier("profile-edit-save") | ||
| 85 | } | ||
| 86 | } | ||
| 87 | } | ||
| 88 | .interactiveDismissDisabled(model.working) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | private var linksSection: some View { | ||
| 93 | Section { | ||
| 94 | ForEach(Array(edit.links.enumerated()), id: \.offset) { index, _ in | ||
| 95 | VStack(alignment: .leading, spacing: 4) { | ||
| 96 | TextField("Label", text: $edit.links[index].label) | ||
| 97 | .autocorrectionDisabled() | ||
| 98 | .accessibilityIdentifier("profile-edit-link-label-\(index)") | ||
| 99 | TextField("https://example.com", text: $edit.links[index].url) | ||
| 100 | .keyboardType(.URL) | ||
| 101 | .autocorrectionDisabled() | ||
| 102 | .textInputAutocapitalization(.never) | ||
| 103 | .accessibilityIdentifier("profile-edit-link-url-\(index)") | ||
| 104 | } | ||
| 105 | } | ||
| 106 | .onDelete { edit.links.remove(atOffsets: $0) } | ||
| 107 | if edit.links.count < 5 { | ||
| 108 | Button { | ||
| 109 | edit.links.append(.init(label: "", url: "")) | ||
| 110 | } label: { | ||
| 111 | Label("Add Link", systemImage: "plus.circle") | ||
| 112 | } | ||
| 113 | .accessibilityIdentifier("profile-edit-add-link") | ||
| 114 | } | ||
| 115 | } header: { | ||
| 116 | Text("Links") | ||
| 117 | } footer: { | ||
| 118 | Text("Up to 5 links.") | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | private func save() { | ||
| 123 | Task { | ||
| 124 | if await model.saveProfile(edit) { dismiss() } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | } | ||
gitbay/Views/Discovery/ProfileView.swift +15
| @@ -8,6 +8,7 @@ struct ProfileView: View { | |||
| 8 | @State private var model: ProfileViewModel | 8 | @State private var model: ProfileViewModel |
| 9 | @State private var creatingOrg = false | 9 | @State private var creatingOrg = false |
| 10 | @State private var newOrgName = "" | 10 | @State private var newOrgName = "" |
| 11 | @State private var editingProfile = false | ||
| 11 | 12 | ||
| 12 | /// Someone else's profile is titled with their name. Your own is | 13 | /// Someone else's profile is titled with their name. Your own is |
| 13 | /// reached from a tab and titled to match it. | 14 | /// reached from a tab and titled to match it. |
| @@ -46,6 +47,15 @@ struct ProfileView: View { | |||
| 46 | .toolbar { | 47 | .toolbar { |
| 47 | if let profile = model.state.value, | 48 | if let profile = model.state.value, |
| 48 | !profile.isOrg, session.current?.username == profile.name { | 49 | !profile.isOrg, session.current?.username == profile.name { |
| 50 | ToolbarItem(placement: .topBarTrailing) { | ||
| 51 | Button { | ||
| 52 | editingProfile = true | ||
| 53 | } label: { | ||
| 54 | Image(systemName: "pencil") | ||
| 55 | } | ||
| 56 | .disabled(model.working) | ||
| 57 | .accessibilityIdentifier("profile-edit-button") | ||
| 58 | } | ||
| 49 | ToolbarItem(placement: .topBarTrailing) { | 59 | ToolbarItem(placement: .topBarTrailing) { |
| 50 | Button { | 60 | Button { |
| 51 | newOrgName = "" | 61 | newOrgName = "" |
| @@ -60,6 +70,11 @@ struct ProfileView: View { | |||
| 60 | } | 70 | } |
| 61 | .task { await model.load() } | 71 | .task { await model.load() } |
| 62 | .refreshable { await model.load() } | 72 | .refreshable { await model.load() } |
| 73 | .sheet(isPresented: $editingProfile) { | ||
| 74 | if let profile = model.state.value { | ||
| 75 | ProfileEditSheet(model: model, profile: profile) | ||
| 76 | } | ||
| 77 | } | ||
| 63 | .alert("New Organization", isPresented: $creatingOrg) { | 78 | .alert("New Organization", isPresented: $creatingOrg) { |
| 64 | TextField("Name", text: $newOrgName) | 79 | TextField("Name", text: $newOrgName) |
| 65 | .autocorrectionDisabled() | 80 | .autocorrectionDisabled() |
gitbayTests/ProfileEditTests.swift +97
| @@ -85,3 +85,100 @@ struct ProfileEditFlagTests { | |||
| 85 | #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil) | 85 | #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil) |
| 86 | } | 86 | } |
| 87 | } | 87 | } |
| 88 | |||
| 89 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 90 | let box = StubProtocol.box() | ||
| 91 | let client = GitbayClient( | ||
| 92 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 93 | token: "test-token", | ||
| 94 | session: box.session() | ||
| 95 | ) | ||
| 96 | return (client, box) | ||
| 97 | } | ||
| 98 | |||
| 99 | private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { | ||
| 100 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 101 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | ||
| 102 | } | ||
| 103 | |||
| 104 | private let okJSON = #"{"protocol_version":1,"exit_code":0}"# | ||
| 105 | |||
| 106 | private let profileJSON = """ | ||
| 107 | {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ | ||
| 108 | "description":"hi","website":"https://cleberg.net","repos":[],\ | ||
| 109 | "activity_total":0},"exit_code":0} | ||
| 110 | """ | ||
| 111 | |||
| 112 | @MainActor | ||
| 113 | struct ProfileSaveTests { | ||
| 114 | |||
| 115 | /// The write, then the reload `profile set` triggers — one POST, one | ||
| 116 | /// GET. Both need a stub or the flow ends up looking like a refusal | ||
| 117 | /// even when the write itself succeeded. | ||
| 118 | @Test func savesTheFullArgvWithAboutOnlyInStdin() async throws { | ||
| 119 | let (client, stub) = try makeClient() | ||
| 120 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 121 | stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) | ||
| 122 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 123 | let edit = ProfileEdit( | ||
| 124 | description: "hi", website: "https://cleberg.net", | ||
| 125 | about: "long form\n\nprose", aboutFormat: "org", | ||
| 126 | links: [.init(label: "blog", url: "https://b.test")] | ||
| 127 | ) | ||
| 128 | |||
| 129 | let saved = await model.saveProfile(edit) | ||
| 130 | |||
| 131 | #expect(saved) | ||
| 132 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 133 | let (argv, stdin) = try argvOf(write) | ||
| 134 | #expect(argv == [ | ||
| 135 | "profile", "set", | ||
| 136 | "--description", "hi", "--website", "https://cleberg.net", | ||
| 137 | "--link", "blog|https://b.test", | ||
| 138 | "--about-format", "org", "--file", "-", | ||
| 139 | ]) | ||
| 140 | #expect(stdin == "long form\n\nprose") | ||
| 141 | #expect(argv.contains { $0.contains("long form") } == false) | ||
| 142 | } | ||
| 143 | |||
| 144 | /// The screen shows what the server stored, not what was typed — the | ||
| 145 | /// reload's payload, not the edit, is what the caller sees next. | ||
| 146 | @Test func aSuccessfulSaveReloadsFromTheServer() async throws { | ||
| 147 | let (client, stub) = try makeClient() | ||
| 148 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 149 | stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) | ||
| 150 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 151 | |||
| 152 | let saved = await model.saveProfile(ProfileEdit(description: "hi", website: "https://cleberg.net")) | ||
| 153 | |||
| 154 | #expect(saved) | ||
| 155 | let profile = try #require(model.state.value) | ||
| 156 | #expect(profile.description == "hi") | ||
| 157 | } | ||
| 158 | |||
| 159 | @Test func aRefusedSaveReturnsFalseAndSurfaces() async throws { | ||
| 160 | let (client, stub) = try makeClient() | ||
| 161 | stub.enqueue(.init(status: 200, json: """ | ||
| 162 | {"protocol_version":1,"error":"setting a profile needs write access","exit_code":4} | ||
| 163 | """)) | ||
| 164 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 165 | |||
| 166 | let saved = await model.saveProfile(ProfileEdit(description: "hi")) | ||
| 167 | |||
| 168 | #expect(saved == false) | ||
| 169 | #expect(model.actionError?.isEmpty == false) | ||
| 170 | } | ||
| 171 | |||
| 172 | /// Six links fail `ProfileEdit.validationError`; the save must refuse | ||
| 173 | /// before any request goes out, not after the server rejects it. | ||
| 174 | @Test func anInvalidEditSendsNothingAtAll() async throws { | ||
| 175 | let (client, stub) = try makeClient() | ||
| 176 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 177 | let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | ||
| 178 | |||
| 179 | let saved = await model.saveProfile(ProfileEdit(links: six)) | ||
| 180 | |||
| 181 | #expect(saved == false) | ||
| 182 | #expect(stub.seen.isEmpty) | ||
| 183 | } | ||
| 184 | } | ||