fix moderation collapsibles & do a consistency pass (#7330)

* fix moderation collapsibles & do a consistency pass

* extract tech review sources

* improve buttons

* buttn

* improve markdown styling

* fix nested details

* save filters + fix reports reloading

* prepr
This commit is contained in:
Prospector
2026-08-27 18:46:13 +00:00
committed by GitHub
parent 263aaedc8f
commit ffd864a9e2
69 changed files with 3573 additions and 3088 deletions
@@ -1,21 +1,13 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<Input
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1"
input-class="h-[40px] w-full"
@input="goToPage(1)"
/>
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
<ModerationQueueToolbar
v-model="query"
:page="currentPage"
:total-pages="totalPages"
@search="goToPage(1)"
@switch-page="goToPage"
>
<template #actions>
<Combobox
v-model="currentMessageFilter"
class="!w-full flex-grow sm:!w-[200px] sm:flex-grow-0"
@@ -25,12 +17,14 @@
trigger-size="lg"
@select="goToPage(1)"
>
<template #selected="{ label: messageLabel }">
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ messageLabel }} ({{ sortedReports.length }})</span
>
<ModerationFilterCount
:label="currentMessageFilterName"
:count="sortedReports.length"
:loading="isLoading"
/>
</span>
</template>
</Combobox>
@@ -78,7 +72,7 @@
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-inherit">
{{
currentReporterOrProject.length === 0
? 'All Reports'
? 'All reports'
: `${currentReporterOrProject.length} selected`
}}
</span>
@@ -111,7 +105,7 @@
class="h-5 w-5 shrink-0 text-primary"
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">All Reports</span>
<span class="min-w-0 flex-1 font-semibold leading-tight">All reports</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon
v-if="currentReporterOrProject.length === 0"
@@ -169,35 +163,34 @@
</div>
</template>
</TeleportPopoutMenu>
</div>
</div>
</template>
<template #meta>
<div v-if="sortedReports.length > 0">
Showing {{ formatNumber(pageStart) }}{{ formatNumber(pageEnd) }} of
{{ formatNumber(sortedReports.length) }} reports
</div>
</template>
</ModerationQueueToolbar>
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing
{{ itemsPerPage * (currentPage - 1) + 1 }}
{{ itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedReports.length) }}
of {{ sortedReports.length }} reports
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ModerationQueueSkeleton v-if="isLoading" />
<div
v-else-if="paginatedReports.length === 0"
class="universal-card flex h-24 items-center justify-center text-secondary"
>
No reports in queue.
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col gap-4">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<div v-else class="flex flex-col gap-4 overflow-x-clip">
<ReportCard
v-for="report in paginatedReports"
:key="report.id"
:report="report"
:collapsed="true"
dismiss-after-close
@dismiss="dismissReport(report.id)"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<div v-if="totalPages > 1" class="flex justify-end">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
@@ -211,7 +204,6 @@ import {
ChevronLeftIcon,
LayersIcon,
ListFilterIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
} from '@modrinth/assets'
@@ -222,163 +214,259 @@ import {
commonMessages,
formatReportType,
injectModrinthClient,
Input,
MultiSelect,
type MultiSelectItem,
Pagination,
TeleportPopoutMenu,
useDebugLogger,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import Fuse from 'fuse.js'
import ModerationFilterCount from '~/components/ui/moderation/ModerationFilterCount.vue'
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
import { enrichReportBatch } from '~/helpers/moderation.ts'
useHead({ title: 'Reports queue - Modrinth' })
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const route = useRoute()
const router = useRouter()
const auth = await useAuth()
const client = injectModrinthClient()
const debug = useDebugLogger('ModerationReports')
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
const startTime = performance.now()
let currentOffset = 0
const REPORT_ENDPOINT_COUNT = 350
const allReports: ExtendedReport[] = []
const { data: allReports, pending: reportsPending } = await useLazyAsyncData(
'new-moderation-reports',
async () => {
const startTime = performance.now()
let currentOffset = 0
const REPORT_ENDPOINT_COUNT = 350
const allReports: ExtendedReport[] = []
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
let reports: Labrinth.Reports.v3.Report[]
let hasMoreReports = true
while (hasMoreReports) {
reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
{
apiVersion: 3,
},
)) as Labrinth.Reports.v3.Report[]
let reports: Labrinth.Reports.v3.Report[]
let hasMoreReports = true
while (hasMoreReports) {
reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
{
apiVersion: 3,
},
)) as Labrinth.Reports.v3.Report[]
hasMoreReports = reports.length > 0
if (!hasMoreReports) {
break
hasMoreReports = reports.length > 0
if (!hasMoreReports) {
break
}
const enrichmentPromise = enrichReportBatch(reports, client)
enrichmentPromises.push(enrichmentPromise)
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
currentOffset += REPORT_ENDPOINT_COUNT
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allReports.push(...completed.flat())
}
}
const enrichmentPromise = enrichReportBatch(reports, client)
enrichmentPromises.push(enrichmentPromise)
const remainingBatches = await Promise.all(enrichmentPromises)
allReports.push(...remainingBatches.flat())
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
currentOffset += REPORT_ENDPOINT_COUNT
const endTime = performance.now()
const duration = endTime - startTime
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allReports.push(...completed.flat())
}
}
debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
const remainingBatches = await Promise.all(enrichmentPromises)
allReports.push(...remainingBatches.flat())
const endTime = performance.now()
const duration = endTime - startTime
debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
return allReports
})
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
return allReports
},
)
const currentSortTypeSorting = ref('oldest')
const isLoading = computed(() => reportsPending.value || allReports.value == null)
const SORT_VALUES = ['oldest', 'newest'] as const
const sortTypes: ComboboxOption<string>[] = [
{ value: 'oldest', label: 'Oldest' },
{ value: 'newest', label: 'Newest' },
]
const currentMessageFilter = ref('all')
const messageFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
{ value: 'read', label: 'Read' },
{ value: 'involved', label: 'Involved' },
]
const MESSAGE_FILTERS = [
{ value: 'all', name: 'All' },
{ value: 'unread', name: 'Unread' },
{ value: 'read', name: 'Read' },
{ value: 'involved', name: 'Involved' },
] as const
const MESSAGE_FILTER_VALUES = MESSAGE_FILTERS.map((filter) => filter.value)
const currentProjectTypeFilter = ref('all')
const projectTypeFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All project types' },
{ value: 'modpack', label: 'Modpacks' },
{ value: 'mod', label: 'Mods' },
{ value: 'resourcepack', label: 'Resource Packs' },
{ value: 'datapack', label: 'Data Packs' },
{ value: 'plugin', label: 'Plugins' },
{ value: 'shader', label: 'Shaders' },
{ value: 'minecraft_java_server', label: 'Servers' },
{ value: 'shared-instance', label: 'Shared instance' },
]
const PROJECT_TYPE_FILTERS = [
{ value: 'all', name: 'All project types' },
{ value: 'modpack', name: 'Modpacks' },
{ value: 'mod', name: 'Mods' },
{ value: 'resourcepack', name: 'Resource Packs' },
{ value: 'datapack', name: 'Data Packs' },
{ value: 'plugin', name: 'Plugins' },
{ value: 'shader', name: 'Shaders' },
{ value: 'minecraft_java_server', name: 'Servers' },
{ value: 'shared-instance', name: 'Shared instance' },
] as const
const PROJECT_TYPE_VALUES = PROJECT_TYPE_FILTERS.map((filter) => filter.value)
const currentReportTargetFilter = ref('all')
const reportTargetFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All' },
{ value: 'project', label: 'Projects' },
{ value: 'user', label: 'Users' },
{ value: 'version', label: 'Versions' },
{ value: 'shared-instance', label: 'Shared instances' },
]
const REPORT_TARGET_FILTERS = [
{ value: 'all', name: 'All' },
{ value: 'project', name: 'Projects' },
{ value: 'user', name: 'Users' },
{ value: 'version', name: 'Versions' },
{ value: 'shared-instance', name: 'Shared instances' },
] as const
const REPORT_TARGET_VALUES = REPORT_TARGET_FILTERS.map((filter) => filter.value)
const currentReportIssueFilter = ref('all')
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
const base: ComboboxOption<string>[] = [{ value: 'all', label: 'All' }]
if (!allReports.value) return base
function parseAllowed<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
const parsed = queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? '')
return (allowed as readonly string[]).includes(parsed) ? (parsed as T) : fallback
}
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
function parsePage(value: unknown): number {
const page = Number.parseInt(
queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? ''),
10,
)
return Number.isInteger(page) && page > 0 ? page : 1
}
const sortedTypes = Array.from(issueTypes).sort()
return [
...base,
...sortedTypes.map((type) => ({
value: type,
label: formatReportType(formatMessage, type),
})),
]
})
function selectedValuesEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
function serializeRouteQuery(query: typeof route.query): string {
const keys = Object.keys(query).sort()
return JSON.stringify(
Object.fromEntries(
keys.flatMap((key) => {
const value = query[key]
if (value == null || value === '') return []
return [[key, Array.isArray(value) ? value.map(String) : String(value)]]
}),
),
)
}
const query = ref(queryAsStringOrEmpty(route.query.q ?? ''))
const currentSortTypeSorting = ref(parseAllowed(route.query.sort, SORT_VALUES, 'oldest'))
const currentMessageFilter = ref(parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all'))
const currentMessageFilterName = computed(
() =>
MESSAGE_FILTERS.find((filter) => filter.value === currentMessageFilter.value)?.name ?? 'All',
)
const currentProjectTypeFilter = ref(
parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all'),
)
const currentReportTargetFilter = ref(parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all'))
const currentReportIssueFilter = ref(queryAsStringOrEmpty(route.query.issue ?? '') || 'all')
const currentReporterOrProject = ref(queryAsStringArray(route.query.selected))
const currentPage = ref(parsePage(route.query.page))
function writeFiltersToRoute() {
const nextQuery = { ...route.query }
if (query.value) nextQuery.q = query.value
else delete nextQuery.q
if (currentSortTypeSorting.value !== 'oldest') nextQuery.sort = currentSortTypeSorting.value
else delete nextQuery.sort
if (currentMessageFilter.value !== 'all') nextQuery.messages = currentMessageFilter.value
else delete nextQuery.messages
if (currentReportTargetFilter.value !== 'all') nextQuery.target = currentReportTargetFilter.value
else delete nextQuery.target
if (currentReportIssueFilter.value !== 'all') nextQuery.issue = currentReportIssueFilter.value
else delete nextQuery.issue
if (currentProjectTypeFilter.value !== 'all') {
nextQuery.projectType = currentProjectTypeFilter.value
} else {
delete nextQuery.projectType
}
if (currentReporterOrProject.value.length === 1) {
nextQuery.selected = currentReporterOrProject.value[0]
} else if (currentReporterOrProject.value.length > 1) {
nextQuery.selected = currentReporterOrProject.value
} else {
delete nextQuery.selected
}
if (currentPage.value > 1) nextQuery.page = String(currentPage.value)
else delete nextQuery.page
if (serializeRouteQuery(route.query) === serializeRouteQuery(nextQuery)) return
router.replace({
path: route.path,
query: nextQuery,
})
}
function readFiltersFromRoute() {
const nextQuery = queryAsStringOrEmpty(route.query.q ?? '')
if (query.value !== nextQuery) query.value = nextQuery
const nextSort = parseAllowed(route.query.sort, SORT_VALUES, 'oldest')
if (currentSortTypeSorting.value !== nextSort) currentSortTypeSorting.value = nextSort
const nextMessages = parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all')
if (currentMessageFilter.value !== nextMessages) currentMessageFilter.value = nextMessages
const nextProjectType = parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all')
if (currentProjectTypeFilter.value !== nextProjectType) {
currentProjectTypeFilter.value = nextProjectType
}
const nextTarget = parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all')
if (currentReportTargetFilter.value !== nextTarget) currentReportTargetFilter.value = nextTarget
const nextIssue = queryAsStringOrEmpty(route.query.issue ?? '') || 'all'
if (currentReportIssueFilter.value !== nextIssue) currentReportIssueFilter.value = nextIssue
const nextSelected = queryAsStringArray(route.query.selected)
if (!selectedValuesEqual(currentReporterOrProject.value, nextSelected)) {
currentReporterOrProject.value = nextSelected
}
const nextPage = parsePage(route.query.page)
if (currentPage.value !== nextPage) currentPage.value = nextPage
}
watch(
[
query,
currentSortTypeSorting,
currentMessageFilter,
currentProjectTypeFilter,
currentReportTargetFilter,
currentReportIssueFilter,
currentReporterOrProject,
currentPage,
],
writeFiltersToRoute,
{ deep: true },
)
watch(() => route.query, readFiltersFromRoute, { deep: true })
type ReportedType<T> = T & { report_item_count: number }
const currentReporterOrProject = ref<string[]>([])
const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
if (!allReports.value) return []
const options: MultiSelectItem<string>[] = []
@@ -410,7 +498,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
.forEach((project) => {
options.push({
value: `project/${project.id}`,
label: `${project.title} (${project.report_item_count})`,
label: `${project.title} (${formatNumber(project.report_item_count)})`,
icon: project.icon_url ? h('img', { src: project.icon_url }) : undefined,
})
})
@@ -426,7 +514,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
.forEach((reporter) => {
options.push({
value: `reporter/${reporter.id}`,
label: `${reporter.username} (${reporter.report_item_count})`,
label: `${reporter.username} (${formatNumber(reporter.report_item_count)})`,
icon: reporter.avatar_url ? h('img', { src: reporter.avatar_url }) : undefined,
})
})
@@ -434,7 +522,6 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
return options
})
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
@@ -501,65 +588,164 @@ const baseFiltered = computed(() => {
})
const filteredReports = computed(() => {
const messageFilter = currentMessageFilter.value
const projectTypeFilter = currentProjectTypeFilter.value
const reportTargetFilter = currentReportTargetFilter.value
const reportIssueFilter = currentReportIssueFilter.value
if (
messageFilter === 'all' &&
projectTypeFilter === 'all' &&
reportTargetFilter === 'all' &&
reportIssueFilter === 'all'
) {
return baseFiltered.value
}
const messageFilterPredicate = (report: ExtendedReport) => {
const messages = report.thread?.messages || []
if (messageFilter === 'all') return true
if (messages.length === 0) return messageFilter === 'Unread'
if (!messages[messages.length - 1].author_id) return false
if (messageFilter === 'involved') {
const userId = (auth.value.user as any)?.id
return userId && messages.some((message) => message.author_id === userId)
}
const roleMap = memberRoleMap.value.get(report.id)
if (!roleMap) return false
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
return messageFilter === 'Read' ? isModeratorMessage : !isModeratorMessage
}
const projectTypeFilterPredicate = (report: ExtendedReport) => {
if (projectTypeFilter === 'all') return true
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
return report.project?.project_type === projectTypeFilter
}
const reportTargetFilterPredicate = (report: ExtendedReport) => {
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
}
const reportIssueFilterPredicate = (report: ExtendedReport) => {
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
}
return baseFiltered.value.filter((report) => {
return (
messageFilterPredicate(report) &&
projectTypeFilterPredicate(report) &&
reportTargetFilterPredicate(report) &&
reportIssueFilterPredicate(report)
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value)
)
})
})
function matchesMessageFilter(
report: ExtendedReport,
messageFilter: (typeof MESSAGE_FILTERS)[number]['value'] | string,
): boolean {
if (messageFilter === 'all') return true
const messages = report.thread?.messages || []
if (messages.length === 0) return messageFilter === 'unread'
if (!messages[messages.length - 1].author_id) return false
if (messageFilter === 'involved') {
const userId = (auth.value.user as any)?.id
return !!userId && messages.some((message) => message.author_id === userId)
}
const roleMap = memberRoleMap.value.get(report.id)
if (!roleMap) return false
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
return messageFilter === 'read' ? isModeratorMessage : !isModeratorMessage
}
function matchesProjectTypeFilter(
report: ExtendedReport,
projectTypeFilter: (typeof PROJECT_TYPE_FILTERS)[number]['value'] | string,
): boolean {
if (projectTypeFilter === 'all') return true
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
return report.project?.project_type === projectTypeFilter
}
function matchesReportTargetFilter(
report: ExtendedReport,
reportTargetFilter: (typeof REPORT_TARGET_FILTERS)[number]['value'] | string,
): boolean {
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
}
function matchesReportIssueFilter(report: ExtendedReport, reportIssueFilter: string): boolean {
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
}
function labelWithCount(name: string, count: number): string {
return `${name} (${formatNumber(count)})`
}
const reportsForMessageCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value),
),
)
const reportsForProjectTypeCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value),
),
)
const reportsForTargetCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value),
),
)
const reportsForIssueCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value),
),
)
const messageFilterTypes = computed<ComboboxOption<string>[]>(() =>
MESSAGE_FILTERS.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForMessageCounts.value.filter((report) =>
matchesMessageFilter(report, filter.value),
).length,
),
})),
)
const projectTypeFilterTypes = computed<ComboboxOption<string>[]>(() =>
PROJECT_TYPE_FILTERS.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForProjectTypeCounts.value.filter((report) =>
matchesProjectTypeFilter(report, filter.value),
).length,
),
})),
)
const reportTargetFilterTypes = computed<ComboboxOption<string>[]>(() =>
REPORT_TARGET_FILTERS.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForTargetCounts.value.filter((report) =>
matchesReportTargetFilter(report, filter.value),
).length,
),
})),
)
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
const issueTypes = new Set((allReports.value ?? []).map((report) => report.report_type))
const options = [
{ value: 'all', name: 'All' },
...Array.from(issueTypes)
.sort()
.map((type) => ({
value: type,
name: formatReportType(formatMessage, type),
})),
]
return options.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForIssueCounts.value.filter((report) =>
matchesReportIssueFilter(report, filter.value),
).length,
),
}))
})
const sortedReports = computed(() => {
const reporterOrProjectFilter = currentReporterOrProject.value
const filtered =
@@ -591,7 +777,37 @@ const paginatedReports = computed(() => {
return sortedReports.value.slice(start, end)
})
const pageStart = computed(() =>
sortedReports.value.length === 0 ? 0 : itemsPerPage * (currentPage.value - 1) + 1,
)
const pageEnd = computed(
() =>
itemsPerPage * (currentPage.value - 1) + Math.min(itemsPerPage, paginatedReports.value.length),
)
function goToPage(page: number) {
currentPage.value = page
}
watch(totalPages, (pages) => {
if (isLoading.value) return
if (pages === 0) {
if (currentPage.value !== 1) goToPage(1)
return
}
if (currentPage.value > pages) {
goToPage(pages)
}
})
function dismissReport(reportId: string) {
if (!allReports.value) return
allReports.value = allReports.value.filter((report) => report.id !== reportId)
if (currentPage.value > totalPages.value) {
currentPage.value = Math.max(1, totalPages.value)
}
}
</script>