mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
feat: show only user related content #11
This commit is contained in:
parent
57fd862149
commit
781826004b
2 changed files with 118 additions and 9 deletions
|
|
@ -9,7 +9,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
@State private var searchText = ""
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
@State private var showCreateFlow = false
|
||||
@State private var involvementScope: InvolvementScope = .created
|
||||
@State private var involvementScope: InvolvementScope = .involved
|
||||
|
||||
private let issueService: IssueService?
|
||||
private let issueType: String
|
||||
|
|
@ -70,7 +70,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
}
|
||||
|
||||
private var hasNonDefaultFilters: Bool {
|
||||
stateFilter != .open || involvementScope != .created
|
||||
stateFilter != .open || involvementScope != .involved
|
||||
}
|
||||
|
||||
private var filterSummaryText: String {
|
||||
|
|
@ -88,7 +88,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
|
||||
private func scopeDisplayLabel(_ scope: InvolvementScope) -> String {
|
||||
switch scope {
|
||||
case .involved: "All"
|
||||
case .involved: "All involvement"
|
||||
case .created: "Created by you"
|
||||
case .assigned: "Assigned to you"
|
||||
case .mentioned: "Mentioned"
|
||||
|
|
@ -242,12 +242,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
private func searchIssues(service: IssueService, page: Int, limit: Int) async throws -> [Issue] {
|
||||
switch involvementScope {
|
||||
case .involved:
|
||||
// No involvement flags — the API uses AND logic when multiple are set,
|
||||
// so we omit all flags to show everything visible to the user.
|
||||
try await service.searchIssues(
|
||||
type: issueType, state: stateFilter.rawValue, query: searchText,
|
||||
page: page, limit: limit,
|
||||
)
|
||||
try await searchInvolved(service: service, page: page, limit: limit)
|
||||
case .created:
|
||||
try await service.searchIssues(
|
||||
type: issueType, state: stateFilter.rawValue, query: searchText,
|
||||
|
|
@ -271,6 +266,60 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fires parallel requests for each involvement type and merges results.
|
||||
/// The API uses AND logic when multiple flags are set, so we need separate
|
||||
/// calls to get OR-union semantics for "All" involvement.
|
||||
/// Individual requests that time out return empty so others still appear.
|
||||
private func searchInvolved(service: IssueService, page: Int, limit: Int) async throws -> [Issue] {
|
||||
let type = issueType
|
||||
let state = stateFilter.rawValue
|
||||
let query = searchText
|
||||
|
||||
let batches = try await withThrowingTaskGroup(of: [Issue].self) { group in
|
||||
for flag in involvedFlags {
|
||||
group.addTask {
|
||||
try await resilientFetch {
|
||||
try await service.searchIssues(
|
||||
type: type, state: state, query: query, page: page, limit: limit,
|
||||
assigned: flag == .assigned, created: flag == .created,
|
||||
mentioned: flag == .mentioned, reviewRequested: flag == .reviewRequested,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: [[Issue]]()) { $0.append($1) }
|
||||
}
|
||||
|
||||
return Self.deduplicateAndSort(batches)
|
||||
}
|
||||
|
||||
private var involvedFlags: [InvolvementScope] {
|
||||
var flags: [InvolvementScope] = [.created, .assigned, .mentioned]
|
||||
if showReviewRequested { flags.append(.reviewRequested) }
|
||||
return flags
|
||||
}
|
||||
|
||||
/// Runs a fetch; returns `[]` on timeout so one slow request does not
|
||||
/// block the rest. All other errors (auth, decoding, etc.) propagate.
|
||||
private func resilientFetch(_ fetch: () async throws -> [Issue]) async throws -> [Issue] {
|
||||
do {
|
||||
return try await fetch()
|
||||
} catch let urlError as URLError where urlError.code == .timedOut {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges multiple batches of issues, removing duplicates by ID and
|
||||
/// sorting by most recently updated first.
|
||||
static func deduplicateAndSort(_ batches: [[Issue]]) -> [Issue] {
|
||||
var seen = Set<Int>()
|
||||
var merged = [Issue]()
|
||||
for issue in batches.flatMap({ $0 }) where seen.insert(issue.id).inserted {
|
||||
merged.append(issue)
|
||||
}
|
||||
return merged.sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func reloadItems(clearItems: Bool = false) -> Task<Void, Never> {
|
||||
guard let issueService else { return Task {} }
|
||||
|
|
|
|||
60
Forji/ForjiTests/SearchableOverviewTests.swift
Normal file
60
Forji/ForjiTests/SearchableOverviewTests.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import ForgejoKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
|
||||
@testable import Forji
|
||||
|
||||
private func makeIssue(id: Int, updatedAt: Date = Date()) -> ForgejoKit.Issue {
|
||||
ForgejoKit.Issue(
|
||||
id: id, number: id,
|
||||
title: "Issue \(id)", body: nil, state: "open",
|
||||
user: User(id: 1, login: "u", fullName: "U", email: "u@e", avatarUrl: nil, isAdmin: false, created: Date()),
|
||||
createdAt: Date(), updatedAt: updatedAt, comments: 0,
|
||||
)
|
||||
}
|
||||
|
||||
private typealias SUT = SearchableOverviewView<EmptyView, EmptyView, EmptyView>
|
||||
|
||||
@Suite("deduplicateAndSort")
|
||||
struct DeduplicateAndSortTests {
|
||||
@Test func emptyBatchesReturnEmpty() {
|
||||
let result = SUT.deduplicateAndSort([[], []])
|
||||
#expect(result.isEmpty)
|
||||
}
|
||||
|
||||
@Test func singleBatchPreserved() {
|
||||
let now = Date()
|
||||
let issues = [
|
||||
makeIssue(id: 1, updatedAt: now),
|
||||
makeIssue(id: 2, updatedAt: now.addingTimeInterval(-10)),
|
||||
]
|
||||
let result = SUT.deduplicateAndSort([issues])
|
||||
#expect(result.map { $0.id } == [1, 2])
|
||||
}
|
||||
|
||||
@Test func duplicatesRemovedKeepsFirst() {
|
||||
let a = makeIssue(id: 1, updatedAt: Date())
|
||||
let b = makeIssue(id: 1, updatedAt: Date().addingTimeInterval(-100))
|
||||
let result = SUT.deduplicateAndSort([[a], [b]])
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].id == 1)
|
||||
}
|
||||
|
||||
@Test func sortedByUpdatedAtDescending() {
|
||||
let old = makeIssue(id: 1, updatedAt: Date().addingTimeInterval(-200))
|
||||
let mid = makeIssue(id: 2, updatedAt: Date().addingTimeInterval(-100))
|
||||
let recent = makeIssue(id: 3, updatedAt: Date())
|
||||
let result = SUT.deduplicateAndSort([[old], [recent], [mid]])
|
||||
#expect(result.map { $0.id } == [3, 2, 1])
|
||||
}
|
||||
|
||||
@Test func duplicatesAcrossMultipleBatches() {
|
||||
let now = Date()
|
||||
let a = makeIssue(id: 1, updatedAt: now)
|
||||
let b = makeIssue(id: 2, updatedAt: now.addingTimeInterval(-10))
|
||||
let c = makeIssue(id: 3, updatedAt: now.addingTimeInterval(-20))
|
||||
let result = SUT.deduplicateAndSort([[a, b], [b, c], [a, c]])
|
||||
#expect(result.map { $0.id } == [1, 2, 3])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue