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.
|
/// for completion or downloading the resulting file.
|
||||||
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation
|
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation
|
||||||
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar
|
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
|
func currentUser() async throws -> OutlineUser
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("stars.create", body: request)
|
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 {
|
public func currentUser() async throws -> OutlineUser {
|
||||||
try await post("users.info", body: EmptyParams())
|
try await post("users.info", body: EmptyParams())
|
||||||
}
|
}
|
||||||
@@ -233,6 +242,14 @@ private struct DuplicateDocumentResponse: Decodable {
|
|||||||
let documents: [OutlineDocument]
|
let documents: [OutlineDocument]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ListStarsResponse: Decodable {
|
||||||
|
let stars: [OutlineStar]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StarIDParams: Encodable {
|
||||||
|
let id: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct MoveDocumentResponse: Decodable {
|
private struct MoveDocumentResponse: Decodable {
|
||||||
let documents: [OutlineDocument]?
|
let documents: [OutlineDocument]?
|
||||||
let collections: [OutlineCollection]?
|
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")
|
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 {
|
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
let client = LiveOutlineAPIClient(
|
let client = LiveOutlineAPIClient(
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ struct CollectionDocumentsOutline: View {
|
|||||||
/// the ViewBridge/`nw_connection` console spam (and worse, silent failures).
|
/// the ViewBridge/`nw_connection` console spam (and worse, silent failures).
|
||||||
@MainActor
|
@MainActor
|
||||||
private struct DocumentNodeRow: View {
|
private struct DocumentNodeRow: View {
|
||||||
|
@Environment(StarStore.self) private var starStore
|
||||||
|
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
let node: DocumentNode
|
let node: DocumentNode
|
||||||
let depth: Int
|
let depth: Int
|
||||||
@@ -142,6 +144,12 @@ private struct DocumentNodeRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if starStore.isStarred(documentId: node.document.id) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
@@ -248,7 +256,7 @@ private struct DocumentNodeRow: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var contextMenuContent: some View {
|
private var contextMenuContent: some View {
|
||||||
Button("Star") {
|
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
|
||||||
Task { await star() }
|
Task { await star() }
|
||||||
}
|
}
|
||||||
// No `subscriptions.*` endpoint in the API — nothing to back this with.
|
// No `subscriptions.*` endpoint in the API — nothing to back this with.
|
||||||
@@ -337,9 +345,9 @@ private struct DocumentNodeRow: View {
|
|||||||
|
|
||||||
private func star() async {
|
private func star() async {
|
||||||
do {
|
do {
|
||||||
_ = try await apiClient.starDocument(StarDocumentRequest(documentId: node.document.id))
|
try await starStore.toggleDocument(node.document.id, apiClient: apiClient)
|
||||||
} catch {
|
} 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 SwiftUI
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct CollectionTreeRow: View {
|
struct CollectionTreeRow: View {
|
||||||
|
@Environment(StarStore.self) private var starStore
|
||||||
|
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
let collection: OutlineCollection
|
let collection: OutlineCollection
|
||||||
let isExpanded: Bool
|
let isExpanded: Bool
|
||||||
@@ -34,6 +37,12 @@ struct CollectionTreeRow: View {
|
|||||||
CollectionRowView(collection: collection)
|
CollectionRowView(collection: collection)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if starStore.isStarred(collectionId: collection.id) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
@@ -87,7 +96,7 @@ struct CollectionTreeRow: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var contextMenuContent: some View {
|
private var contextMenuContent: some View {
|
||||||
Button("Star") {
|
Button(starStore.isStarred(collectionId: collection.id) ? "Unstar" : "Star") {
|
||||||
Task { await star() }
|
Task { await star() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +142,9 @@ struct CollectionTreeRow: View {
|
|||||||
|
|
||||||
private func star() async {
|
private func star() async {
|
||||||
do {
|
do {
|
||||||
_ = try await apiClient.starCollection(StarCollectionRequest(collectionId: collection.id))
|
try await starStore.toggleCollection(collection.id, apiClient: apiClient)
|
||||||
} catch {
|
} 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
|
import OutlineKit
|
||||||
|
|
||||||
struct DocumentRowView: View {
|
struct DocumentRowView: View {
|
||||||
|
@Environment(StarStore.self) private var starStore
|
||||||
|
|
||||||
let document: OutlineDocument
|
let document: OutlineDocument
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -13,6 +15,12 @@ struct DocumentRowView: View {
|
|||||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if starStore.isStarred(documentId: document.id) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(document.updatedAt, format: .relative(presentation: .named))
|
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import OutlineKit
|
|||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@State private var welcomeName: String?
|
@State private var welcomeName: String?
|
||||||
|
@State private var starStore = StarStore()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -19,10 +20,18 @@ struct RootView: View {
|
|||||||
.zIndex(1)
|
.zIndex(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.environment(starStore)
|
||||||
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||||
.task {
|
.task {
|
||||||
await session.refreshTeamInfoIfNeeded()
|
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) {
|
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