Preview markup before sending !128
9 files changed, +146 −0
Layout: unified · split
gitbay/Views/Issues/IssueView.swift +6
| @@ -172,6 +172,7 @@ struct IssueView: View { | |||
| 172 | HStack { | 172 | HStack { |
| 173 | TextField("Comment", text: $commentText, axis: .vertical) | 173 | TextField("Comment", text: $commentText, axis: .vertical) |
| 174 | .lineLimit(1...5) | 174 | .lineLimit(1...5) |
| 175 | .accessibilityIdentifier("issue-comment-field") | ||
| 175 | Picker("Format", selection: $commentFormat) { | 176 | Picker("Format", selection: $commentFormat) { |
| 176 | Text("Markdown").tag("md") | 177 | Text("Markdown").tag("md") |
| 177 | Text("Org").tag("org") | 178 | Text("Org").tag("org") |
| @@ -190,6 +191,11 @@ struct IssueView: View { | |||
| 190 | .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | 191 | .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| 191 | || model.working) | 192 | || model.working) |
| 192 | } | 193 | } |
| 194 | if !commentText.isEmpty { | ||
| 195 | MarkupPreview(text: commentText, format: commentFormat, | ||
| 196 | identifier: "issue-comment-preview") | ||
| 197 | .font(.gbSans(.subheadline)) | ||
| 198 | } | ||
| 193 | } | 199 | } |
| 194 | } | 200 | } |
| 195 | 201 | ||
gitbay/Views/MRs/MRListView.swift +1
| @@ -131,6 +131,7 @@ private struct MRCreateSheet: View { | |||
| 131 | } | 131 | } |
| 132 | .pickerStyle(.segmented) | 132 | .pickerStyle(.segmented) |
| 133 | .accessibilityIdentifier("mr-format") | 133 | .accessibilityIdentifier("mr-format") |
| 134 | MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview") | ||
| 134 | } | 135 | } |
| 135 | Section { | 136 | Section { |
| 136 | Toggle("Draft", isOn: Bindable(model).draft) | 137 | Toggle("Draft", isOn: Bindable(model).draft) |
gitbay/Views/MRs/MRView.swift +6
| @@ -537,6 +537,7 @@ struct MRView: View { | |||
| 537 | HStack { | 537 | HStack { |
| 538 | TextField("Comment", text: $commentText, axis: .vertical) | 538 | TextField("Comment", text: $commentText, axis: .vertical) |
| 539 | .lineLimit(1...5) | 539 | .lineLimit(1...5) |
| 540 | .accessibilityIdentifier("mr-comment-field") | ||
| 540 | Picker("Format", selection: $commentFormat) { | 541 | Picker("Format", selection: $commentFormat) { |
| 541 | Text("Markdown").tag("md") | 542 | Text("Markdown").tag("md") |
| 542 | Text("Org").tag("org") | 543 | Text("Org").tag("org") |
| @@ -555,6 +556,11 @@ struct MRView: View { | |||
| 555 | .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | 556 | .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| 556 | || model.working) | 557 | || model.working) |
| 557 | } | 558 | } |
| 559 | if !commentText.isEmpty { | ||
| 560 | MarkupPreview(text: commentText, format: commentFormat, | ||
| 561 | identifier: "mr-comment-preview") | ||
| 562 | .font(.gbSans(.subheadline)) | ||
| 563 | } | ||
| 558 | } | 564 | } |
| 559 | } | 565 | } |
| 560 | 566 | ||
gitbay/Views/Releases/ReleaseListView.swift +3
| @@ -108,6 +108,9 @@ private struct ReleaseCreateSheet: View { | |||
| 108 | TextEditor(text: $notes) | 108 | TextEditor(text: $notes) |
| 109 | .frame(minHeight: 140) | 109 | .frame(minHeight: 140) |
| 110 | .autocorrectionDisabled() | 110 | .autocorrectionDisabled() |
| 111 | // release create sends no --format, and release notes | ||
| 112 | // render as markdown. | ||
| 113 | MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview") | ||
| 111 | } | 114 | } |
| 112 | if let error = model.createError { | 115 | if let error = model.createError { |
| 113 | Section { | 116 | Section { |
gitbay/Views/Repos/FileEditSheet.swift +5
| @@ -28,6 +28,11 @@ struct FileEditSheet: View { | |||
| 28 | } footer: { | 28 | } footer: { |
| 29 | Text("Commits to \(branch) as an unsigned commit.") | 29 | Text("Commits to \(branch) as an unsigned commit.") |
| 30 | } | 30 | } |
| 31 | if let format = MarkupPreview.format(forFile: model.fileName) { | ||
| 32 | Section { | ||
| 33 | MarkupPreview(text: content, format: format, identifier: "file-edit-preview") | ||
| 34 | } | ||
| 35 | } | ||
| 31 | Section("Message") { | 36 | Section("Message") { |
| 32 | TextField("edit \(model.fileName)", text: $message) | 37 | TextField("edit \(model.fileName)", text: $message) |
| 33 | .autocorrectionDisabled() | 38 | .autocorrectionDisabled() |
gitbay/Views/Shared/ComposeSheet.swift +1
| @@ -73,6 +73,7 @@ struct ComposeSheet: View { | |||
| 73 | .pickerStyle(.segmented) | 73 | .pickerStyle(.segmented) |
| 74 | .accessibilityIdentifier("compose-format") | 74 | .accessibilityIdentifier("compose-format") |
| 75 | } | 75 | } |
| 76 | MarkupPreview(text: bodyText, format: format, identifier: "compose-preview") | ||
| 76 | } | 77 | } |
| 77 | if let errorMessage { | 78 | if let errorMessage { |
| 78 | Section { | 79 | Section { |
gitbay/Views/Shared/MarkupPreview.swift added +42
| @@ -0,0 +1,42 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// A collapsed "Preview" row under a markup editor. Expanded, it renders | ||
| 4 | /// the draft with `FormattedBodyView`, the view the app's threads use, so | ||
| 5 | /// what it shows is what the app will show once the text is sent. It sits | ||
| 6 | /// under the editor rather than replacing it, so the draft stays in view. | ||
| 7 | struct MarkupPreview: View { | ||
| 8 | |||
| 9 | let text: String | ||
| 10 | let format: String? | ||
| 11 | let identifier: String | ||
| 12 | |||
| 13 | @State private var expanded = false | ||
| 14 | |||
| 15 | var body: some View { | ||
| 16 | DisclosureGroup(isExpanded: $expanded) { | ||
| 17 | if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | ||
| 18 | Text("Nothing to preview.") | ||
| 19 | .font(.gbSans(.subheadline)) | ||
| 20 | .foregroundStyle(.secondary) | ||
| 21 | } else { | ||
| 22 | FormattedBodyView(text: text, format: format) | ||
| 23 | .padding(.vertical, 4) | ||
| 24 | } | ||
| 25 | } label: { | ||
| 26 | Text("Preview") | ||
| 27 | .font(.gbSans(.subheadline)) | ||
| 28 | } | ||
| 29 | .accessibilityIdentifier(identifier) | ||
| 30 | } | ||
| 31 | |||
| 32 | /// The format a file's contents render in, or nil for a file shown as | ||
| 33 | /// source. The same extensions the forge renders a README or wiki | ||
| 34 | /// page from. | ||
| 35 | static func format(forFile name: String) -> String? { | ||
| 36 | switch (name as NSString).pathExtension.lowercased() { | ||
| 37 | case "org": "org" | ||
| 38 | case "md", "markdown": "md" | ||
| 39 | default: nil | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
gitbayTests/MarkupPreviewTests.swift added +23
| @@ -0,0 +1,23 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import gitbay | ||
| 3 | |||
| 4 | struct MarkupPreviewFormatTests { | ||
| 5 | |||
| 6 | @Test func orgFilesRenderAsOrg() { | ||
| 7 | #expect(MarkupPreview.format(forFile: "README.org") == "org") | ||
| 8 | #expect(MarkupPreview.format(forFile: "profile/README.org") == "org") | ||
| 9 | } | ||
| 10 | |||
| 11 | @Test func markdownFilesRenderAsMarkdown() { | ||
| 12 | #expect(MarkupPreview.format(forFile: "README.md") == "md") | ||
| 13 | #expect(MarkupPreview.format(forFile: "notes.markdown") == "md") | ||
| 14 | #expect(MarkupPreview.format(forFile: "CHANGES.MD") == "md") | ||
| 15 | } | ||
| 16 | |||
| 17 | /// Source files show as source, so there is nothing to preview. | ||
| 18 | @Test func otherFilesHaveNoPreview() { | ||
| 19 | #expect(MarkupPreview.format(forFile: "main.swift") == nil) | ||
| 20 | #expect(MarkupPreview.format(forFile: "Makefile") == nil) | ||
| 21 | #expect(MarkupPreview.format(forFile: "notes.txt") == nil) | ||
| 22 | } | ||
| 23 | } | ||
gitbayUITests/LiveSmokeUITests.swift +59
| @@ -93,6 +93,10 @@ final class LiveSmokeUITests: XCTestCase { | |||
| 93 | try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle), | 93 | try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle), |
| 94 | "could not open both label fixture merge requests on \(Self.fixtureRepo)") | 94 | "could not open both label fixture merge requests on \(Self.fixtureRepo)") |
| 95 | } | 95 | } |
| 96 | if name.contains("testMarkupPreviewFlow") { | ||
| 97 | try requireFixtureRepo() | ||
| 98 | ensureFixtureIssue() | ||
| 99 | } | ||
| 96 | } | 100 | } |
| 97 | 101 | ||
| 98 | override func tearDownWithError() throws { | 102 | override func tearDownWithError() throws { |
| @@ -1910,4 +1914,59 @@ extension LiveSmokeUITests { | |||
| 1910 | remove.tap() | 1914 | remove.tap() |
| 1911 | XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") | 1915 | XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") |
| 1912 | } | 1916 | } |
| 1917 | |||
| 1918 | /// A comment's preview renders its markup before anything is sent: | ||
| 1919 | /// the bold markers are gone and the word remains. Nothing is posted. | ||
| 1920 | func testMarkupPreviewFlow() throws { | ||
| 1921 | openRepo(Self.fixtureRepo) | ||
| 1922 | app.staticTexts["Issues"].firstMatch.tap() | ||
| 1923 | let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch | ||
| 1924 | XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed") | ||
| 1925 | issue.tap() | ||
| 1926 | |||
| 1927 | let field = app.descendants(matching: .any) | ||
| 1928 | .matching(identifier: "issue-comment-field").firstMatch | ||
| 1929 | XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue") | ||
| 1930 | let draft = "**Bold preview**" | ||
| 1931 | focusAndType(field, draft) | ||
| 1932 | |||
| 1933 | let preview = app.descendants(matching: .any) | ||
| 1934 | .matching(identifier: "issue-comment-preview").firstMatch | ||
| 1935 | XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment") | ||
| 1936 | // The keyboard can cover the row right after typing. | ||
| 1937 | for _ in 0..<3 where !preview.isHittable { app.swipeUp() } | ||
| 1938 | preview.tap() | ||
| 1939 | let rendered = app.staticTexts | ||
| 1940 | .containing(NSPredicate(format: "label CONTAINS 'Bold preview'")).firstMatch | ||
| 1941 | if !rendered.waitForExistence(timeout: 3) { | ||
| 1942 | // The row's identifier can land on the cell rather than the toggle. | ||
| 1943 | app.buttons["Preview"].firstMatch.tap() | ||
| 1944 | } | ||
| 1945 | XCTAssertTrue(rendered.waitForExistence(timeout: 5), "preview did not render the draft") | ||
| 1946 | XCTAssertFalse(app.staticTexts | ||
| 1947 | .containing(NSPredicate(format: "label CONTAINS '**Bold'")).firstMatch.exists, | ||
| 1948 | "preview shows the raw markup") | ||
| 1949 | |||
| 1950 | // Kept on success too: this is how the preview's look is checked | ||
| 1951 | // in light and dark without signing a simulator in by hand. | ||
| 1952 | // The rendered text sits under the keyboard, below the comment | ||
| 1953 | // field. A drag on the list moves the row above the keyboard for | ||
| 1954 | // the screenshot; the wait lets the scroll settle. | ||
| 1955 | if app.keyboards.firstMatch.exists { | ||
| 1956 | app.swipeDown() | ||
| 1957 | _ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3) | ||
| 1958 | } | ||
| 1959 | XCTAssertTrue(scrollTo(rendered), "rendered preview scrolled away") | ||
| 1960 | let shot = XCTAttachment(screenshot: app.screenshot()) | ||
| 1961 | shot.name = "markup preview" | ||
| 1962 | shot.lifetime = .keepAlways | ||
| 1963 | add(shot) | ||
| 1964 | |||
| 1965 | // Leave the fixture as found: clear the draft, send nothing. | ||
| 1966 | XCTAssertTrue(scrollTo(field), "comment field scrolled away") | ||
| 1967 | field.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.9)).tap() | ||
| 1968 | let typed = (field.value as? String)?.count ?? draft.count | ||
| 1969 | field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: typed)) | ||
| 1970 | XCTAssertTrue(preview.waitForNonExistence(timeout: 5), "preview row outlived an empty comment") | ||
| 1971 | } | ||
| 1913 | } | 1972 | } |