fix(pins): decode pins.list correctly, distinguish Pin to Home vs Collection
Same root-cause fix as feature/home-page, applied to this branch's
copy of the pin code (which additionally has the sidebar's real
per-collection Pin wiring):
- pins.list's real response is {data: {pins: [...], documents: [...]}},
not a bare array — confirmed against a live server. Decoding straight
to [OutlinePin] threw every call; try? swallowed it, so pins never
showed up (even a doc pinned for real via the web app).
- "Pin to Home" (web's actual label) sends collectionId: null; "Pin to
Collection" sends a real id — distinct actions. The reader toolbar's
Pin was sending the doc's own collectionId under a plain "Pin" label,
silently doing the wrong one. Fixed to nil, relabeled "Pin to Home".
- Sidebar's per-document Pin was already correctly scoped to
collection.id — relabeled "Pin to Collection" for clarity, no logic
change.
This commit is contained in:
@@ -129,7 +129,12 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||||
try await post("pins.list", body: request)
|
// `data` here is `{ pins: [...], documents: [...] }`, not a bare
|
||||||
|
// array — confirmed against a live server. Decoding straight to
|
||||||
|
// `[OutlinePin]` throws on every call, which `try?` at call sites
|
||||||
|
// swallows silently, so pins never showed up anywhere.
|
||||||
|
let payload: PinsListPayload = try await post("pins.list", body: request)
|
||||||
|
return payload.pins
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deletePin(id: String) async throws {
|
public func deletePin(id: String) async throws {
|
||||||
@@ -286,6 +291,11 @@ private struct DuplicateDocumentResponse: Decodable {
|
|||||||
let documents: [OutlineDocument]
|
let documents: [OutlineDocument]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PinsListPayload: Decodable {
|
||||||
|
let pins: [OutlinePin]
|
||||||
|
let documents: [OutlineDocument]
|
||||||
|
}
|
||||||
|
|
||||||
private struct ListStarsResponse: Decodable {
|
private struct ListStarsResponse: Decodable {
|
||||||
let stars: [OutlineStar]
|
let stars: [OutlineStar]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A document pinned to the top of a collection (or the team home), backed by
|
/// A document pinned to the top of a collection, or to team Home when
|
||||||
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
|
/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI
|
||||||
/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's
|
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
|
||||||
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
|
/// confirmed real against a live server's network traffic. `collectionId: nil`
|
||||||
/// from general knowledge of Outline's API, not verified against this spec;
|
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
|
||||||
/// treat field names as best-effort until confirmed against a live server.
|
/// Collection", a distinct action.
|
||||||
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlinePin` — best-effort shape, not in the vendored spec.
|
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
||||||
|
/// Collection" — these are distinct actions on the real server.
|
||||||
public struct CreatePinRequest: Encodable, Sendable {
|
public struct CreatePinRequest: Encodable, Sendable {
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlinePin` — best-effort shape, not in the vendored spec.
|
/// See `OutlinePin`. `collectionId: nil` lists Home pins only.
|
||||||
public struct ListPinsRequest: Encodable, Sendable {
|
public struct ListPinsRequest: Encodable, Sendable {
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
|
|
||||||
|
|||||||
@@ -676,6 +676,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testListPinsDecodesNestedPinsArray() async throws {
|
||||||
|
// Real shape confirmed against a live server: `data` is
|
||||||
|
// `{ pins: [...], documents: [...] }`, not a bare array.
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"pagination": { "limit": 25, "offset": 0 },
|
||||||
|
"data": {
|
||||||
|
"pins": [
|
||||||
|
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
|
||||||
|
],
|
||||||
|
"documents": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
|
||||||
|
|
||||||
|
XCTAssertEqual(pins.first?.id, "pin-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
|
||||||
|
}
|
||||||
|
|
||||||
func testCreateSubscriptionDecodesSubscription() async throws {
|
func testCreateSubscriptionDecodesSubscription() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
@@ -330,7 +330,9 @@ private struct DocumentNodeRow: View {
|
|||||||
Button("New Document") {
|
Button("New Document") {
|
||||||
Task { await createChildDocument() }
|
Task { await createChildDocument() }
|
||||||
}
|
}
|
||||||
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin" : "Pin") {
|
// Scoped to this collection (`collection.id`) — "Pin to Collection",
|
||||||
|
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
|
||||||
|
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
|
||||||
Task { await togglePin() }
|
Task { await togglePin() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ struct DocumentReaderView: View {
|
|||||||
Button("New Document") {
|
Button("New Document") {
|
||||||
Task { await createChildDocument() }
|
Task { await createChildDocument() }
|
||||||
}
|
}
|
||||||
Button(viewModel.isPinned ? "Unpin" : "Pin") {
|
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
|
||||||
Task { await togglePin() }
|
Task { await togglePin() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadPinAndSubscriptionState() async {
|
func loadPinAndSubscriptionState() async {
|
||||||
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)),
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
||||||
|
// Home", not "Pin to Collection" — those are distinct on the server.
|
||||||
|
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
|
||||||
let match = pins.first(where: { $0.documentId == documentId }) {
|
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||||
isPinned = true
|
isPinned = true
|
||||||
pinId = match.id
|
pinId = match.id
|
||||||
@@ -125,7 +127,7 @@ final class DocumentReaderViewModel {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId))
|
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
|
||||||
pinId = pin.id
|
pinId = pin.id
|
||||||
isPinned = true
|
isPinned = true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user