diff --git a/apps/zbugs-swift/Sources/ZBugs/Queries.swift b/apps/zbugs-swift/Sources/ZBugs/Queries.swift index 37c8aded38..2331570f4e 100644 --- a/apps/zbugs-swift/Sources/ZBugs/Queries.swift +++ b/apps/zbugs-swift/Sources/ZBugs/Queries.swift @@ -178,6 +178,13 @@ public struct IssueListV2Args: Codable, Sendable, Hashable { } } +public struct IssueListCountArgs: Codable, Sendable, Hashable { + public var listContext: ListContextParams + public init(listContext: ListContextParams) { + self.listContext = listContext + } +} + public struct ListIssueByIDArgs: Codable, Sendable, Hashable { public var listContext: ListContextParams public var idField: IDField @@ -375,6 +382,13 @@ public enum Queries { } /// One issue, for the order to start a list at. + /// The number of issues in the list: the same filters and permissions as + /// `issueListV2`, reduced to a `count()`. + public static let issueListCount = defineQuery("issueListCount") { + (args: IssueListCountArgs, auth: AuthData?) in + filterListQuery(zql.issue, args.listContext, auth?.role).count() + } + public static let listIssueByID = defineQuery("listIssueByID") { (args: ListIssueByIDArgs, auth: AuthData?) in buildListQuery(ListQueryArgs(listContext: args.listContext, role: auth?.role)) @@ -421,7 +435,8 @@ public enum Queries { /// Every query, for the server's registry and the drift test. public static let all: [any NamedQuery] = [ allLabels, allUsers, allProjects, user, crewUser, labels, labelsOrderByName, issuePreloadV2, - userPref, usersForProject, commentsPage, comment, issueDetail, issueListV2, listIssueByID, + userPref, usersForProject, commentsPage, comment, issueDetail, issueListV2, issueListCount, + listIssueByID, emojiChange, prevNext, issueList, userPicker, ] } @@ -483,8 +498,6 @@ func buildListQuery(_ args: ListQueryArgs) -> Query { guard let listContext = args.listContext else { return alwaysFalse(q) } - let projectName = (listContext.projectName ?? zeroProjectName).lowercased() - q = q.whereExists(\.project, scalar: true) { $0.where(\.lowerCaseName, projectName) } let dir: AST.Direction = args.dir == .forward @@ -502,6 +515,17 @@ func buildListQuery(_ args: ListQueryArgs) -> Query { q = q.limit(limit) } + return filterListQuery(q, listContext, args.role) +} + +/// Narrows `q` to the issues `listContext` selects that `role` may see +/// (`filterListQuery`): shared by the list and its count. +func filterListQuery( + _ q: Query, _ listContext: ListContextParams, _ role: Role? +) -> Query { + let projectName = (listContext.projectName ?? zeroProjectName).lowercased() + var q = q.whereExists(\.project, scalar: true) { $0.where(\.lowerCaseName, projectName) } + let text = truthy(listContext.textFilter).map { "%\(escapeLike($0))%" } q = q.where { issue in issue.and( @@ -532,7 +556,7 @@ func buildListQuery(_ args: ListQueryArgs) -> Query { if let assignee = truthy(listContext.assignee) { q = q.whereExists(\.assignee, scalar: true) { $0.where(\.login, assignee) } } - return applyIssuePermissions(q, args.role) + return applyIssuePermissions(q, role) } extension Query { diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift index 3091ba58d5..9062341bdc 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift @@ -67,6 +67,58 @@ } } + /// The number of issues in the list, as zbugs' list page shows it: the + /// server's `issueListCount` once it has counted the list, in this session + /// or a previous one, and until then the list's running total. A text + /// search isn't counted: matching it checks every issue's comments, and it + /// changes with every keystroke. + struct IssueCount: View { + let filter: IssueFilter + let list: IssueVirtualizer? + + var body: some View { + if filter.text.isEmpty { + SyncedIssueCount(filter: filter, list: list) + } else { + IssueCountLabel(synced: nil, list: list) + } + } + } + + private struct SyncedIssueCount: View { + let list: IssueVirtualizer? + @ZeroQuery var count: Int + + init(filter: IssueFilter, list: IssueVirtualizer?) { + self.list = list + _count = ZeroQuery( + Queries.issueListCount(IssueListCountArgs(listContext: filter.listContext)), + ttl: .defaultTTL) + } + + var body: some View { + let counted = $count.resultType == .complete || $count.resultType == .cached + IssueCountLabel(synced: counted ? count : nil, list: list) + } + } + + private struct IssueCountLabel: View { + let synced: Int? + let list: IssueVirtualizer? + + var body: some View { + let s = list?.snapshot + let total = synced ?? s?.total + if let total { + Text(total.formatted()).accessibilityIdentifier("IssueCount") + } else if let s, s.complete || s.estimatedTotal > 0 { + // Rounded down to 50 above 50, as the list page does. + let e = s.estimatedTotal + Text("\((e < 50 ? e : e - e % 50).formatted())+").accessibilityIdentifier("IssueCount") + } + } + } + /// The issue list in SwiftUI. struct IssueList: View { let list: IssueVirtualizer diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 1c17fcaecc..d197e69942 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -194,6 +194,11 @@ } @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItem { + IssueCount(filter: filter, list: issues) + .foregroundStyle(.secondary) + .monospacedDigit() + } ToolbarItem { ConnectionBadge(connection: model.zero.connection) } diff --git a/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift b/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift index 3bf3593909..2eb5e99c84 100644 --- a/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift +++ b/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift @@ -13,8 +13,6 @@ import ZeroTesting .appendingPathComponent("drift-vectors.json"), encoding: .utf8) let failures = try Drift.failures( vectors: vectors, schema: .zbugs, queries: Queries.all, mutators: Mutators.all) - // Not ported yet: issueListCount is a count(), and the Swift client has no - // aggregates. Exact, so this fails once it is ported and must be removed. - #expect(failures == ["query issueListCount: no Swift definition"]) + #expect(failures == []) } } diff --git a/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json b/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json index b3b5f38860..ae12ca31c2 100644 --- a/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json +++ b/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json @@ -3289,6 +3289,559 @@ }, "hash": "1b5sw4o1vkbsje" }, + { + "name": "issueListCount", + "args": [ + { + "listContext": { + "open": true, + "assignee": null, + "creator": null, + "labels": null, + "textFilter": null, + "sortField": "modified", + "sortDirection": "desc" + } + } + ], + "context": { + "sub": "u-alice", + "role": "user" + }, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "open" + }, + "right": { + "type": "literal", + "value": true + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "visibility" + }, + "right": { + "type": "literal", + "value": "public" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + }, + "aggregate": { + "fn": "count" + } + }, + "hash": "1dr1ns00xbry3j" + }, + { + "name": "issueListCount", + "args": [ + { + "listContext": { + "open": false, + "projectName": "Zero", + "assignee": "crew", + "creator": "alice", + "labels": [ + "bug", + "p1" + ], + "textFilter": "50%_off\\", + "sortField": "created", + "sortDirection": "asc" + } + } + ], + "context": { + "sub": "u-crew", + "role": "crew" + }, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "open" + }, + "right": { + "type": "literal", + "value": false + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "assigneeID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_assignee", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "login" + }, + "right": { + "type": "literal", + "value": "crew" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "issueLabel", + "alias": "zsubq_issueLabels", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "labelID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_label", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "name" + }, + "right": { + "type": "literal", + "value": "bug" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + }, + "system": "client" + }, + "op": "EXISTS" + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "issueLabel", + "alias": "zsubq_issueLabels", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "labelID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_label", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "name" + }, + "right": { + "type": "literal", + "value": "p1" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + }, + "system": "client" + }, + "op": "EXISTS" + }, + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "description" + }, + "right": { + "type": "literal", + "value": "%50\\%\\_off\\%" + } + }, + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": "%50\\%\\_off\\%" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_comments", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "body" + }, + "right": { + "type": "literal", + "value": "%50\\%\\_off\\%" + } + } + }, + "system": "client" + }, + "op": "EXISTS" + } + ] + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "creatorID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_creator", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "login" + }, + "right": { + "type": "literal", + "value": "alice" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + }, + "aggregate": { + "fn": "count" + } + }, + "hash": "0ta5n0m0bdljqv" + }, + { + "name": "issueListCount", + "args": [ + { + "listContext": { + "open": true, + "assignee": null, + "creator": null, + "labels": null, + "textFilter": null, + "sortField": "modified", + "sortDirection": "desc" + } + } + ], + "context": null, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "open" + }, + "right": { + "type": "literal", + "value": true + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "visibility" + }, + "right": { + "type": "literal", + "value": "public" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + }, + "aggregate": { + "fn": "count" + } + }, + "hash": "1dr1ns00xbry3j" + }, { "name": "listIssueByID", "args": [ diff --git a/apps/zbugs-swift/drift-samples.ts b/apps/zbugs-swift/drift-samples.ts index 90a76913dd..87f3da2f2b 100644 --- a/apps/zbugs-swift/drift-samples.ts +++ b/apps/zbugs-swift/drift-samples.ts @@ -195,6 +195,9 @@ const querySamples: QuerySample[] = [ name: 'issueListV2', args: {listContext, limit: 0, start: rowSort, dir: 'forward'}, }, + {name: 'issueListCount', args: {listContext}, context: alice}, + {name: 'issueListCount', args: {listContext: fullListContext}, context: crew}, + {name: 'issueListCount', args: {listContext}}, { name: 'listIssueByID', args: {listContext, idField: 'shortID', idValue: 12},