mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78c88ce18 |
+31
-8
@@ -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],
|
||||
|
||||
+6
@@ -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',
|
||||
|
||||
+7
-1
@@ -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,
|
||||
})
|
||||
|
||||
+33
-4
@@ -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(
|
||||
|
||||
+342
@@ -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>
|
||||
+330
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user