feat(collections): reflect starred state for documents and collections
Star/Unstar fired the API call but nothing showed it took effect — Document/Collection have no isStarred field, stars.list is the only source of truth, so this adds a shared StarStore (one stars.list fetch, optimistic toggle/rollback on star/unstar) injected at the app root and read by the sidebar tree rows, collection header row, and DocumentRowView (document lists, search results, reader sub-documents). Context menu labels now read "Unstar" once starred, and starred rows show a filled star. OutlineKit: stars.list / stars.delete, with test coverage.
This commit is contained in:
@@ -37,6 +37,9 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// for completion or downloading the resulting file.
|
||||
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation
|
||||
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar
|
||||
/// Every star across both documents and collections for the current user. Backed by `stars.list`.
|
||||
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar]
|
||||
func deleteStar(id: String) async throws
|
||||
|
||||
func currentUser() async throws -> OutlineUser
|
||||
}
|
||||
|
||||
@@ -133,6 +133,15 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("stars.create", body: request)
|
||||
}
|
||||
|
||||
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
|
||||
let result: ListStarsResponse = try await post("stars.list", body: request)
|
||||
return result.stars
|
||||
}
|
||||
|
||||
public func deleteStar(id: String) async throws {
|
||||
try await postForSuccess("stars.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func currentUser() async throws -> OutlineUser {
|
||||
try await post("users.info", body: EmptyParams())
|
||||
}
|
||||
@@ -233,6 +242,14 @@ private struct DuplicateDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]
|
||||
}
|
||||
|
||||
private struct ListStarsResponse: Decodable {
|
||||
let stars: [OutlineStar]
|
||||
}
|
||||
|
||||
private struct StarIDParams: Encodable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private struct MoveDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]?
|
||||
let collections: [OutlineCollection]?
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct ListStarsRequest: Encodable, Sendable {
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
|
||||
public init(offset: Int = 0, limit: Int = 100) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
@@ -539,6 +539,51 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.insights")
|
||||
}
|
||||
|
||||
func testListStarsDecodesStarsArray() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"stars": [
|
||||
{ "id": "star-1", "collectionId": "col-1", "documentId": null },
|
||||
{ "id": "star-2", "collectionId": null, "documentId": "doc-1" }
|
||||
],
|
||||
"documents": []
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let stars = try await client.listStars(ListStarsRequest())
|
||||
|
||||
XCTAssertEqual(stars.count, 2)
|
||||
XCTAssertEqual(stars.first?.collectionId, "col-1")
|
||||
XCTAssertEqual(stars.last?.documentId, "doc-1")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.list")
|
||||
}
|
||||
|
||||
func testDeleteStarSucceedsOnSuccessTrue() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteStar(id: "star-1")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.delete")
|
||||
}
|
||||
|
||||
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
let client = LiveOutlineAPIClient(
|
||||
|
||||
@@ -73,6 +73,8 @@ struct CollectionDocumentsOutline: View {
|
||||
/// the ViewBridge/`nw_connection` console spam (and worse, silent failures).
|
||||
@MainActor
|
||||
private struct DocumentNodeRow: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let node: DocumentNode
|
||||
let depth: Int
|
||||
@@ -142,6 +144,12 @@ private struct DocumentNodeRow: View {
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if starStore.isStarred(documentId: node.document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@@ -248,7 +256,7 @@ private struct DocumentNodeRow: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var contextMenuContent: some View {
|
||||
Button("Star") {
|
||||
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
|
||||
Task { await star() }
|
||||
}
|
||||
// No `subscriptions.*` endpoint in the API — nothing to back this with.
|
||||
@@ -337,9 +345,9 @@ private struct DocumentNodeRow: View {
|
||||
|
||||
private func star() async {
|
||||
do {
|
||||
_ = try await apiClient.starDocument(StarDocumentRequest(documentId: node.document.id))
|
||||
try await starStore.toggleDocument(node.document.id, apiClient: apiClient)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this document.")
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
@MainActor
|
||||
struct CollectionTreeRow: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let collection: OutlineCollection
|
||||
let isExpanded: Bool
|
||||
@@ -34,6 +37,12 @@ struct CollectionTreeRow: View {
|
||||
CollectionRowView(collection: collection)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if starStore.isStarred(collectionId: collection.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
@@ -87,7 +96,7 @@ struct CollectionTreeRow: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var contextMenuContent: some View {
|
||||
Button("Star") {
|
||||
Button(starStore.isStarred(collectionId: collection.id) ? "Unstar" : "Star") {
|
||||
Task { await star() }
|
||||
}
|
||||
|
||||
@@ -133,9 +142,9 @@ struct CollectionTreeRow: View {
|
||||
|
||||
private func star() async {
|
||||
do {
|
||||
_ = try await apiClient.starCollection(StarCollectionRequest(collectionId: collection.id))
|
||||
try await starStore.toggleCollection(collection.id, apiClient: apiClient)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this collection.")
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this collection.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct DocumentRowView: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
|
||||
let document: OutlineDocument
|
||||
|
||||
var body: some View {
|
||||
@@ -13,6 +15,12 @@ struct DocumentRowView: View {
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
|
||||
@@ -4,6 +4,7 @@ import OutlineKit
|
||||
struct RootView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@State private var welcomeName: String?
|
||||
@State private var starStore = StarStore()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -19,10 +20,18 @@ struct RootView: View {
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.environment(starStore)
|
||||
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||
.task {
|
||||
await session.refreshTeamInfoIfNeeded()
|
||||
}
|
||||
.task(id: session.isSignedIn) {
|
||||
if session.isSignedIn, let apiClient = session.apiClient {
|
||||
await starStore.load(apiClient: apiClient)
|
||||
} else {
|
||||
starStore.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OutlineKit
|
||||
|
||||
/// Shared starred-state cache, one `stars.list` fetch shared across every
|
||||
/// sidebar row and reader instead of each row independently guessing at its
|
||||
/// own starred state (the API has no `isStarred` field on `Document`/
|
||||
/// `Collection` themselves — the only source of truth is the stars list).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StarStore {
|
||||
private var documentStars: [String: OutlineStar] = [:]
|
||||
private var collectionStars: [String: OutlineStar] = [:]
|
||||
private(set) var isLoaded = false
|
||||
|
||||
func isStarred(documentId: String) -> Bool {
|
||||
documentStars[documentId] != nil
|
||||
}
|
||||
|
||||
func isStarred(collectionId: String) -> Bool {
|
||||
collectionStars[collectionId] != nil
|
||||
}
|
||||
|
||||
func load(apiClient: OutlineAPIClient) async {
|
||||
guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return }
|
||||
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
|
||||
star.documentId.map { ($0, star) }
|
||||
})
|
||||
collectionStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
|
||||
star.collectionId.map { ($0, star) }
|
||||
})
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
func reset() {
|
||||
documentStars = [:]
|
||||
collectionStars = [:]
|
||||
isLoaded = false
|
||||
}
|
||||
|
||||
/// Optimistic — the row shows the new state immediately rather than
|
||||
/// waiting on a re-fetch, then rolls back if the request actually fails.
|
||||
func toggleDocument(_ documentId: String, apiClient: OutlineAPIClient) async throws {
|
||||
if let star = documentStars[documentId] {
|
||||
documentStars.removeValue(forKey: documentId)
|
||||
do {
|
||||
try await apiClient.deleteStar(id: star.id)
|
||||
} catch {
|
||||
documentStars[documentId] = star
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
let star = try await apiClient.starDocument(StarDocumentRequest(documentId: documentId))
|
||||
documentStars[documentId] = star
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleCollection(_ collectionId: String, apiClient: OutlineAPIClient) async throws {
|
||||
if let star = collectionStars[collectionId] {
|
||||
collectionStars.removeValue(forKey: collectionId)
|
||||
do {
|
||||
try await apiClient.deleteStar(id: star.id)
|
||||
} catch {
|
||||
collectionStars[collectionId] = star
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
let star = try await apiClient.starCollection(StarCollectionRequest(collectionId: collectionId))
|
||||
collectionStars[collectionId] = star
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user