Compare commits

...
Author SHA1 Message Date
tdgao b78c88ce18 feat: initial implementation of breakdown groups 2026-07-14 13:10:50 -07:00
15 changed files with 1222 additions and 28 deletions
@@ -1,6 +1,7 @@
import type { Labrinth } from '@modrinth/api-client'
import {
type AnalyticsBreakdownGroup,
type AnalyticsBreakdownPreset,
type AnalyticsDashboardProject,
type AnalyticsDashboardStat,
@@ -17,6 +18,7 @@ import {
analyticsStatCardMessages,
formatAnalyticsDependentProjectFallbackLabel,
formatAnalyticsDownloadReasonLabel,
formatAnalyticsDownloadSourceLabel,
formatAnalyticsLoaderLabel,
formatAnalyticsMonetizationLabel,
} from '../analytics-messages'
@@ -30,6 +32,10 @@ import {
isUnknownAnalyticsBreakdownValue,
UNKNOWN_BREAKDOWN_VALUE,
} from '../breakdown'
import {
applyAnalyticsBreakdownGroup,
getAnalyticsBreakdownGroupSeriesName,
} from '../breakdown-groups'
import { PREVIOUS_PERIOD_DATASET_ID_PREFIX } from './analytics-chart-constants'
export type ChartDataset = {
@@ -168,6 +174,9 @@ export function formatBreakdownLabel(
if (selectedBreakdown === 'download_reason') {
return formatAnalyticsDownloadReasonLabel(normalizedLowercaseValue, formatMessage)
}
if (selectedBreakdown === 'user_agent') {
return formatAnalyticsDownloadSourceLabel(normalizedValue, formatMessage)
}
if (selectedBreakdown === 'dependent_project_download') {
if (isNoDependentAnalyticsBreakdownValue(breakdownValue)) {
return formatMessage(analyticsMessages.noDependent)
@@ -412,6 +421,7 @@ export function buildChartDatasets(
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
formatMessage: FormatMessage,
sliceCount: number = timeSlices.length,
breakdownGroup?: AnalyticsBreakdownGroup | null,
): ChartDataset[] {
const selectedProjectIds = new Set(selectedProjects.map((project) => project.id))
if (selectedProjectIds.size === 0) {
@@ -428,6 +438,14 @@ export function buildChartDatasets(
return collapseRepeatedUnknownBreakdownLabels(
normalizedBreakdowns.map((breakdown, index) => {
const breakdownValue = breakdownValues[index] ?? ''
if (breakdown === breakdownGroup?.breakdown) {
const seriesName = getAnalyticsBreakdownGroupSeriesName(
breakdownValue,
breakdownGroup,
formatMessage(analyticsMessages.other),
)
if (seriesName) return seriesName
}
if (breakdown === 'project' || breakdown === 'dependent_project_download') {
if (
breakdown === 'dependent_project_download' &&
@@ -464,7 +482,7 @@ export function buildChartDatasets(
if (
normalizedBreakdowns.length > 0 &&
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project')
!(normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'project' && !breakdownGroup)
) {
const hasVersionBreakdown = normalizedBreakdowns.includes('version_id')
const hasDependentProjectBreakdown = normalizedBreakdowns.includes('dependent_project_download')
@@ -488,10 +506,10 @@ export function buildChartDatasets(
continue
}
const breakdownValues = getAnalyticsBreakdownValues(
point,
const breakdownValues = applyAnalyticsBreakdownGroup(
getAnalyticsBreakdownValues(point, normalizedBreakdowns, formatMessage),
normalizedBreakdowns,
formatMessage,
breakdownGroup,
)
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
continue
@@ -543,11 +561,14 @@ export function buildChartDatasets(
'dependent_project_download',
)
const versionName =
hasVersionBreakdown && versionBreakdownIndex !== -1
hasVersionBreakdown &&
versionBreakdownIndex !== -1 &&
breakdownGroup?.breakdown !== 'version_id'
? getVersionDisplayName?.(breakdownValues[versionBreakdownIndex] ?? '')
: undefined
const dependentProjectId =
dependentProjectBreakdownIndex !== -1
dependentProjectBreakdownIndex !== -1 &&
breakdownGroup?.breakdown !== 'dependent_project_download'
? breakdownValues[dependentProjectBreakdownIndex]
: undefined
const dependentProjectName = dependentProjectId
@@ -556,7 +577,9 @@ export function buildChartDatasets(
: (projectNamesById.get(dependentProjectId) ?? dependentProjectId)
: undefined
const versionProjectName =
normalizedBreakdowns.length === 1 && normalizedBreakdowns[0] === 'version_id'
normalizedBreakdowns.length === 1 &&
normalizedBreakdowns[0] === 'version_id' &&
breakdownGroup?.breakdown !== 'version_id'
? getVersionProjectName?.(breakdownValues[0] ?? '')
: undefined
const dependencyProjectNames = [...(dependentOnProjectIdsByBreakdown.get(breakdownKey) ?? [])]
@@ -575,7 +598,7 @@ export function buildChartDatasets(
)
: undefined
const color =
normalizedBreakdowns.length === 1
normalizedBreakdowns.length === 1 && !breakdownGroup
? getBreakdownColor(
breakdownValues[0] ?? '',
normalizedBreakdowns[0],
@@ -5,6 +5,7 @@ import { computed, type ComputedRef, ref, watch } from 'vue'
import { useTheme } from '~/composables/nuxt-accessors'
import { isDarkTheme } from '~/plugins/theme/index.ts'
import type {
AnalyticsBreakdownGroup,
AnalyticsBreakdownPreset,
AnalyticsDashboardContextValue,
AnalyticsDashboardProject,
@@ -51,6 +52,7 @@ export function useAnalyticsChartDatasets(
| 'displayedSelectedGroupBy'
| 'displayedSelectedBreakdowns'
| 'displayedSelectedFilters'
| 'resolvedActiveBreakdownGroup'
| 'hiddenGraphDatasetIds'
| 'hasExplicitGraphDatasetSelection'
| 'isGraphDatasetSelectionActive'
@@ -175,6 +177,7 @@ export function useAnalyticsChartDatasets(
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
context.resolvedActiveBreakdownGroup.value,
),
)
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
@@ -191,6 +194,7 @@ export function useAnalyticsChartDatasets(
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
formatMessage,
sliceCount.value,
context.resolvedActiveBreakdownGroup.value,
),
)
const allChartDatasets = computed(() => chartDatasetsByStat.value[context.activeStat.value])
@@ -384,6 +388,7 @@ function buildDatasetsByStat(
getVersionProjectName: ((versionId: string) => string | undefined) | undefined,
formatMessage: FormatMessage,
sliceCount: number,
breakdownGroup: AnalyticsBreakdownGroup | null,
) {
const datasetsByStat = {} as Record<AnalyticsDashboardStat, ChartDataset[]>
for (const stat of ANALYTICS_DASHBOARD_STATS) {
@@ -401,6 +406,7 @@ function buildDatasetsByStat(
getVersionProjectName,
formatMessage,
sliceCount,
breakdownGroup,
)
}
return datasetsByStat
@@ -32,6 +32,14 @@ export const analyticsMessages = defineMessages({
id: 'analytics.action.refresh',
defaultMessage: 'Refresh',
},
saveButton: {
id: 'analytics.action.save',
defaultMessage: 'Save',
},
cancelButton: {
id: 'analytics.action.cancel',
defaultMessage: 'Cancel',
},
fetchingResults: {
id: 'analytics.loading.fetching-results',
defaultMessage: 'Fetching results...',
@@ -129,6 +137,102 @@ export const analyticsMessages = defineMessages({
id: 'analytics.query.label.breakdown',
defaultMessage: 'Breakdown:',
},
breakdownGroupsButton: {
id: 'analytics.breakdown-groups.button',
defaultMessage: 'Group series',
},
breakdownGroupsNoGrouping: {
id: 'analytics.breakdown-groups.none',
defaultMessage: 'No grouping',
},
breakdownGroupsCreate: {
id: 'analytics.breakdown-groups.create',
defaultMessage: 'Create group',
},
breakdownGroupsEdit: {
id: 'analytics.breakdown-groups.edit',
defaultMessage: 'Edit group',
},
breakdownGroupsDelete: {
id: 'analytics.breakdown-groups.delete',
defaultMessage: 'Delete group',
},
breakdownGroupsDeleteTitle: {
id: 'analytics.breakdown-groups.delete-title',
defaultMessage: 'Delete {name}?',
},
breakdownGroupsDeleteDescription: {
id: 'analytics.breakdown-groups.delete-description',
defaultMessage: 'This saved breakdown group will be permanently deleted from this browser.',
},
breakdownGroupsModalCreateTitle: {
id: 'analytics.breakdown-groups.modal.create-title',
defaultMessage: 'Create breakdown group',
},
breakdownGroupsModalEditTitle: {
id: 'analytics.breakdown-groups.modal.edit-title',
defaultMessage: 'Edit breakdown group',
},
breakdownGroupsGroupName: {
id: 'analytics.breakdown-groups.group-name',
defaultMessage: 'Group name',
},
breakdownGroupsGroupNamePlaceholder: {
id: 'analytics.breakdown-groups.group-name-placeholder',
defaultMessage: 'For example, Platform families',
},
breakdownGroupsSeriesName: {
id: 'analytics.breakdown-groups.series-name',
defaultMessage: 'Series name',
},
breakdownGroupsSeriesValues: {
id: 'analytics.breakdown-groups.series-values',
defaultMessage: 'Breakdown values',
},
breakdownGroupsSelectValues: {
id: 'analytics.breakdown-groups.select-values',
defaultMessage: 'Select values',
},
breakdownGroupsAddSeries: {
id: 'analytics.breakdown-groups.add-series',
defaultMessage: 'Add series',
},
breakdownGroupsRemoveSeries: {
id: 'analytics.breakdown-groups.remove-series',
defaultMessage: 'Remove series',
},
breakdownGroupsOtherDescription: {
id: 'analytics.breakdown-groups.other-description',
defaultMessage: '{count, plural, one {# unassigned value} other {# unassigned values}}',
},
breakdownGroupsUnavailableValue: {
id: 'analytics.breakdown-groups.unavailable-value',
defaultMessage: '{value} (not in current results)',
},
breakdownGroupsNameRequired: {
id: 'analytics.breakdown-groups.validation.name-required',
defaultMessage: 'Enter a group name.',
},
breakdownGroupsNameDuplicate: {
id: 'analytics.breakdown-groups.validation.name-duplicate',
defaultMessage: 'A group with this name already exists for this breakdown.',
},
breakdownGroupsSeriesRequired: {
id: 'analytics.breakdown-groups.validation.series-required',
defaultMessage: 'Add at least one custom series.',
},
breakdownGroupsSeriesNameRequired: {
id: 'analytics.breakdown-groups.validation.series-name-required',
defaultMessage: 'Every custom series needs a name.',
},
breakdownGroupsSeriesNameDuplicate: {
id: 'analytics.breakdown-groups.validation.series-name-duplicate',
defaultMessage: 'Custom series names must be unique.',
},
breakdownGroupsSeriesValuesRequired: {
id: 'analytics.breakdown-groups.validation.series-values-required',
defaultMessage: 'Assign at least one breakdown value to every custom series.',
},
addFilterButton: {
id: 'analytics.query.filter.add',
defaultMessage: 'Add filter',
@@ -1,6 +1,7 @@
import type { TableColumn } from '@modrinth/ui'
import type {
AnalyticsBreakdownGroup,
AnalyticsBreakdownPreset,
AnalyticsDashboardStat,
AnalyticsSelectedFilters,
@@ -23,6 +24,7 @@ type BuildAnalyticsTableColumnsOptions = {
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
selectedFilters: AnalyticsSelectedFilters
showBreakdownColumn: boolean
breakdownGroup: AnalyticsBreakdownGroup | null
formatMessage: FormatMessage
getRelevantAnalyticsDashboardStats: (
breakdowns: readonly AnalyticsBreakdownPreset[],
@@ -42,6 +44,7 @@ export function buildAnalyticsTableColumns({
selectedBreakdowns,
selectedFilters,
showBreakdownColumn,
breakdownGroup,
formatMessage,
getRelevantAnalyticsDashboardStats,
}: BuildAnalyticsTableColumnsOptions): TableColumn<AnalyticsTableColumnKey>[] {
@@ -62,7 +65,10 @@ export function buildAnalyticsTableColumns({
for (const breakdown of selectedBreakdowns) {
nextColumns.push({
key: getAnalyticsTableBreakdownColumnKey(breakdown),
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
label:
breakdownGroup?.breakdown === breakdown
? breakdownGroup.name
: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
enableSorting: true,
width: breakdown === 'project' && selectedBreakdowns.length === 1 ? '45%' : undefined,
})
@@ -1,6 +1,7 @@
import type { Labrinth } from '@modrinth/api-client'
import {
type AnalyticsBreakdownGroup,
type AnalyticsBreakdownPreset,
type AnalyticsDashboardStat,
type AnalyticsSelectedFilters,
@@ -28,6 +29,10 @@ import {
isNoDependentAnalyticsBreakdownValue,
isUnknownAnalyticsBreakdownValue,
} from '../breakdown'
import {
applyAnalyticsBreakdownGroup,
getAnalyticsBreakdownGroupSeriesName,
} from '../breakdown-groups'
import { getAnalyticsTableBreakdownColumnKey } from './analytics-table-columns'
import type {
AnalyticsTableBreakdownDisplayValues,
@@ -43,6 +48,7 @@ type BuildAnalyticsTableRowsOptions = {
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null
timeSlices: Labrinth.Analytics.v3.TimeSlice[]
selectedBreakdowns: readonly AnalyticsTableBreakdownPreset[]
breakdownGroup: AnalyticsBreakdownGroup | null
selectedProjectIds: ReadonlySet<string>
selectedFilters: AnalyticsSelectedFilters
dependentProjectTypesById: ReadonlyMap<string, readonly string[]>
@@ -62,6 +68,7 @@ export function buildAnalyticsTableRows({
fetchRequest,
timeSlices,
selectedBreakdowns,
breakdownGroup,
selectedProjectIds,
selectedFilters,
dependentProjectTypesById,
@@ -94,6 +101,14 @@ export function buildAnalyticsTableRows({
breakdownValue: string,
breakdown: AnalyticsTableBreakdownPreset,
) {
if (breakdown === breakdownGroup?.breakdown) {
const seriesName = getAnalyticsBreakdownGroupSeriesName(
breakdownValue,
breakdownGroup,
formatMessage(analyticsMessages.other),
)
if (seriesName) return seriesName
}
const key = `${breakdown}:${breakdownValue}`
let displayValue = breakdownDisplayValues.get(key)
if (displayValue === undefined) {
@@ -112,7 +127,11 @@ export function buildAnalyticsTableRows({
function getProjectDisplayValueForBreakdownValues(breakdownValues: readonly string[]) {
const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id')
if (versionBreakdownIndex === -1 || selectedBreakdowns.includes('project')) {
if (
versionBreakdownIndex === -1 ||
selectedBreakdowns.includes('project') ||
breakdownGroup?.breakdown === 'version_id'
) {
return ''
}
@@ -131,7 +150,7 @@ export function buildAnalyticsTableRows({
function getProjectVersionIdForBreakdownValues(breakdownValues: readonly string[]) {
const versionBreakdownIndex = selectedBreakdowns.indexOf('version_id')
if (versionBreakdownIndex === -1) {
if (versionBreakdownIndex === -1 || breakdownGroup?.breakdown === 'version_id') {
return ''
}
@@ -253,7 +272,12 @@ export function buildAnalyticsTableRows({
createRow(ALL_PROJECTS_BREAKDOWN_VALUE, [])
}
if (!includeDate && selectedBreakdowns.length === 1 && selectedBreakdowns[0] === 'project') {
if (
!includeDate &&
selectedBreakdowns.length === 1 &&
selectedBreakdowns[0] === 'project' &&
breakdownGroup?.breakdown !== 'project'
) {
for (const projectId of selectedProjectIds) {
createRow(projectId, [projectId])
}
@@ -285,10 +309,15 @@ export function buildAnalyticsTableRows({
continue
}
const breakdownValues =
const rawBreakdownValues =
selectedBreakdowns.length === 0
? []
: getAnalyticsBreakdownValues(point, selectedBreakdowns, formatMessage)
const breakdownValues = applyAnalyticsBreakdownGroup(
rawBreakdownValues,
selectedBreakdowns,
breakdownGroup,
)
if (breakdownValues.some((breakdownValue) => breakdownValue === ALL_BREAKDOWN_VALUE)) {
continue
}
@@ -56,7 +56,9 @@
<span class="text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_project="{ row, value }">
<span v-if="isGroupedBreakdown('project')" class="mr-2.5 text-primary">{{ value }}</span>
<ProjectCell
v-else
:label="getProjectCellLabel(value)"
:icon-url="getProjectIconUrl(row.breakdownValues.project)"
:icon-tooltip="getProjectCellLabel(value)"
@@ -78,8 +80,10 @@
<span class="mr-2.5 text-primary">{{ value }}</span>
</template>
<template #cell-breakdown_user_id="{ row, value }">
<span v-if="isGroupedBreakdown('user_id')" class="mr-2.5 text-primary">{{ value }}</span>
<component
:is="getUserPageHref(row.breakdownValues.user_id) ? 'a' : 'span'"
v-else
:href="getUserPageHref(row.breakdownValues.user_id)"
:target="getUserPageHref(row.breakdownValues.user_id) ? '_blank' : undefined"
:rel="getUserPageHref(row.breakdownValues.user_id) ? 'noopener noreferrer' : undefined"
@@ -104,7 +108,11 @@
</component>
</template>
<template #cell-breakdown_dependent_project_download="{ row, value }">
<span v-if="isGroupedBreakdown('dependent_project_download')" class="mr-2.5 text-primary">
{{ value }}
</span>
<ProjectCell
v-else
:label="getProjectCellLabel(value)"
:icon-url="
isMissingDependentProjectValue(row.breakdownValues.dependent_project_download)
@@ -124,8 +132,10 @@
/>
</template>
<template #cell-breakdown_version_id="{ row, value }">
<span v-if="isGroupedBreakdown('version_id')" class="mr-2.5 text-primary">{{ value }}</span>
<component
:is="getVersionPageHref(row.projectVersionId) ? 'a' : 'span'"
v-else
v-tooltip="getVersionProjectName(row.projectVersionId)"
:href="getVersionPageHref(row.projectVersionId)"
:target="getVersionPageHref(row.projectVersionId) ? '_blank' : undefined"
@@ -276,6 +286,7 @@ const {
displayedSelectedGroupBy: selectedGroupBy,
displayedSelectedBreakdowns: selectedBreakdowns,
displayedSelectedFilters: selectedFilters,
resolvedActiveBreakdownGroup,
displayedFetchRequest: fetchRequest,
displayedTimeSlices: timeSlices,
activeStat,
@@ -407,9 +418,11 @@ const emptyTableMessage = computed(() => {
})
const breakdownColumnLabel = computed(() =>
selectedBreakdowns.value.length === 1
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
: formatMessage(analyticsBreakdownMessages.breakdown),
resolvedActiveBreakdownGroup.value
? resolvedActiveBreakdownGroup.value.name
: selectedBreakdowns.value.length === 1
? getAnalyticsTableBreakdownColumnLabel(selectedBreakdowns.value[0], formatMessage)
: formatMessage(analyticsBreakdownMessages.breakdown),
)
const relevantStats = computed(
() =>
@@ -430,6 +443,7 @@ function buildTableRows(mode: AnalyticsTableMode) {
fetchRequest: fetchRequest.value,
timeSlices: timeSlices.value,
selectedBreakdowns: selectedBreakdowns.value,
breakdownGroup: resolvedActiveBreakdownGroup.value,
selectedProjectIds: selectedProjectIdSet.value,
selectedFilters: selectedFilters.value,
dependentProjectTypesById: dependentProjectTypesById.value,
@@ -454,6 +468,7 @@ function buildColumns(includeDate: boolean) {
selectedBreakdowns: selectedBreakdowns.value,
selectedFilters: selectedFilters.value,
showBreakdownColumn: showBreakdownColumn.value,
breakdownGroup: resolvedActiveBreakdownGroup.value,
formatMessage,
getRelevantAnalyticsDashboardStats,
})
@@ -463,6 +478,10 @@ function getProjectIconUrl(projectId: string | undefined) {
return projectId ? projectIconUrlsById.value.get(projectId) : undefined
}
function isGroupedBreakdown(breakdown: string) {
return resolvedActiveBreakdownGroup.value?.breakdown === breakdown
}
function getProjectOrganizationName(projectId: string | undefined) {
return projectId ? projectOrganizationNamesById.value.get(projectId) : undefined
}
@@ -592,6 +611,7 @@ watch(
selectedProjectIds,
selectedGroupBy,
selectedBreakdowns,
resolvedActiveBreakdownGroup,
selectedFilters,
projects,
dependentProjectTypesById,
@@ -0,0 +1,60 @@
import type {
AnalyticsBreakdownGroup,
AnalyticsBreakdownPreset,
} from '~/providers/analytics/analytics'
export const ANALYTICS_BREAKDOWN_GROUP_OTHER_SERIES_ID = '__other__'
const GROUPED_BREAKDOWN_VALUE_PREFIX = '__breakdown_group__:'
export function createAnalyticsBreakdownGroupId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
}
export function getAnalyticsBreakdownGroupValue(groupId: string, seriesId: string): string {
return `${GROUPED_BREAKDOWN_VALUE_PREFIX}${encodeURIComponent(groupId)}:${encodeURIComponent(seriesId)}`
}
export function getAnalyticsBreakdownGroupSeriesId(value: string): string | null {
if (!value.startsWith(GROUPED_BREAKDOWN_VALUE_PREFIX)) return null
const encodedParts = value.slice(GROUPED_BREAKDOWN_VALUE_PREFIX.length).split(':')
if (encodedParts.length !== 2) return null
try {
return decodeURIComponent(encodedParts[1] ?? '') || null
} catch {
return null
}
}
export function getAnalyticsBreakdownGroupSeriesName(
value: string,
group: AnalyticsBreakdownGroup | null | undefined,
otherLabel: string,
): string | undefined {
if (!group) return undefined
const seriesId = getAnalyticsBreakdownGroupSeriesId(value)
if (!seriesId) return undefined
if (seriesId === ANALYTICS_BREAKDOWN_GROUP_OTHER_SERIES_ID) return otherLabel
return group.series.find((series) => series.id === seriesId)?.name
}
export function applyAnalyticsBreakdownGroup(
values: readonly string[],
breakdowns: readonly AnalyticsBreakdownPreset[],
group: AnalyticsBreakdownGroup | null | undefined,
): string[] {
const nextValues = [...values]
if (!group) return nextValues
const breakdownIndex = breakdowns.findIndex((breakdown) => breakdown === group.breakdown)
if (breakdownIndex === -1) return nextValues
const rawValue = nextValues[breakdownIndex]
if (!rawValue) return nextValues
const series = group.series.find((candidate) => candidate.values.includes(rawValue))
nextValues[breakdownIndex] = getAnalyticsBreakdownGroupValue(
group.id,
series?.id ?? ANALYTICS_BREAKDOWN_GROUP_OTHER_SERIES_ID,
)
return nextValues
}
@@ -13,7 +13,7 @@ export const COMBINED_BREAKDOWN_DATASET_ID_PREFIX = 'breakdowns:'
export function getAnalyticsBreakdownValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
selectedBreakdown: AnalyticsBreakdownPreset,
formatMessage: FormatMessage,
_formatMessage: FormatMessage,
): string {
switch (selectedBreakdown) {
case 'none':
@@ -29,13 +29,10 @@ export function getAnalyticsBreakdownValue(
return ALL_BREAKDOWN_VALUE
}
case 'user_agent': {
const downloadSource = normalizeBreakdownValue(
return normalizeBreakdownValue(
'user_agent' in point ? point.user_agent : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
return downloadSource === UNKNOWN_BREAKDOWN_VALUE
? UNKNOWN_BREAKDOWN_VALUE
: getDownloadSourceLabel(downloadSource, formatMessage)
}
case 'download_reason':
return normalizeBreakdownValue(
@@ -0,0 +1,342 @@
<template>
<NewModal
ref="modal"
:header="modalTitle"
max-width="calc(100vw - 2rem)"
width="720px"
:on-hide="resetDraft"
>
<div class="flex flex-col gap-5">
<label class="flex flex-col gap-2 font-semibold text-primary">
{{ formatMessage(analyticsMessages.breakdownGroupsGroupName) }}
<StyledInput
v-model="draftName"
:placeholder="formatMessage(analyticsMessages.breakdownGroupsGroupNamePlaceholder)"
/>
</label>
<div class="flex flex-col gap-3">
<div
v-for="(series, seriesIndex) in draftSeries"
:key="series.id"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-surface-2 p-4"
>
<div class="flex items-end gap-2">
<label class="flex min-w-0 flex-1 flex-col gap-2 font-semibold text-primary">
{{ formatMessage(analyticsMessages.breakdownGroupsSeriesName) }}
<StyledInput v-model="series.name" />
</label>
<ButtonStyled circular type="transparent">
<button
type="button"
:aria-label="formatMessage(analyticsMessages.breakdownGroupsRemoveSeries)"
@click="removeSeries(seriesIndex)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
<label class="flex flex-col gap-2 font-semibold text-primary">
{{ formatMessage(analyticsMessages.breakdownGroupsSeriesValues) }}
<MultiSelect
v-model="series.values"
:options="getSeriesOptions(series.id)"
:placeholder="formatMessage(analyticsMessages.breakdownGroupsSelectValues)"
searchable
fuzzy-search
:clearable="false"
checkbox-position="right"
:max-tag-rows="2"
>
<template #input-content="{ isOpen, selectedOptions }">
<div class="flex min-h-8 min-w-0 flex-1 items-center gap-2">
<span
class="min-w-0 flex-1 truncate font-medium"
:class="selectedOptions.length === 0 ? 'text-secondary' : 'text-primary'"
>
{{
selectedOptions.length === 0
? formatMessage(analyticsMessages.breakdownGroupsSelectValues)
: selectedOptions.map((option) => option.label).join(', ')
}}
</span>
<ChevronLeftIcon
class="size-5 shrink-0 text-secondary transition-transform duration-150"
:class="isOpen ? 'rotate-90' : '-rotate-90'"
/>
</div>
</template>
</MultiSelect>
</label>
</div>
<ButtonStyled type="transparent" class="w-fit">
<button type="button" @click="addSeries">
<PlusIcon />
{{ formatMessage(analyticsMessages.breakdownGroupsAddSeries) }}
</button>
</ButtonStyled>
</div>
<div class="rounded-xl border border-solid border-surface-5 bg-surface-2 p-4">
<div class="font-semibold text-contrast">{{ formatMessage(analyticsMessages.other) }}</div>
<div class="mt-1 text-sm text-secondary">
{{
formatMessage(analyticsMessages.breakdownGroupsOtherDescription, {
count: unassignedValueCount,
})
}}
</div>
</div>
<p v-if="validationMessage" class="m-0 text-sm font-semibold text-red">
{{ validationMessage }}
</p>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent">
<button type="button" @click="modal?.hide()">
{{ formatMessage(analyticsMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="Boolean(validationMessage)" @click="save">
{{ formatMessage(analyticsMessages.saveButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
MultiSelect,
type MultiSelectOption,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { createAnalyticsBreakdownGroupId } from '~/components/analytics-dashboard/breakdown-groups'
import {
type AnalyticsBreakdownGroup,
type AnalyticsBreakdownGroupSeries,
type AnalyticsBreakdownPreset,
doesAnalyticsPointMatchNormalizedFilters,
getProjectIdsMatchingStatusFilter,
injectAnalyticsDashboardContext,
normalizeAnalyticsSelectedFilters,
} from '~/providers/analytics/analytics'
import { formatBreakdownLabel } from '../analytics-chart/analytics-chart-utils'
import { analyticsMessages } from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
getAnalyticsBreakdownValue,
isNoDependentAnalyticsBreakdownValue,
isUnknownAnalyticsBreakdownValue,
} from '../breakdown'
type Breakdown = Exclude<AnalyticsBreakdownPreset, 'none'>
const emit = defineEmits<{
save: [group: AnalyticsBreakdownGroup, activate: boolean]
}>()
const {
breakdownGroups,
displayedTimeSlices,
displayedSelectedProjectIds,
displayedSelectedFilters,
projectStatusById,
dependentProjectTypesById,
projectNamesById,
userNamesById,
getVersionDisplayName,
} = injectAnalyticsDashboardContext()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const editingGroupId = ref<string | null>(null)
const draftBreakdown = ref<Breakdown>('country')
const draftName = ref('')
const draftSeries = ref<AnalyticsBreakdownGroupSeries[]>([])
const modalTitle = computed(() =>
formatMessage(
editingGroupId.value
? analyticsMessages.breakdownGroupsModalEditTitle
: analyticsMessages.breakdownGroupsModalCreateTitle,
),
)
const availableValueSet = computed(
() => new Set(availableValues.value.map((option) => option.value)),
)
const availableValues = computed<MultiSelectOption<string>[]>(() => {
const projectIds = new Set(
getProjectIdsMatchingStatusFilter(
displayedSelectedProjectIds.value,
projectStatusById.value,
displayedSelectedFilters.value,
),
)
const normalizedFilters = normalizeAnalyticsSelectedFilters(displayedSelectedFilters.value)
const values = new Set<string>()
for (const slice of displayedTimeSlices.value) {
for (const point of slice) {
if (!isProjectAnalyticsPoint(point) || !projectIds.has(point.source_project)) continue
if (
!doesAnalyticsPointMatchNormalizedFilters(
point,
normalizedFilters,
dependentProjectTypesById.value,
)
) {
continue
}
const value = getAnalyticsBreakdownValue(point, draftBreakdown.value, formatMessage)
if (value !== ALL_BREAKDOWN_VALUE) values.add(value)
}
}
return [...values]
.map((value) => ({ value, label: getValueLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label))
})
const allDraftValues = computed(() => draftSeries.value.flatMap((series) => series.values))
const unassignedValueCount = computed(() => {
const assignedValues = new Set(allDraftValues.value)
return availableValues.value.filter((option) => !assignedValues.has(option.value)).length
})
const validationMessage = computed(() => {
const normalizedName = draftName.value.trim().toLocaleLowerCase()
if (!normalizedName) return formatMessage(analyticsMessages.breakdownGroupsNameRequired)
if (
breakdownGroups.value.some(
(group) =>
group.breakdown === draftBreakdown.value &&
group.id !== editingGroupId.value &&
group.name.trim().toLocaleLowerCase() === normalizedName,
)
) {
return formatMessage(analyticsMessages.breakdownGroupsNameDuplicate)
}
if (draftSeries.value.length === 0) {
return formatMessage(analyticsMessages.breakdownGroupsSeriesRequired)
}
const names = draftSeries.value.map((series) => series.name.trim().toLocaleLowerCase())
if (names.some((name) => !name)) {
return formatMessage(analyticsMessages.breakdownGroupsSeriesNameRequired)
}
if (new Set(names).size !== names.length) {
return formatMessage(analyticsMessages.breakdownGroupsSeriesNameDuplicate)
}
if (draftSeries.value.some((series) => series.values.length === 0)) {
return formatMessage(analyticsMessages.breakdownGroupsSeriesValuesRequired)
}
return ''
})
function isProjectAnalyticsPoint(
point: Labrinth.Analytics.v3.AnalyticsData,
): point is Labrinth.Analytics.v3.ProjectAnalytics {
return 'source_project' in point
}
function getValueLabel(value: string) {
if (draftBreakdown.value === 'project' || draftBreakdown.value === 'dependent_project_download') {
if (isNoDependentAnalyticsBreakdownValue(value)) {
return formatMessage(analyticsMessages.noDependent)
}
if (!isUnknownAnalyticsBreakdownValue(value)) {
return projectNamesById.value.get(value) ?? value
}
}
return formatBreakdownLabel(
value,
draftBreakdown.value,
getVersionDisplayName,
userNamesById.value,
formatMessage,
)
}
function getSeriesOptions(seriesId: string): MultiSelectOption<string>[] {
const selectedByOtherSeries = new Set(
draftSeries.value.filter((series) => series.id !== seriesId).flatMap((series) => series.values),
)
const currentSeries = draftSeries.value.find((series) => series.id === seriesId)
const unavailableValues = (currentSeries?.values ?? []).filter(
(value) => !availableValueSet.value.has(value),
)
return [
...availableValues.value.map((option) => ({
...option,
disabled: selectedByOtherSeries.has(option.value),
})),
...unavailableValues.map((value) => ({
value,
label: formatMessage(analyticsMessages.breakdownGroupsUnavailableValue, {
value: getValueLabel(value),
}),
})),
]
}
function createEmptySeries(): AnalyticsBreakdownGroupSeries {
return { id: createAnalyticsBreakdownGroupId(), name: '', values: [] }
}
function addSeries() {
draftSeries.value.push(createEmptySeries())
}
function removeSeries(index: number) {
draftSeries.value.splice(index, 1)
}
function show(breakdown: Breakdown, group?: AnalyticsBreakdownGroup, event?: MouseEvent) {
draftBreakdown.value = breakdown
editingGroupId.value = group?.id ?? null
draftName.value = group?.name ?? ''
draftSeries.value = group
? group.series.map((series) => ({ ...series, values: [...series.values] }))
: [createEmptySeries()]
modal.value?.show(event)
}
function resetDraft() {
editingGroupId.value = null
draftName.value = ''
draftSeries.value = []
}
function save() {
if (validationMessage.value) return
const isNewGroup = editingGroupId.value === null
emit(
'save',
{
id: editingGroupId.value ?? createAnalyticsBreakdownGroupId(),
name: draftName.value.trim(),
breakdown: draftBreakdown.value,
series: draftSeries.value.map((series) => ({
id: series.id,
name: series.name.trim(),
values: [...new Set(series.values)],
})),
},
isNewGroup,
)
modal.value?.hide()
}
defineExpose({ show })
</script>
@@ -0,0 +1,330 @@
<template>
<ConfirmModal
ref="deleteModal"
:markdown="false"
:title="deleteTitle"
:description="formatMessage(analyticsMessages.breakdownGroupsDeleteDescription)"
:proceed-label="formatMessage(analyticsMessages.breakdownGroupsDelete)"
@proceed="confirmDelete"
/>
<div ref="optionContent" class="group flex min-w-0 flex-1 items-center gap-2">
<slot></slot>
<ButtonStyled type="outlined" size="small">
<button
class="!shadow-none transition-opacity"
:class="
activeGroup
? undefined
: 'md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100'
"
type="button"
:aria-label="formatMessage(analyticsMessages.breakdownGroupsButton)"
:aria-expanded="isMenuOpen"
aria-haspopup="menu"
@click.stop="toggleMenu"
@keydown.enter.stop
@keydown.space.stop
>
<span class="max-w-32 truncate">
{{ activeGroup?.name ?? formatMessage(analyticsMessages.breakdownGroupsButton) }}
</span>
<ChevronRightIcon />
</button>
</ButtonStyled>
</div>
<Teleport to="#teleports">
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="isMenuOpen"
ref="submenu"
data-multi-select-submenu
class="fixed z-[10000] w-64 overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-4 text-primary shadow-2xl"
:style="submenuStyle"
role="menu"
@mousedown.stop
@click.stop
@keydown.esc.stop="closeMenu"
>
<button
type="button"
class="menu-row"
:class="{ 'text-brand': isNoGroupingActive }"
@click="selectGroup(null)"
>
<span class="min-w-0 flex-1 text-left">
{{ formatMessage(analyticsMessages.breakdownGroupsNoGrouping) }}
</span>
<CheckIcon v-if="isNoGroupingActive" />
</button>
<div
v-for="group in groups"
:key="group.id"
class="group/menu-item flex items-center hover:bg-surface-5"
>
<button
type="button"
class="menu-row min-w-0 flex-1"
:class="{ 'text-brand': isGroupActive(group) }"
@click="selectGroup(group)"
>
<span class="min-w-0 flex-1 truncate text-left">{{ group.name }}</span>
<CheckIcon v-if="isGroupActive(group)" class="shrink-0" />
</button>
<button
type="button"
class="icon-action"
:aria-label="formatMessage(analyticsMessages.breakdownGroupsEdit)"
@click.stop="editGroup(group, $event)"
>
<PencilIcon />
</button>
<button
type="button"
class="icon-action hover:!text-red"
:aria-label="formatMessage(analyticsMessages.breakdownGroupsDelete)"
@click.stop="requestDelete(group, $event)"
>
<TrashIcon />
</button>
</div>
<button
type="button"
class="menu-row border-0 border-t border-solid border-surface-5 text-brand"
@click="createGroup"
>
<PlusIcon />
<span>{{ formatMessage(analyticsMessages.breakdownGroupsCreate) }}</span>
</button>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { CheckIcon, ChevronRightIcon, PencilIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, ConfirmModal, useVIntl } from '@modrinth/ui'
import type {
AnalyticsActiveBreakdownGroup,
AnalyticsBreakdownGroup,
AnalyticsBreakdownPreset,
} from '~/providers/analytics/analytics'
import { injectAnalyticsDashboardContext } from '~/providers/analytics/analytics'
import { analyticsMessages } from '../analytics-messages'
type Breakdown = Exclude<AnalyticsBreakdownPreset, 'none'>
const SUBMENU_GAP = 8
const VIEWPORT_MARGIN = 8
const SUBMENU_WIDTH = 256
const OPEN_SUBMENU_EVENT = 'analytics-breakdown-group-submenu-open'
const props = defineProps<{
breakdown: Breakdown
selected: boolean
activeGroup: AnalyticsActiveBreakdownGroup | null
}>()
const emit = defineEmits<{
activate: [breakdown: Breakdown]
selectGroup: [group: AnalyticsBreakdownGroup | null]
create: [breakdown: Breakdown, event?: MouseEvent]
edit: [group: AnalyticsBreakdownGroup, event?: MouseEvent]
}>()
const { breakdownGroups, deleteBreakdownGroup } = injectAnalyticsDashboardContext()
const { formatMessage } = useVIntl()
const optionContent = ref<HTMLElement | null>(null)
const submenu = ref<HTMLElement | null>(null)
const isMenuOpen = ref(false)
const submenuStyle = ref({ left: '0px', top: '0px' })
const deleteModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const pendingDeleteGroup = ref<AnalyticsBreakdownGroup | null>(null)
const menuId = Symbol('breakdown-group-submenu')
const groups = computed(() =>
breakdownGroups.value.filter((group) => group.breakdown === props.breakdown),
)
const activeGroup = computed(() => {
if (!props.selected || props.activeGroup?.breakdown !== props.breakdown) return null
return groups.value.find((group) => group.id === props.activeGroup?.groupId) ?? null
})
const isNoGroupingActive = computed(() => props.selected && activeGroup.value === null)
const deleteTitle = computed(() =>
formatMessage(analyticsMessages.breakdownGroupsDeleteTitle, {
name: pendingDeleteGroup.value?.name ?? '',
}),
)
function isGroupActive(group: AnalyticsBreakdownGroup) {
return activeGroup.value?.id === group.id
}
function selectGroup(group: AnalyticsBreakdownGroup | null) {
emit('selectGroup', group)
closeMenu()
}
function openMenu() {
if (!isMenuOpen.value) {
window.dispatchEvent(new CustomEvent(OPEN_SUBMENU_EVENT, { detail: menuId }))
isMenuOpen.value = true
}
updateMenuPosition()
nextTick(updateMenuPosition)
}
function closeMenu() {
isMenuOpen.value = false
}
function toggleMenu() {
if (isMenuOpen.value) {
closeMenu()
} else {
openMenu()
}
}
function updateMenuPosition() {
if (!isMenuOpen.value || typeof window === 'undefined') return
const anchor = optionContent.value?.closest<HTMLElement>('[role="option"]') ?? optionContent.value
if (!anchor) return
const anchorRect = anchor.getBoundingClientRect()
const submenuRect = submenu.value?.getBoundingClientRect()
const submenuWidth = submenuRect?.width ?? SUBMENU_WIDTH
const submenuHeight = submenuRect?.height ?? 320
const viewport = window.visualViewport
const viewportLeft = viewport?.offsetLeft ?? 0
const viewportTop = viewport?.offsetTop ?? 0
const viewportRight = viewportLeft + (viewport?.width ?? window.innerWidth)
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight)
const anchorLeft = anchorRect.left + viewportLeft
const anchorRight = anchorRect.right + viewportLeft
const anchorTop = anchorRect.top + viewportTop
const opensRight =
anchorRight + SUBMENU_GAP + submenuWidth + VIEWPORT_MARGIN <= viewportRight ||
anchorLeft - submenuWidth - SUBMENU_GAP < viewportLeft + VIEWPORT_MARGIN
const preferredLeft = opensRight
? anchorRight + SUBMENU_GAP
: anchorLeft - submenuWidth - SUBMENU_GAP
const maxLeft = Math.max(
viewportLeft + VIEWPORT_MARGIN,
viewportRight - submenuWidth - VIEWPORT_MARGIN,
)
const maxTop = Math.max(
viewportTop + VIEWPORT_MARGIN,
viewportBottom - submenuHeight - VIEWPORT_MARGIN,
)
submenuStyle.value = {
left: `${Math.min(Math.max(viewportLeft + VIEWPORT_MARGIN, preferredLeft), maxLeft)}px`,
top: `${Math.min(Math.max(viewportTop + VIEWPORT_MARGIN, anchorTop), maxTop)}px`,
}
}
function handleOtherSubmenuOpen(event: Event) {
if ((event as CustomEvent<symbol>).detail !== menuId) closeMenu()
}
function handleDocumentMouseDown(event: MouseEvent) {
const target = event.target
if (
isMenuOpen.value &&
target instanceof Node &&
!optionContent.value?.contains(target) &&
!submenu.value?.contains(target)
) {
closeMenu()
}
}
function createGroup(event: MouseEvent) {
closeMenu()
emit('activate', props.breakdown)
emit('create', props.breakdown, event)
}
function editGroup(group: AnalyticsBreakdownGroup, event: MouseEvent) {
closeMenu()
emit('edit', group, event)
}
function requestDelete(group: AnalyticsBreakdownGroup, event: MouseEvent) {
pendingDeleteGroup.value = group
closeMenu()
deleteModal.value?.show(event)
}
function confirmDelete() {
if (pendingDeleteGroup.value) {
if (isGroupActive(pendingDeleteGroup.value)) emit('selectGroup', null)
deleteBreakdownGroup(pendingDeleteGroup.value.id)
}
pendingDeleteGroup.value = null
}
onMounted(() => {
window.addEventListener(OPEN_SUBMENU_EVENT, handleOtherSubmenuOpen)
window.addEventListener('resize', updateMenuPosition)
window.addEventListener('scroll', updateMenuPosition, true)
document.addEventListener('mousedown', handleDocumentMouseDown)
})
onBeforeUnmount(() => {
window.removeEventListener(OPEN_SUBMENU_EVENT, handleOtherSubmenuOpen)
window.removeEventListener('resize', updateMenuPosition)
window.removeEventListener('scroll', updateMenuPosition, true)
document.removeEventListener('mousedown', handleDocumentMouseDown)
})
</script>
<style scoped>
.menu-row {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
border: 0;
background: transparent;
padding: 0.75rem 1rem;
color: inherit;
box-shadow: none;
}
.menu-row:hover,
.menu-row:focus-visible {
background: var(--surface-5);
}
.menu-row svg,
.icon-action svg {
width: 1.25rem;
height: 1.25rem;
}
.icon-action {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0.5rem;
color: var(--color-secondary);
box-shadow: none;
}
.icon-action:hover,
.icon-action:focus-visible {
color: var(--color-contrast);
}
</style>
@@ -1,5 +1,6 @@
<template>
<div class="md:pl-1">
<BreakdownGroupModal ref="breakdownGroupModal" @save="saveBreakdownGroup" />
<div class="flex flex-wrap items-center gap-2 md:hidden">
<MultiSelect
v-if="showProjectRow"
@@ -156,8 +157,8 @@
class="min-w-0 max-w-full"
:options="breakdownOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
:dropdown-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:dropdown-min-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:dropdown-width="'20rem'"
:dropdown-min-width="'20rem'"
:trigger-class="analyticsQueryChipTriggerClass"
fit-content
checkbox-position="right"
@@ -183,6 +184,24 @@
/>
</div>
</template>
<template #option="{ item, selected }">
<BreakdownGroupOptionMenu
:breakdown="item.value"
:selected="selected"
:active-group="draftActiveBreakdownGroup"
@activate="ensureBreakdownSelected"
@select-group="selectBreakdownGroup(item.value, $event)"
@create="openCreateBreakdownGroup"
@edit="openEditBreakdownGroup"
>
<span
class="min-w-0 flex-1 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</BreakdownGroupOptionMenu>
</template>
</MultiSelect>
<QueryBuilderFilter
@@ -401,8 +420,8 @@
v-model="selectedBreakdownValue"
:options="breakdownOptions"
:max-height="QUERY_BUILDER_DROPDOWN_MAX_HEIGHT"
:dropdown-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:dropdown-min-width="QUERY_BUILDER_DROPDOWN_MIN_WIDTH"
:dropdown-width="'24rem'"
:dropdown-min-width="'24rem'"
checkbox-position="right"
:placeholder="formatMessage(analyticsMessages.none)"
show-selection-actions
@@ -429,6 +448,24 @@
/>
</div>
</template>
<template #option="{ item, selected }">
<BreakdownGroupOptionMenu
:breakdown="item.value"
:selected="selected"
:active-group="draftActiveBreakdownGroup"
@activate="ensureBreakdownSelected"
@select-group="selectBreakdownGroup(item.value, $event)"
@create="openCreateBreakdownGroup"
@edit="openEditBreakdownGroup"
>
<span
class="min-w-0 flex-1 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</BreakdownGroupOptionMenu>
</template>
</MultiSelect>
</div>
</div>
@@ -469,6 +506,8 @@ import {
MAX_ANALYTICS_BREAKDOWN_PRESETS,
} from '~/components/analytics-dashboard/analytics-route-query'
import {
type AnalyticsActiveBreakdownGroup,
type AnalyticsBreakdownGroup,
type AnalyticsBreakdownPreset,
type AnalyticsDashboardProject,
type AnalyticsDashboardStat,
@@ -486,6 +525,8 @@ import {
formatAnalyticsGroupByLabel,
formatAnalyticsGroupBySelectedLabel,
} from '../analytics-messages.ts'
import BreakdownGroupModal from './BreakdownGroupModal.vue'
import BreakdownGroupOptionMenu from './BreakdownGroupOptionMenu.vue'
import DownloadsThresholdInput from './DownloadsThresholdInput.vue'
import {
getAnalyticsStatsForBreakdown,
@@ -526,6 +567,7 @@ const {
selectedCustomTimeframeEndDate,
selectedGroupBy,
selectedBreakdowns,
activeBreakdownGroup,
selectedFilters,
activeStat,
showPreviousPeriod,
@@ -534,11 +576,37 @@ const {
queryResetToken,
refreshAnalyticsQuery,
setFetchRequest,
upsertBreakdownGroup,
setActiveBreakdownGroup,
} = injectAnalyticsDashboardContext()
const route = useRoute()
const { formatMessage } = useVIntl()
const { selectedTimeRange, selectedTimeframeDurationMinutes } = useSelectedAnalyticsTimeRange()
const defaultQueryState = buildDefaultAnalyticsQueryBuilderState([])
const breakdownGroupModal = ref<InstanceType<typeof BreakdownGroupModal> | null>(null)
function openCreateBreakdownGroup(
breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>,
event?: MouseEvent,
) {
breakdownGroupModal.value?.show(breakdown, undefined, event)
}
function openEditBreakdownGroup(group: AnalyticsBreakdownGroup, event?: MouseEvent) {
breakdownGroupModal.value?.show(group.breakdown, group, event)
}
function saveBreakdownGroup(group: AnalyticsBreakdownGroup, activate: boolean) {
upsertBreakdownGroup(group)
if (activate) {
setActiveBreakdownGroup({ breakdown: group.breakdown, groupId: group.id })
}
}
function ensureBreakdownSelected(breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>) {
if (selectedBreakdownValue.value.includes(breakdown)) return
selectedBreakdownValue.value = [...selectedBreakdownValue.value, breakdown]
}
function getProjectOption(
project: AnalyticsDashboardProject,
@@ -810,15 +878,25 @@ function selectUserProjectsMode() {
}
const draftSelectedBreakdowns = ref<AnalyticsSelectedBreakdowns>([...selectedBreakdowns.value])
const draftActiveBreakdownGroup = ref<AnalyticsActiveBreakdownGroup | null>(
activeBreakdownGroup.value ? { ...activeBreakdownGroup.value } : null,
)
const isBreakdownSelectOpen = ref(false)
const selectedBreakdownValue = computed<AnalyticsSelectedBreakdowns>({
get: () => draftSelectedBreakdowns.value,
set: (nextBreakdowns) => {
draftSelectedBreakdowns.value = getAnalyticsBreakdownPresetsForProjectSelection(
const normalizedBreakdowns = getAnalyticsBreakdownPresetsForProjectSelection(
nextBreakdowns.slice(0, MAX_ANALYTICS_BREAKDOWN_PRESETS),
selectedProjectIds.value,
)
draftSelectedBreakdowns.value = normalizedBreakdowns
if (
draftActiveBreakdownGroup.value &&
!normalizedBreakdowns.includes(draftActiveBreakdownGroup.value.breakdown)
) {
draftActiveBreakdownGroup.value = null
}
},
})
@@ -829,14 +907,23 @@ watch(selectedBreakdowns, (nextBreakdowns) => {
draftSelectedBreakdowns.value = [...nextBreakdowns]
})
watch(activeBreakdownGroup, (nextActiveGroup) => {
if (isBreakdownSelectOpen.value) return
draftActiveBreakdownGroup.value = nextActiveGroup ? { ...nextActiveGroup } : null
})
function handleBreakdownSelectOpen() {
isBreakdownSelectOpen.value = true
draftSelectedBreakdowns.value = [...selectedBreakdowns.value]
draftActiveBreakdownGroup.value = activeBreakdownGroup.value
? { ...activeBreakdownGroup.value }
: null
}
function handleBreakdownSelectClose() {
isBreakdownSelectOpen.value = false
commitDraftSelectedBreakdowns()
commitDraftActiveBreakdownGroup()
}
function commitDraftSelectedBreakdowns() {
@@ -847,6 +934,27 @@ function commitDraftSelectedBreakdowns() {
}
}
function commitDraftActiveBreakdownGroup() {
const currentGroup = activeBreakdownGroup.value
const nextGroup = draftActiveBreakdownGroup.value
if (
currentGroup?.breakdown === nextGroup?.breakdown &&
currentGroup?.groupId === nextGroup?.groupId
) {
return
}
showPreviousPeriod.value = false
setActiveBreakdownGroup(nextGroup ? { ...nextGroup } : null)
}
function selectBreakdownGroup(
breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>,
group: AnalyticsBreakdownGroup | null,
) {
ensureBreakdownSelected(breakdown)
draftActiveBreakdownGroup.value = group ? { breakdown, groupId: group.id } : null
}
function areSelectedBreakdownsEqual(
left: readonly AnalyticsBreakdownPreset[],
right: readonly AnalyticsBreakdownPreset[],
@@ -20,6 +20,81 @@
"analytics.action.reset": {
"message": "Reset"
},
"analytics.action.save": {
"message": "Save"
},
"analytics.breakdown-groups.add-series": {
"message": "Add series"
},
"analytics.breakdown-groups.button": {
"message": "Group series"
},
"analytics.breakdown-groups.create": {
"message": "Create group"
},
"analytics.breakdown-groups.delete": {
"message": "Delete group"
},
"analytics.breakdown-groups.delete-description": {
"message": "This saved breakdown group will be permanently deleted from this browser."
},
"analytics.breakdown-groups.delete-title": {
"message": "Delete {name}?"
},
"analytics.breakdown-groups.edit": {
"message": "Edit group"
},
"analytics.breakdown-groups.group-name": {
"message": "Group name"
},
"analytics.breakdown-groups.group-name-placeholder": {
"message": "For example, Platform families"
},
"analytics.breakdown-groups.modal.create-title": {
"message": "Create breakdown group"
},
"analytics.breakdown-groups.modal.edit-title": {
"message": "Edit breakdown group"
},
"analytics.breakdown-groups.none": {
"message": "No grouping"
},
"analytics.breakdown-groups.other-description": {
"message": "{count, plural, one {# unassigned value} other {# unassigned values}}"
},
"analytics.breakdown-groups.remove-series": {
"message": "Remove series"
},
"analytics.breakdown-groups.select-values": {
"message": "Select values"
},
"analytics.breakdown-groups.series-name": {
"message": "Series name"
},
"analytics.breakdown-groups.series-values": {
"message": "Breakdown values"
},
"analytics.breakdown-groups.unavailable-value": {
"message": "{value} (not in current results)"
},
"analytics.breakdown-groups.validation.name-duplicate": {
"message": "A group with this name already exists for this breakdown."
},
"analytics.breakdown-groups.validation.name-required": {
"message": "Enter a group name."
},
"analytics.breakdown-groups.validation.series-name-duplicate": {
"message": "Custom series names must be unique."
},
"analytics.breakdown-groups.validation.series-name-required": {
"message": "Every custom series needs a name."
},
"analytics.breakdown-groups.validation.series-required": {
"message": "Add at least one custom series."
},
"analytics.breakdown-groups.validation.series-values-required": {
"message": "Assign at least one breakdown value to every custom series."
},
"analytics.breakdown.country": {
"message": "Country"
},
@@ -47,6 +47,21 @@ export type AnalyticsBreakdownPreset =
| 'dependent_project_download'
export type AnalyticsSelectedBreakdowns = Exclude<AnalyticsBreakdownPreset, 'none'>[]
export type AnalyticsBreakdownGroupSeries = {
id: string
name: string
values: string[]
}
export type AnalyticsBreakdownGroup = {
id: string
name: string
breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>
series: AnalyticsBreakdownGroupSeries[]
}
export type AnalyticsActiveBreakdownGroup = {
breakdown: Exclude<AnalyticsBreakdownPreset, 'none'>
groupId: string
}
export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime'
export type AnalyticsGraphViewMode = 'line' | 'area' | 'bar'
export type AnalyticsTableSortColumn =
@@ -80,6 +80,8 @@ import {
UNKNOWN_ORGANIZATION_NAME,
} from './analytics-project-utils'
import type {
AnalyticsActiveBreakdownGroup,
AnalyticsBreakdownGroup,
AnalyticsBreakdownPreset,
AnalyticsDashboardFilterOptions,
AnalyticsDashboardPercentChanges,
@@ -109,6 +111,9 @@ export {
getProjectIdsMatchingStatusFilter,
} from './analytics-project-utils'
export type {
AnalyticsActiveBreakdownGroup,
AnalyticsBreakdownGroup,
AnalyticsBreakdownGroupSeries,
AnalyticsBreakdownPreset,
AnalyticsDashboardFilterOptions,
AnalyticsDashboardPercentChanges,
@@ -186,6 +191,12 @@ export interface AnalyticsDashboardContextValue {
selectedGroupBy: Ref<AnalyticsGroupByPreset>
analyticsAllTimeStartDate: ComputedRef<Date>
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
breakdownGroups: Ref<AnalyticsBreakdownGroup[]>
activeBreakdownGroup: Ref<AnalyticsActiveBreakdownGroup | null>
resolvedActiveBreakdownGroup: ComputedRef<AnalyticsBreakdownGroup | null>
upsertBreakdownGroup: (group: AnalyticsBreakdownGroup) => void
deleteBreakdownGroup: (groupId: string) => void
setActiveBreakdownGroup: (activeGroup: AnalyticsActiveBreakdownGroup | null) => void
selectedFilters: Ref<AnalyticsSelectedFilters>
queryRefreshTimestamp: Ref<number>
queryResetToken: Ref<number>
@@ -315,6 +326,8 @@ export function createAnalyticsDashboardContext(
)
const selectedGroupBy = ref<AnalyticsGroupByPreset>(initialQueryState.selectedGroupBy)
const selectedBreakdowns = ref<AnalyticsSelectedBreakdowns>(initialQueryState.selectedBreakdowns)
const breakdownGroups = ref<AnalyticsBreakdownGroup[]>([])
const activeBreakdownGroup = ref<AnalyticsActiveBreakdownGroup | null>(null)
const selectedFilters = ref<AnalyticsSelectedFilters>(initialQueryState.selectedFilters)
const queryRefreshTimestamp = ref(Date.now())
const queryResetToken = ref(0)
@@ -324,6 +337,64 @@ export function createAnalyticsDashboardContext(
let revenueHourlyGroupByBeforeOverride: AnalyticsGroupByPreset | null = null
let mobileLayoutMedia: MediaQueryList | null = null
const resolvedActiveBreakdownGroup = computed<AnalyticsBreakdownGroup | null>(() => {
const activeGroup = activeBreakdownGroup.value
if (!activeGroup || !selectedBreakdowns.value.includes(activeGroup.breakdown)) return null
return (
breakdownGroups.value.find(
(group) => group.id === activeGroup.groupId && group.breakdown === activeGroup.breakdown,
) ?? null
)
})
function resetGraphDatasetSelection() {
hiddenGraphDatasetIds.value = []
hasExplicitGraphDatasetSelection.value = false
selectedGraphDatasetIds.value = []
}
function setActiveBreakdownGroup(nextActiveGroup: AnalyticsActiveBreakdownGroup | null) {
const current = activeBreakdownGroup.value
if (
current?.breakdown === nextActiveGroup?.breakdown &&
current?.groupId === nextActiveGroup?.groupId
) {
return
}
activeBreakdownGroup.value = nextActiveGroup
resetGraphDatasetSelection()
}
function upsertBreakdownGroup(group: AnalyticsBreakdownGroup) {
const groupIndex = breakdownGroups.value.findIndex((candidate) => candidate.id === group.id)
if (groupIndex === -1) {
breakdownGroups.value = [...breakdownGroups.value, group]
} else {
const nextGroups = [...breakdownGroups.value]
nextGroups[groupIndex] = group
breakdownGroups.value = nextGroups
}
if (activeBreakdownGroup.value?.groupId === group.id) {
resetGraphDatasetSelection()
}
}
function deleteBreakdownGroup(groupId: string) {
breakdownGroups.value = breakdownGroups.value.filter((group) => group.id !== groupId)
if (activeBreakdownGroup.value?.groupId === groupId) {
setActiveBreakdownGroup(null)
}
}
watch(selectedBreakdowns, (nextBreakdowns) => {
if (
activeBreakdownGroup.value &&
!nextBreakdowns.includes(activeBreakdownGroup.value.breakdown)
) {
setActiveBreakdownGroup(null)
}
})
function syncMobileLayoutState() {
isMobileLayout.value = mobileLayoutMedia?.matches ?? false
}
@@ -707,7 +778,7 @@ export function createAnalyticsDashboardContext(
selectedProjectIds.value,
)
return isQueryBuilderDefault && isGraphDefault
return isQueryBuilderDefault && isGraphDefault && activeBreakdownGroup.value === null
})
const isRevenueTimeframeAvailable = computed(
() =>
@@ -1867,6 +1938,7 @@ export function createAnalyticsDashboardContext(
selectedCustomTimeframeEndDate.value = defaultQueryState.selectedCustomTimeframeEndDate
selectedGroupBy.value = defaultQueryState.selectedGroupBy
selectedBreakdowns.value = defaultQueryState.selectedBreakdowns
setActiveBreakdownGroup(null)
selectedFilters.value = defaultQueryState.selectedFilters
activeStat.value = defaultGraphState.activeStat
activeGraphViewMode.value = defaultGraphState.activeGraphViewMode
@@ -1940,6 +2012,12 @@ export function createAnalyticsDashboardContext(
selectedGroupBy,
analyticsAllTimeStartDate,
selectedBreakdowns,
breakdownGroups,
activeBreakdownGroup,
resolvedActiveBreakdownGroup,
upsertBreakdownGroup,
deleteBreakdownGroup,
setActiveBreakdownGroup,
selectedFilters,
queryRefreshTimestamp,
queryResetToken,
@@ -1205,6 +1205,7 @@ function handleDropdownKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Escape':
event.preventDefault()
event.stopPropagation()
closeDropdown()
break
case 'ArrowDown':
@@ -1355,7 +1356,7 @@ onClickOutside(
() => {
closeDropdown()
},
{ ignore: [triggerRef, containerRef, '.v-popper__popper'] },
{ ignore: [triggerRef, containerRef, '.v-popper__popper', '[data-multi-select-submenu]'] },
)
onMounted(() => {