diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts index 84144f344e..5c42fbd129 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts @@ -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], diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts index 482c879002..9cd15c9c9c 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/use-analytics-chart-datasets.ts @@ -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>(() => @@ -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 for (const stat of ANALYTICS_DASHBOARD_STATS) { @@ -401,6 +406,7 @@ function buildDatasetsByStat( getVersionProjectName, formatMessage, sliceCount, + breakdownGroup, ) } return datasetsByStat diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-messages.ts b/apps/frontend/src/components/analytics-dashboard/analytics-messages.ts index 49a0b7ea27..d3bf81b33a 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-messages.ts +++ b/apps/frontend/src/components/analytics-dashboard/analytics-messages.ts @@ -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', diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-columns.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-columns.ts index 21cae3cd79..f69574febe 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-columns.ts +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-columns.ts @@ -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[] { @@ -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, }) diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts index a4fe46007c..3cebe494e2 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts @@ -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 selectedFilters: AnalyticsSelectedFilters dependentProjectTypesById: ReadonlyMap @@ -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 } diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/index.vue b/apps/frontend/src/components/analytics-dashboard/analytics-table/index.vue index 2b625b3a9b..b4873c70b9 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-table/index.vue +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/index.vue @@ -56,7 +56,9 @@ {{ value }} + @@ -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 | null>(null) + +function openCreateBreakdownGroup( + breakdown: Exclude, + 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) { + if (selectedBreakdownValue.value.includes(breakdown)) return + selectedBreakdownValue.value = [...selectedBreakdownValue.value, breakdown] +} function getProjectOption( project: AnalyticsDashboardProject, @@ -810,15 +878,25 @@ function selectUserProjectsMode() { } const draftSelectedBreakdowns = ref([...selectedBreakdowns.value]) +const draftActiveBreakdownGroup = ref( + activeBreakdownGroup.value ? { ...activeBreakdownGroup.value } : null, +) const isBreakdownSelectOpen = ref(false) const selectedBreakdownValue = computed({ 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, + group: AnalyticsBreakdownGroup | null, +) { + ensureBreakdownSelected(breakdown) + draftActiveBreakdownGroup.value = group ? { breakdown, groupId: group.id } : null +} + function areSelectedBreakdownsEqual( left: readonly AnalyticsBreakdownPreset[], right: readonly AnalyticsBreakdownPreset[], diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 6073103af0..3a19673b4b 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -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" }, diff --git a/apps/frontend/src/providers/analytics/analytics-types.ts b/apps/frontend/src/providers/analytics/analytics-types.ts index 37d6b2a604..147bdee5df 100644 --- a/apps/frontend/src/providers/analytics/analytics-types.ts +++ b/apps/frontend/src/providers/analytics/analytics-types.ts @@ -47,6 +47,21 @@ export type AnalyticsBreakdownPreset = | 'dependent_project_download' export type AnalyticsSelectedBreakdowns = Exclude[] +export type AnalyticsBreakdownGroupSeries = { + id: string + name: string + values: string[] +} +export type AnalyticsBreakdownGroup = { + id: string + name: string + breakdown: Exclude + series: AnalyticsBreakdownGroupSeries[] +} +export type AnalyticsActiveBreakdownGroup = { + breakdown: Exclude + groupId: string +} export type AnalyticsDashboardStat = 'views' | 'downloads' | 'revenue' | 'playtime' export type AnalyticsGraphViewMode = 'line' | 'area' | 'bar' export type AnalyticsTableSortColumn = diff --git a/apps/frontend/src/providers/analytics/analytics.ts b/apps/frontend/src/providers/analytics/analytics.ts index a623a3913e..7c4089a9b4 100644 --- a/apps/frontend/src/providers/analytics/analytics.ts +++ b/apps/frontend/src/providers/analytics/analytics.ts @@ -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 analyticsAllTimeStartDate: ComputedRef selectedBreakdowns: Ref + breakdownGroups: Ref + activeBreakdownGroup: Ref + resolvedActiveBreakdownGroup: ComputedRef + upsertBreakdownGroup: (group: AnalyticsBreakdownGroup) => void + deleteBreakdownGroup: (groupId: string) => void + setActiveBreakdownGroup: (activeGroup: AnalyticsActiveBreakdownGroup | null) => void selectedFilters: Ref queryRefreshTimestamp: Ref queryResetToken: Ref @@ -315,6 +326,8 @@ export function createAnalyticsDashboardContext( ) const selectedGroupBy = ref(initialQueryState.selectedGroupBy) const selectedBreakdowns = ref(initialQueryState.selectedBreakdowns) + const breakdownGroups = ref([]) + const activeBreakdownGroup = ref(null) const selectedFilters = ref(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(() => { + 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, diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index 25a19b715d..f3a62c54bf 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -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(() => {