Commit 26e787b104
Verified · cmc
Layout: unified · split
gitbay/Discovery/ProfileViewModel.swift +11 −2
| @@ -123,12 +123,21 @@ final class ProfileViewModel { | ||
| 123 | 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 | 136 | working = true |
| 128 | 137 | actionError = nil |
| 129 | 138 | defer { working = false } |
| 130 | 139 | do { |
| 131 | try await client.run(argv) | |
| 140 | try await client.run(argv, stdin: stdin) | |
| 132 | 141 | await load() |
| 133 | 142 | } catch let error as GitbayError { |
| 134 | 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 | 8 | @State private var model: ProfileViewModel |
| 9 | 9 | @State private var creatingOrg = false |
| 10 | 10 | @State private var newOrgName = "" |
| 11 | @State private var editingProfile = false | |
| 11 | 12 | |
| 12 | 13 | /// Someone else's profile is titled with their name. Your own is |
| 13 | 14 | /// reached from a tab and titled to match it. |
| @@ -46,6 +47,15 @@ struct ProfileView: View { | ||
| 46 | 47 | .toolbar { |
| 47 | 48 | if let profile = model.state.value, |
| 48 | 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 | 59 | ToolbarItem(placement: .topBarTrailing) { |
| 50 | 60 | Button { |
| 51 | 61 | newOrgName = "" |
| @@ -60,6 +70,11 @@ struct ProfileView: View { | ||
| 60 | 70 | } |
| 61 | 71 | .task { await model.load() } |
| 62 | 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 | 78 | .alert("New Organization", isPresented: $creatingOrg) { |
| 64 | 79 | TextField("Name", text: $newOrgName) |
| 65 | 80 | .autocorrectionDisabled() |
gitbayTests/ProfileEditTests.swift +97
| @@ -85,3 +85,100 @@ struct ProfileEditFlagTests { | ||
| 85 | 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 | } | |