diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 48af9e9f2aec..2ebb5fe0185a 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -293,5 +293,6 @@ jobs: - run: mix compile --warnings-as-errors --all-warnings - run: mix format --check-formatted - run: mix deps.unlock --check-unused + - run: mix generate_countries_meta && git diff --exit-code -- assets/data/countries_meta.json - run: mix credo diff --from-git-merge-base origin/master - run: mix dialyzer diff --git a/assets/data/countries_meta.json b/assets/data/countries_meta.json new file mode 100644 index 000000000000..4dd242e0ca49 --- /dev/null +++ b/assets/data/countries_meta.json @@ -0,0 +1,1006 @@ +{ + "YE": [ + "YEM", + "🇾🇪" + ], + "QA": [ + "QAT", + "🇶🇦" + ], + "MR": [ + "MRT", + "🇲🇷" + ], + "CH": [ + "CHE", + "🇨🇭" + ], + "AF": [ + "AFG", + "🇦🇫" + ], + "AO": [ + "AGO", + "🇦🇴" + ], + "BT": [ + "BTN", + "🇧🇹" + ], + "HK": [ + "HKG", + "🇭🇰" + ], + "CU": [ + "CUB", + "🇨🇺" + ], + "HU": [ + "HUN", + "🇭🇺" + ], + "AZ": [ + "AZE", + "🇦🇿" + ], + "SS": [ + "SSD", + "🇸🇸" + ], + "BY": [ + "BLR", + "🇧🇾" + ], + "MA": [ + "MAR", + "🇲🇦" + ], + "MZ": [ + "MOZ", + "🇲🇿" + ], + "TR": [ + "TUR", + "🇹🇷" + ], + "PL": [ + "POL", + "🇵🇱" + ], + "US": [ + "USA", + "🇺🇸" + ], + "SR": [ + "SUR", + "🇸🇷" + ], + "LR": [ + "LBR", + "🇱🇷" + ], + "OM": [ + "OMN", + "🇴🇲" + ], + "MH": [ + "MHL", + "🇲🇭" + ], + "TG": [ + "TGO", + "🇹🇬" + ], + "CW": [ + "CUW", + "🇨🇼" + ], + "AT": [ + "AUT", + "🇦🇹" + ], + "CO": [ + "COL", + "🇨🇴" + ], + "SV": [ + "SLV", + "🇸🇻" + ], + "RE": [ + "REU", + "🇷🇪" + ], + "LI": [ + "LIE", + "🇱🇮" + ], + "PR": [ + "PRI", + "🇵🇷" + ], + "GI": [ + "GIB", + "🇬🇮" + ], + "CV": [ + "CPV", + "🇨🇻" + ], + "KG": [ + "KGZ", + "🇰🇬" + ], + "SK": [ + "SVK", + "🇸🇰" + ], + "LT": [ + "LTU", + "🇱🇹" + ], + "AL": [ + "ALB", + "🇦🇱" + ], + "BL": [ + "BLM", + "🇧🇱" + ], + "FK": [ + "FLK", + "🇫🇰" + ], + "TV": [ + "TUV", + "🇹🇻" + ], + "SJ": [ + "SJM", + "🇸🇯" + ], + "CX": [ + "CXR", + "🇨🇽" + ], + "VG": [ + "VGB", + "🇻🇬" + ], + "VI": [ + "VIR", + "🇻🇮" + ], + "TT": [ + "TTO", + "🇹🇹" + ], + "AI": [ + "AIA", + "🇦🇮" + ], + "GE": [ + "GEO", + "🇬🇪" + ], + "GB": [ + "GBR", + "🇬🇧" + ], + "TW": [ + "TWN", + "🇹🇼" + ], + "BV": [ + "BVT", + "🇧🇻" + ], + "AU": [ + "AUS", + "🇦🇺" + ], + "CD": [ + "COD", + "🇨🇩" + ], + "IL": [ + "ISR", + "🇮🇱" + ], + "FJ": [ + "FJI", + "🇫🇯" + ], + "HM": [ + "HMD", + "🇭🇲" + ], + "MO": [ + "MAC", + "🇲🇴" + ], + "LU": [ + "LUX", + "🇱🇺" + ], + "TH": [ + "THA", + "🇹🇭" + ], + "TZ": [ + "TZA", + "🇹🇿" + ], + "MC": [ + "MCO", + "🇲🇨" + ], + "AD": [ + "AND", + "🇦🇩" + ], + "IQ": [ + "IRQ", + "🇮🇶" + ], + "NI": [ + "NIC", + "🇳🇮" + ], + "KW": [ + "KWT", + "🇰🇼" + ], + "MW": [ + "MWI", + "🇲🇼" + ], + "GH": [ + "GHA", + "🇬🇭" + ], + "DM": [ + "DMA", + "🇩🇲" + ], + "EE": [ + "EST", + "🇪🇪" + ], + "IS": [ + "ISL", + "🇮🇸" + ], + "BM": [ + "BMU", + "🇧🇲" + ], + "EC": [ + "ECU", + "🇪🇨" + ], + "KM": [ + "COM", + "🇰🇲" + ], + "SB": [ + "SLB", + "🇸🇧" + ], + "AE": [ + "ARE", + "🇦🇪" + ], + "CM": [ + "CMR", + "🇨🇲" + ], + "EH": [ + "ESH", + "🇪🇭" + ], + "CC": [ + "CCK", + "🇨🇨" + ], + "AS": [ + "ASM", + "🇦🇸" + ], + "KH": [ + "KHM", + "🇰🇭" + ], + "MD": [ + "MDA", + "🇲🇩" + ], + "NA": [ + "NAM", + "🇳🇦" + ], + "SA": [ + "SAU", + "🇸🇦" + ], + "HN": [ + "HND", + "🇭🇳" + ], + "MK": [ + "MKD", + "🇲🇰" + ], + "LC": [ + "LCA", + "🇱🇨" + ], + "PA": [ + "PAN", + "🇵🇦" + ], + "VC": [ + "VCT", + "🇻🇨" + ], + "TM": [ + "TKM", + "🇹🇲" + ], + "SI": [ + "SVN", + "🇸🇮" + ], + "GQ": [ + "GNQ", + "🇬🇶" + ], + "PH": [ + "PHL", + "🇵🇭" + ], + "CZ": [ + "CZE", + "🇨🇿" + ], + "BO": [ + "BOL", + "🇧🇴" + ], + "SY": [ + "SYR", + "🇸🇾" + ], + "NO": [ + "NOR", + "🇳🇴" + ], + "IM": [ + "IMN", + "🇮🇲" + ], + "SX": [ + "SXM", + "🇸🇽" + ], + "GG": [ + "GGY", + "🇬🇬" + ], + "GW": [ + "GNB", + "🇬🇼" + ], + "SH": [ + "SHN", + "🇸🇭" + ], + "NC": [ + "NCL", + "🇳🇨" + ], + "BE": [ + "BEL", + "🇧🇪" + ], + "JP": [ + "JPN", + "🇯🇵" + ], + "LV": [ + "LVA", + "🇱🇻" + ], + "AM": [ + "ARM", + "🇦🇲" + ], + "SD": [ + "SDN", + "🇸🇩" + ], + "GT": [ + "GTM", + "🇬🇹" + ], + "PY": [ + "PRY", + "🇵🇾" + ], + "MN": [ + "MNG", + "🇲🇳" + ], + "TK": [ + "TKL", + "🇹🇰" + ], + "DZ": [ + "DZA", + "🇩🇿" + ], + "KZ": [ + "KAZ", + "🇰🇿" + ], + "LY": [ + "LBY", + "🇱🇾" + ], + "AW": [ + "ABW", + "🇦🇼" + ], + "UY": [ + "URY", + "🇺🇾" + ], + "GL": [ + "GRL", + "🇬🇱" + ], + "SN": [ + "SEN", + "🇸🇳" + ], + "UM": [ + "UMI", + "🇺🇲" + ], + "JO": [ + "JOR", + "🇯🇴" + ], + "MT": [ + "MLT", + "🇲🇹" + ], + "BS": [ + "BHS", + "🇧🇸" + ], + "BI": [ + "BDI", + "🇧🇮" + ], + "BA": [ + "BIH", + "🇧🇦" + ], + "MQ": [ + "MTQ", + "🇲🇶" + ], + "MU": [ + "MUS", + "🇲🇺" + ], + "MS": [ + "MSR", + "🇲🇸" + ], + "BW": [ + "BWA", + "🇧🇼" + ], + "YT": [ + "MYT", + "🇾🇹" + ], + "PN": [ + "PCN", + "🇵🇳" + ], + "MP": [ + "MNP", + "🇲🇵" + ], + "ML": [ + "MLI", + "🇲🇱" + ], + "BH": [ + "BHR", + "🇧🇭" + ], + "LB": [ + "LBN", + "🇱🇧" + ], + "AR": [ + "ARG", + "🇦🇷" + ], + "PG": [ + "PNG", + "🇵🇬" + ], + "GR": [ + "GRC", + "🇬🇷" + ], + "HT": [ + "HTI", + "🇭🇹" + ], + "WS": [ + "WSM", + "🇼🇸" + ], + "SG": [ + "SGP", + "🇸🇬" + ], + "GP": [ + "GLP", + "🇬🇵" + ], + "BF": [ + "BFA", + "🇧🇫" + ], + "ME": [ + "MNE", + "🇲🇪" + ], + "AQ": [ + "ATA", + "🇦🇶" + ], + "PK": [ + "PAK", + "🇵🇰" + ], + "FM": [ + "FSM", + "🇫🇲" + ], + "MV": [ + "MDV", + "🇲🇻" + ], + "GS": [ + "SGS", + "🇬🇸" + ], + "BN": [ + "BRN", + "🇧🇳" + ], + "CK": [ + "COK", + "🇨🇰" + ], + "IO": [ + "IOT", + "🇮🇴" + ], + "SE": [ + "SWE", + "🇸🇪" + ], + "SC": [ + "SYC", + "🇸🇨" + ], + "ZW": [ + "ZWE", + "🇿🇼" + ], + "SL": [ + "SLE", + "🇸🇱" + ], + "AG": [ + "ATG", + "🇦🇬" + ], + "PF": [ + "PYF", + "🇵🇫" + ], + "CF": [ + "CAF", + "🇨🇫" + ], + "BD": [ + "BGD", + "🇧🇩" + ], + "AX": [ + "ALA", + "🇦🇽" + ], + "SZ": [ + "SWZ", + "🇸🇿" + ], + "HR": [ + "HRV", + "🇭🇷" + ], + "RS": [ + "SRB", + "🇷🇸" + ], + "NF": [ + "NFK", + "🇳🇫" + ], + "IE": [ + "IRL", + "🇮🇪" + ], + "NR": [ + "NRU", + "🇳🇷" + ], + "ZA": [ + "ZAF", + "🇿🇦" + ], + "CA": [ + "CAN", + "🇨🇦" + ], + "KR": [ + "KOR", + "🇰🇷" + ], + "A1": [ + null, + "🏳️" + ], + "VA": [ + "VAT", + "🇻🇦" + ], + "NU": [ + "NIU", + "🇳🇺" + ], + "JE": [ + "JEY", + "🇯🇪" + ], + "TD": [ + "TCD", + "🇹🇩" + ], + "IR": [ + "IRN", + "🇮🇷" + ], + "NL": [ + "NLD", + "🇳🇱" + ], + "BB": [ + "BRB", + "🇧🇧" + ], + "FI": [ + "FIN", + "🇫🇮" + ], + "UA": [ + "UKR", + "🇺🇦" + ], + "ID": [ + "IDN", + "🇮🇩" + ], + "ST": [ + "STP", + "🇸🇹" + ], + "VU": [ + "VUT", + "🇻🇺" + ], + "RU": [ + "RUS", + "🇷🇺" + ], + "NE": [ + "NER", + "🇳🇪" + ], + "TO": [ + "TON", + "🇹🇴" + ], + "UZ": [ + "UZB", + "🇺🇿" + ], + "GN": [ + "GIN", + "🇬🇳" + ], + "JM": [ + "JAM", + "🇯🇲" + ], + "FR": [ + "FRA", + "🇫🇷" + ], + "TL": [ + "TLS", + "🇹🇱" + ], + "ET": [ + "ETH", + "🇪🇹" + ], + "KI": [ + "KIR", + "🇰🇮" + ], + "CG": [ + "COG", + "🇨🇬" + ], + "DE": [ + "DEU", + "🇩🇪" + ], + "RW": [ + "RWA", + "🇷🇼" + ], + "DO": [ + "DOM", + "🇩🇴" + ], + "VE": [ + "VEN", + "🇻🇪" + ], + "PW": [ + "PLW", + "🇵🇼" + ], + "TC": [ + "TCA", + "🇹🇨" + ], + "ZM": [ + "ZMB", + "🇿🇲" + ], + "NG": [ + "NGA", + "🇳🇬" + ], + "WF": [ + "WLF", + "🇼🇫" + ], + "GF": [ + "GUF", + "🇬🇫" + ], + "KN": [ + "KNA", + "🇰🇳" + ], + "ES": [ + "ESP", + "🇪🇸" + ], + "GM": [ + "GMB", + "🇬🇲" + ], + "KP": [ + "PRK", + "🇰🇵" + ], + "GY": [ + "GUY", + "🇬🇾" + ], + "MX": [ + "MEX", + "🇲🇽" + ], + "IN": [ + "IND", + "🇮🇳" + ], + "SM": [ + "SMR", + "🇸🇲" + ], + "BG": [ + "BGR", + "🇧🇬" + ], + "MF": [ + "MAF", + "🇲🇫" + ], + "CL": [ + "CHL", + "🇨🇱" + ], + "VN": [ + "VNM", + "🇻🇳" + ], + "NP": [ + "NPL", + "🇳🇵" + ], + "CR": [ + "CRI", + "🇨🇷" + ], + "ER": [ + "ERI", + "🇪🇷" + ], + "LK": [ + "LKA", + "🇱🇰" + ], + "CI": [ + "CIV", + "🇨🇮" + ], + "PT": [ + "PRT", + "🇵🇹" + ], + "TJ": [ + "TJK", + "🇹🇯" + ], + "MY": [ + "MYS", + "🇲🇾" + ], + "PS": [ + "PSE", + "🇵🇸" + ], + "PE": [ + "PER", + "🇵🇪" + ], + "LS": [ + "LSO", + "🇱🇸" + ], + "CY": [ + "CYP", + "🇨🇾" + ], + "TN": [ + "TUN", + "🇹🇳" + ], + "XK": [ + "XKX", + "🇽🇰" + ], + "KY": [ + "CYM", + "🇰🇾" + ], + "DK": [ + "DNK", + "🇩🇰" + ], + "BZ": [ + "BLZ", + "🇧🇿" + ], + "FO": [ + "FRO", + "🇫🇴" + ], + "LA": [ + "LAO", + "🇱🇦" + ], + "RO": [ + "ROU", + "🇷🇴" + ], + "DJ": [ + "DJI", + "🇩🇯" + ], + "EG": [ + "EGY", + "🇪🇬" + ], + "KE": [ + "KEN", + "🇰🇪" + ], + "UG": [ + "UGA", + "🇺🇬" + ], + "MM": [ + "MMR", + "🇲🇲" + ], + "BQ": [ + "BES", + "🇧🇶" + ], + "PM": [ + "SPM", + "🇵🇲" + ], + "GD": [ + "GRD", + "🇬🇩" + ], + "GU": [ + "GUM", + "🇬🇺" + ], + "CN": [ + "CHN", + "🇨🇳" + ], + "SO": [ + "SOM", + "🇸🇴" + ], + "BJ": [ + "BEN", + "🇧🇯" + ], + "BR": [ + "BRA", + "🇧🇷" + ], + "GA": [ + "GAB", + "🇬🇦" + ], + "NZ": [ + "NZL", + "🇳🇿" + ], + "MG": [ + "MDG", + "🇲🇬" + ], + "IT": [ + "ITA", + "🇮🇹" + ], + "TF": [ + "ATF", + "🇹🇫" + ] +} \ No newline at end of file diff --git a/assets/js/dashboard/components/drilldown-link.tsx b/assets/js/dashboard/components/drilldown-link.tsx index e9f20483a1ef..43171ea0c180 100644 --- a/assets/js/dashboard/components/drilldown-link.tsx +++ b/assets/js/dashboard/components/drilldown-link.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { ReactNode } from 'react' import { AppNavigationLink, AppNavigationLinkProps @@ -20,15 +20,16 @@ export function DrilldownLink({ filterInfo, onClick, children, - extraClass + icon, + className, + textClassName }: Pick & { - extraClass?: string + className?: string + textClassName?: string + icon?: ReactNode filterInfo: FilterInfo | null }) { const { dashboardState } = useDashboardStateContext() - const className = classNames(`${extraClass}`, { - 'hover:underline': !!filterInfo - }) if (filterInfo) { const { prefix, filter, labels } = filterInfo @@ -43,7 +44,7 @@ export function DrilldownLink({ return ( ({ @@ -52,10 +53,18 @@ export function DrilldownLink({ labels: newLabels })} > - {children} + {icon} + + {children} + ) } else { - return {children} + return ( + + {icon} + {children} + + ) } } diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx index b19c9a2aff8c..1e8a1f10d0d8 100644 --- a/assets/js/dashboard/router.tsx +++ b/assets/js/dashboard/router.tsx @@ -15,7 +15,7 @@ import { BREAKDOWN_REPORTS, BreakdownReportKey } from './stats/reports/reports-config' -import LocationsModal from './stats/modals/locations-modal' +import { LocationsDetails } from './stats/locations/details' import PropsModal from './stats/modals/props' import ConversionsModal from './stats/modals/conversions' import FilterModal from './stats/modals/filter-modal' @@ -113,18 +113,18 @@ export const exitPagesRoute = { } export const countriesRoute = { - path: 'countries', - element: + path: BREAKDOWN_REPORTS.countries.detailsPath, + element: } export const regionsRoute = { - path: 'regions', - element: + path: BREAKDOWN_REPORTS.regions.detailsPath, + element: } export const citiesRoute = { - path: 'cities', - element: + path: BREAKDOWN_REPORTS.cities.detailsPath, + element: } export const browsersRoute = { diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 1a0592bda5a0..2e49f3b1f71e 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -55,6 +55,7 @@ export type ReportParams = { include?: Partial order_by?: OrderBy pagination?: Pagination + alwaysOnFilters?: ApiFilter[] } export type StatsQuery = { @@ -84,7 +85,10 @@ export function createStatsQuery( relative_date: dashboardState.date ? formatISO(dashboardState.date) : null, dimensions: reportParams.dimensions || [], metrics: reportParams.metrics, - filters: remapToApiFilters(dashboardState.filters), + filters: [ + ...remapToApiFilters(dashboardState.filters), + ...(reportParams.alwaysOnFilters ?? []) + ], order_by: reportParams.order_by || null, pagination: reportParams.pagination || null, include: { diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index d8128121b03d..249d7785f7e3 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -14,10 +14,6 @@ import { } from '../stats-query' import { Filter } from '../dashboard-state' import { MetricsByContext } from './reports/reports-config' -import { - defaultGetStatsQuery, - StatsReportQueryKey -} from '../hooks/use-query-api' import classNames from 'classnames' import { DIRECT_NONE } from './sources' @@ -25,6 +21,7 @@ export type SharedBreakdownReportProps = { dimensionLabel: string dimensions: NonTimeDimension[] metrics: Metric[] + alwaysOnFilters?: ApiFilter[] } export type ColumnConfiguration = { @@ -43,37 +40,6 @@ export type ColumnConfiguration = { align?: 'left' | 'right' } -const FILTER_DIMENSIONS_NOT = { - 'visit:city': [0], - 'visit:country': ['\0\0', 'ZZ'], - 'visit:region': [''], - 'visit:utm_medium': [''], - 'visit:utm_source': [''], - 'visit:utm_campaign': [''], - 'visit:utm_content': [''], - 'visit:utm_term': [''], - 'visit:entry_page': [''], - 'visit:exit_page': [''] -} - -export function getStatsQueryWithImplicitNotEmptyFilter( - queryKey: StatsReportQueryKey -) { - let statsQuery = defaultGetStatsQuery(queryKey) - - const dimension = queryKey[1].reportParams.dimensions[0] - - if (Object.keys(FILTER_DIMENSIONS_NOT).includes(dimension)) { - statsQuery = addFilter(statsQuery, [ - 'is_not', - dimension, - FILTER_DIMENSIONS_NOT[dimension as keyof typeof FILTER_DIMENSIONS_NOT] - ]) - } - - return statsQuery -} - export type GetFilterInfo = ( dimension: NonTimeDimension, row: QueryResultRow diff --git a/assets/js/dashboard/stats/devices/details.tsx b/assets/js/dashboard/stats/devices/details.tsx index 6eb91a21e4c1..6dbd7c739f68 100644 --- a/assets/js/dashboard/stats/devices/details.tsx +++ b/assets/js/dashboard/stats/devices/details.tsx @@ -64,6 +64,7 @@ export function DevicesDetails({ dimensionLabel={reportConfig.dimensionLabel} dimensions={reportConfig.dimensions} metrics={metrics} + alwaysOnFilters={reportConfig.alwaysOnFilters} defaultOrderBy={[['visitors', 'desc']]} searchEnabled={searchEnabled} DimensionElement={DimensionElement} diff --git a/assets/js/dashboard/stats/devices/index.tsx b/assets/js/dashboard/stats/devices/index.tsx index 876c460ad5d0..7cc262c174f0 100644 --- a/assets/js/dashboard/stats/devices/index.tsx +++ b/assets/js/dashboard/stats/devices/index.tsx @@ -112,6 +112,7 @@ export function Devices() { metrics={metrics} dimensions={reportConfig.dimensions} dimensionLabel={reportConfig.dimensionLabel} + alwaysOnFilters={reportConfig.alwaysOnFilters} DimensionElement={DimensionElement} onDataReady={setCurrentData} /> diff --git a/assets/js/dashboard/stats/locations/countries.ts b/assets/js/dashboard/stats/locations/countries.ts new file mode 100644 index 000000000000..37757b8ca390 --- /dev/null +++ b/assets/js/dashboard/stats/locations/countries.ts @@ -0,0 +1,37 @@ +import worldJson from 'visionscarto-world-atlas/world/110m.json' +import countriesMeta from '../../../../data/countries_meta.json' +import * as topojson from 'topojson-client' + +// The actual type is more extensive, this is only the part that we care about +export type WorldJsonCountryData = { properties: { a3: string } } + +export function parseWorldTopoJsonToGeoJsonFeatures(): Array { + const collection = topojson.feature( + // @ts-expect-error strings in worldJson not recongizable as the enum values declared in library + worldJson, + worldJson.objects.countries + ) + // @ts-expect-error topojson.feature return type incorrectly inferred as not a collection + return collection.features +} + +export type CountryEntry = { + // alpha_3 is null for non-country override entries like "A1" (Anonymous VPN) + alpha_3: string | null + flag: string +} + +type CountryTwoLetterCode = string + +export type CountriesLookup = Record + +const remapCountriesMeta = () => { + const result: CountriesLookup = {} + for (const [alpha_2, [alpha_3, flag]] of Object.entries(countriesMeta)) { + // flag is definitely defined in the source file + const entry: CountryEntry = { alpha_3, flag: flag! } + Object.assign(result, { [alpha_2]: entry }) + } + return result +} +export const COUNTRIES_BY_TWO_LETTER_CODE = remapCountriesMeta() diff --git a/assets/js/dashboard/stats/locations/details.tsx b/assets/js/dashboard/stats/locations/details.tsx new file mode 100644 index 000000000000..7d2353bf71ad --- /dev/null +++ b/assets/js/dashboard/stats/locations/details.tsx @@ -0,0 +1,111 @@ +import React, { ReactNode } from 'react' +import { revenueAvailable } from '../../dashboard-state' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { chooseBreakdownMetricsByContext } from '../breakdowns' +import { + BREAKDOWN_REPORTS, + BreakdownReportKey +} from '../reports/reports-config' +import { + DetailsBreakdown, + DimensionCell, + DimensionCellProps +} from '../modals/details-breakdown' +import Modal from '../modals/modal' +import { + getCitiesFilterInfo, + getCountriesFilterInfo, + getRegionsFilterInfo, + LocationsReportKey +} from '.' +import { FlagEmoji } from './flag-emoji' + +export function LocationsDetails({ + reportKey +}: { + reportKey: LocationsReportKey +}) { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + const reportConfig = BREAKDOWN_REPORTS[reportKey] + + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const metrics = chooseBreakdownMetricsByContext( + reportConfig.metricsByContext, + { + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: isRevenueAvailable + } + ) + + const DimensionElement = DIMENSION_ELEMENTS[reportKey] + + return ( + + + + ) +} + +const CountryDimensionCell = (props: DimensionCellProps) => { + const [countryName, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getCountriesFilterInfo} + /> + ) +} + +const RegionsDimensionCell = (props: DimensionCellProps) => { + const [regionName, _regionCode, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getRegionsFilterInfo} + /> + ) +} + +const CitiesDimensionCell = (props: DimensionCellProps) => { + const [cityName, _cityCode, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getCitiesFilterInfo} + /> + ) +} + +const DIMENSION_ELEMENTS: Record< + LocationsReportKey, + (props: DimensionCellProps) => ReactNode +> = { + [BreakdownReportKey.countries]: CountryDimensionCell, + [BreakdownReportKey.regions]: RegionsDimensionCell, + [BreakdownReportKey.cities]: CitiesDimensionCell +} diff --git a/assets/js/dashboard/stats/locations/flag-emoji.tsx b/assets/js/dashboard/stats/locations/flag-emoji.tsx new file mode 100644 index 000000000000..314d725337bb --- /dev/null +++ b/assets/js/dashboard/stats/locations/flag-emoji.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { COUNTRIES_BY_TWO_LETTER_CODE } from './countries' + +export const FlagEmoji = ({ countryCode }: { countryCode: string | null }) => { + if (!countryCode) { + return null + } + const entry = COUNTRIES_BY_TWO_LETTER_CODE[countryCode] + if (!entry?.flag) { + return null + } + return {entry.flag} +} diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js deleted file mode 100644 index b083a2da1625..000000000000 --- a/assets/js/dashboard/stats/locations/index.js +++ /dev/null @@ -1,315 +0,0 @@ -import React from 'react' - -import * as storage from '../../util/storage' -import CountriesMap from './map' - -import * as api from '../../api' -import { apiPath } from '../../util/url' -import ListReport from '../reports/list-legacy' -import * as metrics from '../reports/metrics' -import { - hasConversionGoalFilter, - getFiltersByKeyPrefix -} from '../../util/filters' -import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' -import { citiesRoute, countriesRoute, regionsRoute } from '../../router' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { ReportLayout } from '../reports/report-layout' -import { ReportHeader } from '../reports/report-header' -import { TabButton, TabWrapper } from '../../components/tabs' -import MoreLink from '../more-link' -import { MoreLinkState } from '../more-link-state' - -function Countries({ dashboardState, site, onClick, afterFetchData }) { - function fetchData() { - return api.get(apiPath(site, '/countries'), dashboardState, { limit: 9 }) - } - - function renderIcon(country) { - return {country.flag} - } - - function getFilterInfo(listItem) { - return { - prefix: 'country', - filter: ['is', 'country', [listItem['code']]], - labels: { [listItem['code']]: listItem['name'] } - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function Regions({ dashboardState, site, onClick, afterFetchData }) { - function fetchData() { - return api.get(apiPath(site, '/regions'), dashboardState, { limit: 9 }) - } - - function renderIcon(region) { - return {region.country_flag} - } - - function getFilterInfo(listItem) { - return { - prefix: 'region', - filter: ['is', 'region', [listItem['code']]], - labels: { [listItem['code']]: listItem['name'] } - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function Cities({ dashboardState, site, afterFetchData }) { - function fetchData() { - return api.get(apiPath(site, '/cities'), dashboardState, { limit: 9 }) - } - - function renderIcon(city) { - return {city.country_flag} - } - - function getFilterInfo(listItem) { - return { - prefix: 'city', - filter: ['is', 'city', [listItem['code']]], - labels: { [listItem['code']]: listItem['name'] } - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -class Locations extends React.Component { - constructor(props) { - super(props) - this.onCountryFilter = this.onCountryFilter.bind(this) - this.onRegionFilter = this.onRegionFilter.bind(this) - this.afterFetchData = this.afterFetchData.bind(this) - this.tabKey = `geoTab__${props.site.domain}` - const storedTab = storage.getItem(this.tabKey) - this.state = { - mode: storedTab || 'map', - loading: true, - skipImportedReason: null, - moreLinkState: MoreLinkState.LOADING - } - } - - componentDidUpdate(prevProps, prevState) { - const isRemovingFilter = (filterName) => { - return ( - getFiltersByKeyPrefix(prevProps.dashboardState, filterName).length > - 0 && - getFiltersByKeyPrefix(this.props.dashboardState, filterName).length == 0 - ) - } - - if (this.state.mode === 'cities' && isRemovingFilter('region')) { - this.setMode('regions')() - } - - if (this.state.mode === 'regions' && isRemovingFilter('country')) { - this.setMode(this.countriesRestoreMode || 'countries')() - } - - if ( - this.props.dashboardState !== prevProps.dashboardState || - this.state.mode !== prevState.mode - ) { - this.setState({ loading: true, moreLinkState: MoreLinkState.LOADING }) - } - } - - setMode(mode) { - return () => { - storage.setItem(this.tabKey, mode) - this.setState({ mode }) - } - } - - onCountryFilter(mode) { - return () => { - this.countriesRestoreMode = mode - this.setMode('regions')() - } - } - - onRegionFilter() { - this.setMode('cities')() - } - - afterFetchData(apiResponse) { - let newMoreLinkState - - if (apiResponse.results && apiResponse.results.length > 0) { - newMoreLinkState = MoreLinkState.READY - } else { - newMoreLinkState = MoreLinkState.HIDDEN - } - this.setState({ - loading: false, - moreLinkState: newMoreLinkState, - skipImportedReason: apiResponse.skip_imported_reason - }) - } - - renderContent() { - switch (this.state.mode) { - case 'cities': - return ( - - ) - case 'regions': - return ( - - ) - case 'countries': - return ( - - ) - case 'map': - default: - return ( - - ) - } - } - - getMoreLinkProps() { - let path - - if (this.state.mode === 'regions') { - path = regionsRoute.path - } else if (this.state.mode === 'cities') { - path = citiesRoute.path - } else { - path = countriesRoute.path - } - - return { path: path, search: (search) => search } - } - - render() { - return ( - - -
- - {[ - { label: 'Map', value: 'map' }, - { label: 'Countries', value: 'countries' }, - { label: 'Regions', value: 'regions' }, - { label: 'Cities', value: 'cities' } - ].map(({ value, label }) => ( - - {label} - - ))} - - -
- -
- {this.renderContent()} -
- ) - } -} - -function LocationsWithContext() { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - return -} -export default LocationsWithContext diff --git a/assets/js/dashboard/stats/locations/index.tsx b/assets/js/dashboard/stats/locations/index.tsx new file mode 100644 index 000000000000..a899dcf7cd4d --- /dev/null +++ b/assets/js/dashboard/stats/locations/index.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' + +import * as storage from '../../util/storage' +import CountriesMap from './map' + +import { + hasConversionGoalFilter, + isRealTimeDashboard, + getFiltersByKeyPrefix +} from '../../util/filters' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { ReportLayout } from '../reports/report-layout' +import { ReportHeader } from '../reports/report-header' +import { TabButton, TabWrapper } from '../../components/tabs' +import MoreLink from '../more-link' +import { MoreLinkState } from '../more-link-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import ImportedWarningBubble from '../imported-warning-bubble' +import { + BREAKDOWN_REPORTS, + BreakdownReportKey +} from '../reports/reports-config' +import { + DimensionCellWithBar, + DimensionCellWithBarProps, + IndexBreakdown +} from '../reports/index-breakdown' +import { chooseBreakdownMetricsByContext } from '../breakdowns' +import { FilterInfo } from '../../components/drilldown-link' +import { NonTimeDimension } from '../../stats-query' +import { FlagEmoji } from './flag-emoji' +import { DashboardState } from '../../dashboard-state' + +type MapTabKey = 'map' +type TabKey = + | MapTabKey + | BreakdownReportKey.countries + | BreakdownReportKey.regions + | BreakdownReportKey.cities + +export type LocationsReportKey = + | BreakdownReportKey.countries + | BreakdownReportKey.regions + | BreakdownReportKey.cities + +const BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' + +const initTab = (storedTab: string | null): TabKey => { + switch (storedTab) { + case BreakdownReportKey.countries: + return BreakdownReportKey.countries + case BreakdownReportKey.regions: + return BreakdownReportKey.regions + case BreakdownReportKey.cities: + return BreakdownReportKey.cities + case 'map': + default: + return 'map' + } +} + +const getAppliedLocationsFilters = (dashboardState: DashboardState) => ({ + countryFiltersApplied: + getFiltersByKeyPrefix(dashboardState, 'country').length > 0, + regionFiltersApplied: + getFiltersByKeyPrefix(dashboardState, 'region').length > 0 +}) + +export function Locations() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const tabKey = `geoTab__${site.domain}` + const [tab, setTab] = useState(initTab(storage.getItem(tabKey))) + // determines whether to show list or map (the default) when zooming out of Regions tab + const [prefersCountriesList, setPrefersCountriesList] = + useState(false) + const [currentData, setCurrentData] = useState(null) + + useEffect(() => { + storage.setItem(tabKey, tab) + }, [tabKey, tab]) + + const prevFilters = useRef<{ + countryFiltersApplied: boolean + regionFiltersApplied: boolean + }>(getAppliedLocationsFilters(dashboardState)) + + /** + * Clicking on a country applies "Country is ..." filter and zooms to Regions tab, + * clicking on a region applies "Region is ..." filter and zooms to Cities tab (see onClick handlers below). + * This effect handles zooming out of Regions tab on dismissing the countries filter, + * and zooming out of Cities tab on dismissing the regions filter. + */ + useEffect(() => { + setTab((currentTab) => { + const { countryFiltersApplied, regionFiltersApplied } = + getAppliedLocationsFilters(dashboardState) + const prev = { ...prevFilters.current } + prevFilters.current = { countryFiltersApplied, regionFiltersApplied } + + if ( + currentTab === BreakdownReportKey.regions && + prev.countryFiltersApplied && + !countryFiltersApplied + ) { + return prefersCountriesList ? BreakdownReportKey.countries : 'map' + } + + if ( + currentTab === BreakdownReportKey.cities && + prev.regionFiltersApplied && + !regionFiltersApplied + ) { + return BreakdownReportKey.regions + } + + return currentTab + }) + }, [prefersCountriesList, dashboardState]) + + const selectedListKey: LocationsReportKey = + tab === 'map' ? BreakdownReportKey.countries : tab + const reportConfig = BREAKDOWN_REPORTS[selectedListKey] + + const metrics = chooseBreakdownMetricsByContext( + reportConfig.metricsByContext, + { + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: false, + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRevenueAvailable: false + } + ) + + const moreLinkState = currentData + ? currentData.results.length > 0 + ? MoreLinkState.READY + : MoreLinkState.HIDDEN + : MoreLinkState.LOADING + + const moreLinkPath = reportConfig.detailsPath + + const CountriesDimensionElement = useCallback( + (props: DimensionCellWithBarProps) => ( + { + setPrefersCountriesList(true) + setTab(BreakdownReportKey.regions) + }} + /> + ), + [] + ) + + const RegionsDimensionElement = useCallback( + (props: DimensionCellWithBarProps) => ( + setTab(BreakdownReportKey.cities)} + /> + ), + [] + ) + + const DimensionElement = { + [BreakdownReportKey.countries]: CountriesDimensionElement, + [BreakdownReportKey.regions]: RegionsDimensionElement, + [BreakdownReportKey.cities]: CitiesDimensionCell + }[selectedListKey] + + return ( + + +
+ + {( + [ + { label: 'Map', value: 'map' }, + { + label: 'Countries', + value: BreakdownReportKey.countries + }, + { label: 'Regions', value: BreakdownReportKey.regions }, + { label: 'Cities', value: BreakdownReportKey.cities } + ] as const + ).map(({ label, value }) => ( + setTab(value)} + > + {label} + + ))} + + +
+ search + }} + /> +
+ {tab === 'map' ? ( + { + setPrefersCountriesList(false) + setTab(BreakdownReportKey.regions) + }} + onDataReady={setCurrentData} + /> + ) : ( + + )} +
+ ) +} + +const CountriesDimensionCell = ( + props: DimensionCellWithBarProps & { onClick: () => void } +) => { + const [countryName, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getCountriesFilterInfo} + /> + ) +} + +const RegionsDimensionCell = ( + props: DimensionCellWithBarProps & { onClick: () => void } +) => { + const [regionName, _regionCode, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getRegionsFilterInfo} + /> + ) +} + +const CitiesDimensionCell = (props: DimensionCellWithBarProps) => { + const [cityName, _cityCode, countryCode] = props.row.dimensions + return ( + } + getFilterInfo={getCitiesFilterInfo} + /> + ) +} + +export const getCountriesFilterInfo = ( + _dimension: NonTimeDimension, + row: QueryResultRow +): FilterInfo => { + const [countryName, countryCode] = row.dimensions + + return { + prefix: 'country', + filter: ['is', 'country', [countryCode]], + labels: { [countryCode]: countryName } + } +} + +export const getRegionsFilterInfo = ( + _dimension: NonTimeDimension, + row: QueryResultRow +): FilterInfo => { + const [regionName, regionCode, _countryCode] = row.dimensions + + return { + prefix: 'region', + filter: ['is', 'region', [regionCode]], + labels: { [regionCode]: regionName } + } +} + +export const getCitiesFilterInfo = ( + _dimension: NonTimeDimension, + row: QueryResultRow +): FilterInfo => { + const [cityName, cityCode, _countryCode] = row.dimensions + + return { + prefix: 'city', + filter: ['is', 'city', [cityCode]], + labels: { [cityCode]: cityName } + } +} + +export default Locations diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 60b0bdfddaf2..f773bfbd7869 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as d3 from 'd3' import classNames from 'classnames' -import * as api from '../../api' import { replaceFilterByPrefix, cleanLabels, @@ -10,17 +9,24 @@ import { } from '../../util/filters' import { useAppNavigate } from '../../navigation/use-app-navigate' import { numberShortFormatter } from '../../util/number-formatter' -import * as topojson from 'topojson-client' -import { useQuery } from '@tanstack/react-query' import { useSiteContext } from '../../site-context' import { useDashboardStateContext } from '../../dashboard-state-context' -import worldJson from 'visionscarto-world-atlas/world/110m.json' import { UIMode, useTheme } from '../../theme-context' -import { apiPath } from '../../util/url' import { MIN_HEIGHT } from '../reports/list-legacy' import { MapTooltip } from './map-tooltip' import { GeolocationNotice } from './geolocation-notice' import { DashboardState } from '../../dashboard-state' +import { useQueryApi } from '../../hooks/use-query-api' +import { QueryApiResponse } from '../../api' +import { + COUNTRIES_BY_TWO_LETTER_CODE, + parseWorldTopoJsonToGeoJsonFeatures, + WorldJsonCountryData +} from './countries' +import { + BREAKDOWN_REPORTS, + BreakdownReportKey +} from '../reports/reports-config' const width = 475 const height = 335 @@ -31,7 +37,6 @@ type CountryData = { visitors: number code: string } -type WorldJsonCountryData = { properties: { name: string; a3: string } } function getMetricLabel(dashboardState: DashboardState) { if (hasConversionGoalFilter(dashboardState)) { @@ -45,10 +50,10 @@ function getMetricLabel(dashboardState: DashboardState) { const WorldMap = ({ onCountrySelect, - afterFetchData + onDataReady }: { onCountrySelect: () => void - afterFetchData: (response: unknown) => void + onDataReady: (response: QueryApiResponse) => void }) => { const navigate = useAppNavigate() const { mode } = useTheme() @@ -66,49 +71,52 @@ const WorldMap = ({ [dashboardState] ) - const { data, refetch, isFetching, isError } = useQuery({ - queryKey: ['countries', 'map', dashboardState], - placeholderData: (previousData) => previousData, - queryFn: async (): Promise<{ - results: CountryData[] - }> => { - return await api.get(apiPath(site, '/countries'), dashboardState, { - limit: 300 - }) - } - }) - - useEffect(() => { - const onTickRefetchData = () => { - if (dashboardState.period === 'realtime') { - refetch() + const { apiState } = useQueryApi(site, [ + 'visit:country', + { + dashboardState, + reportParams: { + metrics: ['visitors'], + dimensions: BREAKDOWN_REPORTS[BreakdownReportKey.countries].dimensions, + alwaysOnFilters: + BREAKDOWN_REPORTS[BreakdownReportKey.countries].alwaysOnFilters, + order_by: [['visitors', 'desc']], + pagination: { limit: 300, offset: 0 } } } - document.addEventListener('tick', onTickRefetchData) - return () => document.removeEventListener('tick', onTickRefetchData) - }, [dashboardState.period, refetch]) + ]) + const { data, isFetching, isError } = apiState useEffect(() => { if (data) { - afterFetchData(data) + onDataReady(data) } - }, [afterFetchData, data, isFetching]) + }, [onDataReady, data]) - const { maxValue, dataByCountryCode } = useMemo(() => { - const dataByCountryCode: Map = new Map() + const { maxValue, dataByAlpha3Code } = useMemo(() => { + const dataByAlpha3Code: Map = new Map() let maxValue = 0 - for (const { alpha_3, visitors, name, code } of data?.results || []) { + for (const row of data?.results ?? []) { + const [countryName, countryCode] = row.dimensions + const [visitors] = row.metrics as [number] + const entry = COUNTRIES_BY_TWO_LETTER_CODE[countryCode] + if (!entry || !entry.alpha_3) continue if (visitors > maxValue) { maxValue = visitors } - dataByCountryCode.set(alpha_3, { alpha_3, visitors, name, code }) + dataByAlpha3Code.set(entry.alpha_3, { + alpha_3: entry.alpha_3, + visitors, + name: countryName, + code: countryCode + }) } - return { maxValue, dataByCountryCode } + return { maxValue, dataByAlpha3Code } }, [data]) const onCountryClick = useCallback( (d: WorldJsonCountryData) => { - const country = dataByCountryCode.get(d.properties.a3) + const country = dataByAlpha3Code.get(d.properties.a3) const clickable = country && country.visitors if (clickable) { const filters = replaceFilterByPrefix(dashboardState, 'country', [ @@ -125,7 +133,7 @@ const WorldMap = ({ }) } }, - [navigate, dashboardState, dataByCountryCode, onCountrySelect] + [navigate, dashboardState, dataByAlpha3Code, onCountrySelect] ) useEffect(() => { @@ -173,15 +181,15 @@ const WorldMap = ({ colorInCountriesWithValues( svgRef.current, getColorForValue, - dataByCountryCode + dataByAlpha3Code ).on('click', (_event, countryPath) => { onCountryClick(countryPath as unknown as WorldJsonCountryData) }) } - }, [mode, maxValue, dataByCountryCode, onCountryClick]) + }, [mode, maxValue, dataByAlpha3Code, onCountryClick]) const hoveredCountryData = tooltip.hoveredCountryAlpha3Code - ? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code) + ? dataByAlpha3Code.get(tooltip.hoveredCountryAlpha3Code) : undefined return ( @@ -274,25 +282,19 @@ function colorInCountriesWithValues( getColorForValue: d3.ScaleLinear, dataByCountryCode: Map ) { - function getCountryByCountryPath(countryPath: unknown) { - return dataByCountryCode.get( - (countryPath as unknown as WorldJsonCountryData).properties.a3 - ) - } - const svg = d3.select(element) return svg - .selectAll(countrySelector) + .selectAll(countrySelector) .style('fill', (countryPath) => { - const country = getCountryByCountryPath(countryPath) + const country = dataByCountryCode.get(countryPath.properties.a3) if (!country?.visitors) { return null } return getColorForValue(country.visitors) }) .style('cursor', (countryPath) => { - const country = getCountryByCountryPath(countryPath) + const country = dataByCountryCode.get(countryPath.properties.a3) if (!country?.visitors) { return null } @@ -304,7 +306,6 @@ function drawHighlightedCountryOutline(element: SVGSVGElement) { return d3.select(element).append('path').attr('class', initialOutlineClass) } -/** @returns the d3 selected svg element */ function drawInteractiveCountries(element: SVGSVGElement) { const path = setupProjetionPath() const data = parseWorldTopoJsonToGeoJsonFeatures() @@ -331,14 +332,4 @@ function setupProjetionPath() { return path } -function parseWorldTopoJsonToGeoJsonFeatures(): Array { - const collection = topojson.feature( - // @ts-expect-error strings in worldJson not recongizable as the enum values declared in library - worldJson, - worldJson.objects.countries - ) - // @ts-expect-error topojson.feature return type incorrectly inferred as not a collection - return collection.features -} - export default WorldMap diff --git a/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx index 7189784fc3df..30a71007e27c 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx @@ -224,7 +224,7 @@ const NameCell = ({ path={rootRoute.path} filterInfo={getFilterInfo(item)} onClick={undefined} - extraClass={undefined} + className={undefined} > {item.name} diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 2a3e8f29556d..b0da56466572 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -30,7 +30,6 @@ import { formatDateRangeLabel, useBodyPortalRef, extractMetricValue, - getStatsQueryWithImplicitNotEmptyFilter, GetFilterInfo } from '../breakdowns' import { @@ -87,6 +86,7 @@ export function DetailsBreakdown({ dimensionLabel, dimensions, metrics, + alwaysOnFilters, defaultOrderBy = [] as MetricOrderBy, DimensionElement, searchEnabled = true, @@ -124,15 +124,14 @@ export function DetailsBreakdown({ order_by: [ ...(orderBy.length ? orderBy : storedOrderBy), ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) - ] + ], + alwaysOnFilters }, search } ] - const apiState = useSearchAndPaginateQueryAPI(site, statsReportQueryKey, { - getStatsQuery: getStatsQueryWithImplicitNotEmptyFilter - }) + const apiState = useSearchAndPaginateQueryAPI(site, statsReportQueryKey) useEffect(() => { const pages = apiState.data?.pages @@ -491,8 +490,8 @@ export const DimensionCell = ({ - {icon} {text} {externalLink} diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js deleted file mode 100644 index 739a0cd084f9..000000000000 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useCallback } from 'react' - -import Modal from './modal' -import { - hasConversionGoalFilter, - isRealTimeDashboard -} from '../../util/filters' -import BreakdownModal from './breakdown-modal-legacy' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { addFilter, revenueAvailable } from '../../dashboard-state' - -const VIEWS = { - countries: { - title: 'Top countries', - dimension: 'country', - endpoint: '/countries', - dimensionLabel: 'Country', - defaultOrder: ['visitors', 'desc'] - }, - regions: { - title: 'Top regions', - dimension: 'region', - endpoint: '/regions', - dimensionLabel: 'Region', - defaultOrder: ['visitors', 'desc'] - }, - cities: { - title: 'Top cities', - dimension: 'city', - endpoint: '/cities', - dimensionLabel: 'City', - defaultOrder: ['visitors', 'desc'] - } -} - -function LocationsModal({ currentView }) { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - - /*global BUILD_EXTRA*/ - const showRevenueMetrics = - BUILD_EXTRA && revenueAvailable(dashboardState, site) - - let reportInfo = VIEWS[currentView] - reportInfo = { - ...reportInfo, - endpoint: url.apiPath(site, reportInfo.endpoint) - } - - const getFilterInfo = useCallback( - (listItem) => { - return { - prefix: reportInfo.dimension, - filter: ['is', reportInfo.dimension, [listItem.code]], - labels: { [listItem.code]: listItem.name } - } - }, - [reportInfo.dimension] - ) - - const addSearchFilter = useCallback( - (dashboardState, searchString) => { - return addFilter(dashboardState, [ - 'contains', - `${reportInfo.dimension}_name`, - [searchString], - { case_sensitive: false } - ]) - }, - [reportInfo.dimension] - ) - - function chooseMetrics() { - if (hasConversionGoalFilter(dashboardState)) { - return [ - metrics.createTotalVisitors(), - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Conversions', - width: 'w-28' - }), - metrics.createConversionRate(), - showRevenueMetrics && metrics.createTotalRevenue(), - showRevenueMetrics && metrics.createAverageRevenue() - ].filter((metric) => !!metric) - } - - if ( - isRealTimeDashboard(dashboardState) && - !hasConversionGoalFilter(dashboardState) - ) { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Current visitors', - width: 'w-32' - }) - ] - } - - return [ - metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), - currentView === 'countries' && metrics.createPercentage() - ].filter((metric) => !!metric) - } - - const renderIcon = useCallback((listItem) => { - return ( - {listItem.country_flag || listItem.flag} - ) - }, []) - - return ( - - - - ) -} - -export default LocationsModal diff --git a/assets/js/dashboard/stats/pages/details.tsx b/assets/js/dashboard/stats/pages/details.tsx index d68d562c09d9..0616043edf17 100644 --- a/assets/js/dashboard/stats/pages/details.tsx +++ b/assets/js/dashboard/stats/pages/details.tsx @@ -56,6 +56,7 @@ export function PagesDetails({ dimensionLabel={reportConfig.dimensionLabel} dimensions={reportConfig.dimensions} metrics={metrics} + alwaysOnFilters={reportConfig.alwaysOnFilters} defaultOrderBy={[['visitors', 'desc']]} DimensionElement={PagesDimensionElement} /> diff --git a/assets/js/dashboard/stats/pages/index.tsx b/assets/js/dashboard/stats/pages/index.tsx index 04b78e6d5bf4..980a2f394947 100644 --- a/assets/js/dashboard/stats/pages/index.tsx +++ b/assets/js/dashboard/stats/pages/index.tsx @@ -72,6 +72,7 @@ export default function Pages() { metrics={metrics} dimensions={reportConfig.dimensions} dimensionLabel={reportConfig.dimensionLabel} + alwaysOnFilters={reportConfig.alwaysOnFilters} DimensionElement={PagesDimensionCell} onDataReady={setCurrentData} /> diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index ffc2881ed61e..0d5700becc8a 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -18,7 +18,6 @@ import { formatDateRangeLabel, useBodyPortalRef, extractMetricValue, - getStatsQueryWithImplicitNotEmptyFilter, MetricValueWrapper, GetFilterInfo } from '../breakdowns' @@ -61,6 +60,7 @@ export function IndexBreakdown({ dimensions, DimensionElement, dimensionLabel, + alwaysOnFilters, onDataReady, metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH }: IndexBreakdownProps) { @@ -79,6 +79,7 @@ export function IndexBreakdown({ ['visitors', 'desc'], ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) ], + alwaysOnFilters, pagination: { limit: MAX_ITEMS, offset: 0 } } } @@ -87,7 +88,7 @@ export function IndexBreakdown({ const { apiState, isRealtimeSilentUpdate } = useQueryApi( site, statsReportQueryKey, - { enabled: visible, getStatsQuery: getStatsQueryWithImplicitNotEmptyFilter } + { enabled: visible } ) useEffect(() => { @@ -234,10 +235,11 @@ export const DimensionCellWithBar = ({ - {icon} - {text} + {text} {externalLink} diff --git a/assets/js/dashboard/stats/reports/list-legacy.tsx b/assets/js/dashboard/stats/reports/list-legacy.tsx index 43810bbdf4c2..3b913d84bc40 100644 --- a/assets/js/dashboard/stats/reports/list-legacy.tsx +++ b/assets/js/dashboard/stats/reports/list-legacy.tsx @@ -312,13 +312,11 @@ export default function ListReport< - {maybeRenderIconFor(listItem)} - - - {trimURL(listItem.name, colMinWidth)} - + {trimURL(listItem.name, colMinWidth)} diff --git a/assets/js/dashboard/stats/sources/index.tsx b/assets/js/dashboard/stats/sources/index.tsx index 5f0801100164..b50c34baf3c8 100644 --- a/assets/js/dashboard/stats/sources/index.tsx +++ b/assets/js/dashboard/stats/sources/index.tsx @@ -219,6 +219,7 @@ export default function Sources() { metrics={metrics} dimensions={reportConfig.dimensions} dimensionLabel={reportConfig.dimensionLabel} + alwaysOnFilters={reportConfig.alwaysOnFilters} DimensionElement={DimensionElement} onDataReady={setCurrentQueryApiData} /> diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts index cf13664d60ae..491fb28b6bf3 100644 --- a/e2e/tests/dashboard/breakdowns.spec.ts +++ b/e2e/tests/dashboard/breakdowns.spec.ts @@ -925,6 +925,10 @@ test('locations breakdown', async ({ page, request }) => { country_code: 'PL', subdivision1_code: 'PL-14', city_geoname_id: 756_135 + }, + { + name: 'pageview', + country_code: '' } ] }) @@ -968,6 +972,14 @@ test('locations breakdown', async ({ page, request }) => { await expectMetricValues(modal(page), 'Estonia', ['2']) + await expect(searchInput(modal(page))).toBeVisible() + + await searchInput(modal(page)).fill('Esto') + await expectRows(modal(page), [/Estonia/]) + + await searchInput(modal(page)).fill('') + await expectRows(modal(page), [/Estonia/, /Poland/]) + await closeModalButton(page).click() }) @@ -990,7 +1002,7 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(report, ['Region', 'Visitors']) - await expectRows(report, [/Harjumaa/, /Tartumaa/, /Mazovia/]) + await expectRows(report, [/Harjumaa/, /Mazovia/, /Tartumaa/]) await expectMetricValues(report, 'Harjumaa', ['1', '33.3%']) await expectMetricValues(report, 'Tartumaa', ['1', '33.3%']) @@ -1006,10 +1018,18 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(modal(page), ['Region', /Visitors/]) - await expectRows(modal(page), [/Harjumaa/, /Tartumaa/, /Mazovia/]) + await expectRows(modal(page), [/Harjumaa/, /Mazovia/, /Tartumaa/]) await expectMetricValues(modal(page), 'Harjumaa', ['1']) + await expect(searchInput(modal(page))).toBeVisible() + + await searchInput(modal(page)).fill('Harju') + await expectRows(modal(page), [/Harjumaa/]) + + await searchInput(modal(page)).fill('') + await expectRows(modal(page), [/Harjumaa/, /Mazovia/, /Tartumaa/]) + await closeModalButton(page).click() }) @@ -1032,7 +1052,7 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(report, ['City', 'Visitors']) - await expectRows(report, [/Tartu/, /Tallinn/, /Warsaw/]) + await expectRows(report, [/Tallinn/, /Tartu/, /Warsaw/]) await expectMetricValues(report, 'Tartu', ['1', '33.3%']) await expectMetricValues(report, 'Tallinn', ['1', '33.3%']) @@ -1048,10 +1068,205 @@ test('locations breakdown', async ({ page, request }) => { await expectHeaders(modal(page), ['City', /Visitors/]) - await expectRows(modal(page), [/Tartu/, /Tallinn/, /Warsaw/]) + await expectRows(modal(page), [/Tallinn/, /Tartu/, /Warsaw/]) await expectMetricValues(modal(page), 'Tartu', ['1']) + await expect(searchInput(modal(page))).toBeVisible() + + await searchInput(modal(page)).fill('Tallinn') + await expectRows(modal(page), [/Tallinn/]) + + await searchInput(modal(page)).fill('') + await expectRows(modal(page), [/Tallinn/, /Tartu/, /Warsaw/]) + + await closeModalButton(page).click() + }) +}) + +test('locations breakdown with a revenue goal filter applied', async ({ + page, + request +}) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 1, + name: 'pageview', + country_code: 'A1' + }, + { + user_id: 2, + name: 'pageview', + country_code: 'A1' + }, + { + user_id: 2, + name: 'purchase', + revenue_reporting_amount: '23', + revenue_reporting_currency: 'EUR', + country_code: 'A1' + }, + { + user_id: 3, + name: 'pageview', + country_code: 'PL', + subdivision1_code: 'PL-14', + city_geoname_id: 756_135 + }, + { + user_id: 4, + name: 'pageview', + country_code: 'EE', + subdivision1_code: 'EE-37', + city_geoname_id: 588_409 + }, + { + user_id: 4, + name: 'purchase', + revenue_reporting_amount: '12345.67', + revenue_reporting_currency: 'EUR', + country_code: 'EE', + subdivision1_code: 'EE-37', + city_geoname_id: 588_409 + } + ] + }) + + await addCustomGoal({ page, domain, name: 'purchase', currency: 'EUR' }) + + await page.goto('/' + domain + '?f=is,goal,purchase', { + waitUntil: 'commit' + }) + + const report = page.getByTestId('report-locations') + + await test.step('countries report shows conversions for revenue goal', async () => { + await tabButton(report, 'Countries').click() + + await expectHeaders(report, ['Country', 'Conversions', 'CR']) + + await expectRows(report, [/Anonymous VPN Service/, /Estonia/]) + + await expectMetricValues(report, 'Anonymous VPN Service', ['1', '50%']) + await expectMetricValues(report, 'Estonia', ['1', '100%']) + }) + + await test.step('countries details modal includes revenue columns', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top countries' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Country', + /Total visitors/, + /Conversions/, + /CR/, + /Revenue/, + /Average/ + ]) + + await expectRows(modal(page), [/Anonymous VPN Service/, /Estonia/]) + + await expectMetricValues(modal(page), 'Anonymous VPN Service', [ + '2', + '1', + '50%', + '€23.0', + '€23.0' + ]) + await expectMetricValues(modal(page), 'Estonia', [ + '1', + '1', + '100%', + '€12.3K', + '€12.3K' + ]) + + await closeModalButton(page).click() + }) + + await test.step('regions report shows conversions for revenue goal', async () => { + await tabButton(report, 'Regions').click() + + await expectHeaders(report, ['Region', 'Conversions', 'CR']) + + await expectRows(report, [/Harjumaa/]) + + await expectMetricValues(report, 'Harjumaa', ['1', '100%']) + }) + + await test.step('regions details modal includes revenue columns', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top regions' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'Region', + /Total visitors/, + /Conversions/, + /CR/, + /Revenue/, + /Average/ + ]) + + await expectRows(modal(page), [/Harjumaa/]) + + await expectMetricValues(modal(page), 'Harjumaa', [ + '1', + '1', + '100%', + '€12.3K', + '€12.3K' + ]) + + await closeModalButton(page).click() + }) + + await test.step('cities report shows conversions for revenue goal', async () => { + await tabButton(report, 'Cities').click() + + await expectHeaders(report, ['City', 'Conversions', 'CR']) + + await expectRows(report, [/Tallinn/]) + + await expectMetricValues(report, 'Tallinn', ['1', '100%']) + }) + + await test.step('cities details modal includes revenue columns', async () => { + await detailsLink(report).click() + + await expect( + modal(page).getByRole('heading', { name: 'Top cities' }) + ).toBeVisible() + + await expectHeaders(modal(page), [ + 'City', + /Total visitors/, + /Conversions/, + /CR/, + /Revenue/, + /Average/ + ]) + + await expectRows(modal(page), [/Tallinn/]) + + await expectMetricValues(modal(page), 'Tallinn', [ + '1', + '1', + '100%', + '€12.3K', + '€12.3K' + ]) + await closeModalButton(page).click() }) }) diff --git a/lib/mix/tasks/generate_countries_meta.ex b/lib/mix/tasks/generate_countries_meta.ex new file mode 100644 index 000000000000..0805b9c01a41 --- /dev/null +++ b/lib/mix/tasks/generate_countries_meta.ex @@ -0,0 +1,41 @@ +defmodule Mix.Tasks.GenerateCountriesMeta do + @moduledoc """ + Regenerates `countries_meta.json`, an `alpha_2 -> [alpha_3, flag]` dictionary. + The dashboard uses it for two things. + First, to render country flags the same way as the BE does. + Secondly, to map visitors by country to the country shapes in the world map. This is needed + because country shapes are identified in the geography dataset only with their alpha3 code. + + The source of truth is `Location.Country.all/0` from the `:location` + dependency. We materialize it to a checked-in JSON file in `assets/` folder. + + The checked-in file is protected from drifting off from the dependency by a CI job. + + Run `mix generate_countries_meta` locally whenever the `:location` dependency is bumped, + or any time you want to refresh the committed file. + """ + + use Mix.Task + + @output_path "assets/data/countries_meta.json" + + @impl Mix.Task + def run(_args) do + Application.ensure_all_started(:jason) + Location.Country.load() + + json = + Location.Country.all() + |> Map.new(fn %Location.Country{ + alpha_2: code, + alpha_3: alpha_3, + flag: flag + } -> + {code, [alpha_3, flag]} + end) + |> Jason.encode!(pretty: true) + + File.write!(@output_path, json) + Mix.shell().info("Wrote #{byte_size(json)} bytes to #{@output_path}") + end +end diff --git a/lib/plausible/stats/imported/sql/expression.ex b/lib/plausible/stats/imported/sql/expression.ex index a69825fe87b8..f96cdbbb8396 100644 --- a/lib/plausible/stats/imported/sql/expression.ex +++ b/lib/plausible/stats/imported/sql/expression.ex @@ -276,6 +276,20 @@ defmodule Plausible.Stats.Imported.SQL.Expression do }) end + # Rows imported from Plausible store `imported_locations.region` as ISO 3166-2 code. + # Rows imported from GA4 have `imported_locations.region` set to region name + # (e.g. "California"). + # + # imported_locations.region_name is an alias column that looks up the name by the ISO code. + # Lookup by values imported from GA4 (e.g. "California"), will yield an empty string. + # + # This function ensures that in those scenarios, the region name is set to the region code. + defp select_group_fields(q, "visit:region_name", key, _query) do + select_merge_as(q, [i], %{ + key => fragment("if(empty(?), ?, ?)", i.region_name, i.region, i.region_name) + }) + end + defp select_group_fields(q, dimension, key, _query) do select_merge_as(q, [i], %{key => field(i, ^dim(dimension))}) end @@ -298,7 +312,7 @@ defmodule Plausible.Stats.Imported.SQL.Expression do defp filter_group_values(q, "visit:city"), do: where(q, [i], i.city != 0 and not is_nil(i.city)) defp filter_group_values(q, "visit:country_name"), do: where(q, [i], i.country_name != "ZZ") - defp filter_group_values(q, "visit:region_name"), do: where(q, [i], i.region_name != "") + defp filter_group_values(q, "visit:region_name"), do: where(q, [i], i.region != "") defp filter_group_values(q, "visit:city_name"), do: where(q, [i], i.city_name != "") defp filter_group_values(q, _dimension), do: q diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs index d3317f874033..9ed544765ccc 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs @@ -1152,6 +1152,58 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryImportedTest do end end + test "imported non-empty region IDs that don't have a corresponding name in our locations dictionary have region_name set to the ID itself, empty region IDs are excluded", + %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + # GA4 import: `region` is the name of the `region` + build(:imported_locations, country: "US", region: "California", visitors: 10), + # Plausible import: `region` is the region ID + build(:imported_locations, country: "EE", region: "EE-37", visitors: 5), + # Plausible import: `region` is the region ID, but the ID is stale + build(:imported_locations, country: "NO", region: "NO-99", visitors: 50), + # Plausible / GA4 import when the region is unknown + build(:imported_locations, country: "US", region: "", visitors: 99) + ]) + + for {dimensions, expected_dimension_values} <- [ + {["visit:region_name", "visit:region"], + [ + NO: ["NO-99", "NO-99"], + US: ["California", "California"], + EE: ["Harjumaa", "EE-37"] + ]}, + {["visit:region"], + [ + NO: ["NO-99"], + US: ["California"], + EE: ["EE-37"] + ]}, + {["visit:region_name"], + [ + NO: ["NO-99"], + US: ["California"], + EE: ["Harjumaa"] + ]} + ] do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => dimensions, + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => expected_dimension_values[:NO], "metrics" => [50]}, + %{"dimensions" => expected_dimension_values[:US], "metrics" => [10]}, + %{"dimensions" => expected_dimension_values[:EE], "metrics" => [5]} + ] + end + end + test "imported country and city names", %{ site: site, conn: conn