diff --git a/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx b/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx index 1a131da13c..192e51acee 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteDetails.tsx @@ -10,7 +10,13 @@ import WebsiteHeader from './WebsiteHeader'; import WebsiteMetricsBar from './WebsiteMetricsBar'; import WebsiteTableView from './WebsiteTableView'; -export default function WebsiteDetails({ websiteId }: { websiteId: string }) { +export default function WebsiteDetails({ + websiteId, + customDataFields, +}: { + websiteId: string; + customDataFields: string[]; +}) { const { data: website, isLoading, error } = useWebsite(websiteId); const pathname = usePathname(); const { query } = useNavigation(); @@ -31,7 +37,13 @@ export default function WebsiteDetails({ websiteId }: { websiteId: string }) { {!website && } {website && ( <> - {!view && } + {!view && ( + + )} {view && } )} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx index 7cc415e5cd..9cabcf7de1 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx @@ -9,13 +9,17 @@ import WorldMap from 'components/metrics/WorldMap'; import CountriesTable from 'components/metrics/CountriesTable'; import EventsTable from 'components/metrics/EventsTable'; import EventsChart from 'components/metrics/EventsChart'; +import PageviewCustomDataTable from 'components/metrics/PageviewCustomDataTable'; +import { chunkArray } from 'next-basics'; export default function WebsiteTableView({ websiteId, domainName, + customDataFields, }: { websiteId: string; domainName: string; + customDataFields: string[]; }) { const [countryData, setCountryData] = useState(); const tableProps = { @@ -35,6 +39,16 @@ export default function WebsiteTableView({ + {chunkArray(customDataFields, 3).map((fields, index) => ( + + {fields.map(field => ( + + ))} + + ))} diff --git a/src/app/(main)/websites/[websiteId]/page.tsx b/src/app/(main)/websites/[websiteId]/page.tsx index ddb6c833b1..6cc8471421 100644 --- a/src/app/(main)/websites/[websiteId]/page.tsx +++ b/src/app/(main)/websites/[websiteId]/page.tsx @@ -1,8 +1,8 @@ import WebsiteDetails from './WebsiteDetails'; import { Metadata } from 'next'; -export default function WebsitePage({ params: { websiteId } }) { - return ; +export default function WebsitePage({ params: { websiteId }, searchParams }) { + return ; } export const metadata: Metadata = { diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx index 26355018ff..a8a77c7881 100644 --- a/src/components/metrics/MetricsTable.tsx +++ b/src/components/metrics/MetricsTable.tsx @@ -29,6 +29,7 @@ export interface MetricsTableProps extends ListTableProps { onSearch?: (search: string) => void; allowSearch?: boolean; children?: ReactNode; + fieldName?: string } export function MetricsTable({ @@ -41,6 +42,7 @@ export function MetricsTable({ delay = null, allowSearch = false, children, + fieldName, ...props }: MetricsTableProps) { const [search, setSearch] = useState(''); @@ -70,6 +72,7 @@ export function MetricsTable({ city, limit, search, + fieldName, }, { retryDelay: delay || DEFAULT_ANIMATION_DURATION, onDataLoad }, ); diff --git a/src/components/metrics/PageviewCustomDataTable.tsx b/src/components/metrics/PageviewCustomDataTable.tsx new file mode 100644 index 0000000000..c7cffa76a6 --- /dev/null +++ b/src/components/metrics/PageviewCustomDataTable.tsx @@ -0,0 +1,29 @@ +import useMessages from 'components/hooks/useMessages'; +import MetricsTable, { MetricsTableProps } from 'components/metrics/MetricsTable'; + +export function PageviewCustomDataTable(props: MetricsTableProps) { + const { formatMessage, labels } = useMessages(); + + function renderLink({ x: field }) { + return <>{field}; + } + + function titleize(fieldName: string) { + return fieldName + .split(/[_-]/) + .map((word, i) => (i === 0 ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); + } + + return ( + + ); +} + +export default PageviewCustomDataTable; diff --git a/src/pages/api/websites/[websiteId]/metrics.ts b/src/pages/api/websites/[websiteId]/metrics.ts index 4a881ef91b..1c9b4774e5 100644 --- a/src/pages/api/websites/[websiteId]/metrics.ts +++ b/src/pages/api/websites/[websiteId]/metrics.ts @@ -28,6 +28,7 @@ export interface WebsiteMetricsRequestQuery { limit?: number; offset?: number; search?: string; + fieldName?: string; } const schema = { @@ -51,6 +52,7 @@ const schema = { limit: yup.number(), offset: yup.number(), search: yup.string(), + fieldName: yup.string(), }), }; @@ -62,7 +64,26 @@ export default async ( await useAuth(req, res); await useValidate(schema, req, res); - const { websiteId, type, limit, offset, search } = req.query; + const { + websiteId, + type, + url, + referrer, + title, + query, + os, + browser, + device, + country, + region, + city, + language, + event, + limit, + offset, + search, + fieldName, + } = req.query; if (req.method === 'GET') { if (!(await canViewWebsite(req.auth, websiteId))) { @@ -111,6 +132,10 @@ export default async ( if (EVENT_COLUMNS.includes(type)) { const data = await getPageviewMetrics(websiteId, type, filters, limit, offset); + return ok(res, data); + } else if (type === 'custom') { + const data = await getPageviewMetrics(websiteId, column, filters, limit, offset, fieldName); + return ok(res, data); } diff --git a/src/queries/analytics/eventData/getEventDataEvents.ts b/src/queries/analytics/eventData/getEventDataEvents.ts index f869deae49..3dc4afcfc1 100644 --- a/src/queries/analytics/eventData/getEventDataEvents.ts +++ b/src/queries/analytics/eventData/getEventDataEvents.ts @@ -51,6 +51,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { on website_event.event_id = event_data.website_event_id where event_data.website_id = {{websiteId::uuid}} and event_data.created_at between {{startDate}} and {{endDate}} + and website_event.event_name is not null group by website_event.event_name, event_data.data_key, event_data.data_type order by 1 asc, 2 asc limit 500 diff --git a/src/queries/analytics/eventData/getEventDataStats.ts b/src/queries/analytics/eventData/getEventDataStats.ts index 978f561bc9..64456ebcbf 100644 --- a/src/queries/analytics/eventData/getEventDataStats.ts +++ b/src/queries/analytics/eventData/getEventDataStats.ts @@ -32,8 +32,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { data_key, count(*) as "total" from event_data - where website_id = {{websiteId::uuid}} - and created_at between {{startDate}} and {{endDate}} + join website_event on website_event.event_id = event_data.website_event_id + where website_event.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + and website_event.event_name is not null ${filterQuery} group by website_event_id, data_key ) as t diff --git a/src/queries/analytics/pageviews/getPageviewMetrics.ts b/src/queries/analytics/pageviews/getPageviewMetrics.ts index eaf4ae324a..1c2da63b8c 100644 --- a/src/queries/analytics/pageviews/getPageviewMetrics.ts +++ b/src/queries/analytics/pageviews/getPageviewMetrics.ts @@ -5,7 +5,14 @@ import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from 'lib/constants'; import { QueryFilters } from 'lib/types'; export async function getPageviewMetrics( - ...args: [websiteId: string, type: string, filters: QueryFilters, limit?: number, offset?: number] + ...args: [ + websiteId: string, + column: string, + filters: QueryFilters, + limit?: number, + offset?: number, + fieldName?: string, + ] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -19,6 +26,7 @@ async function relationalQuery( filters: QueryFilters, limit: number = 500, offset: number = 0, + fieldName?: string, ) { const column = FILTER_COLUMNS[type] || type; const { rawQuery, parseFilters } = prisma; @@ -32,19 +40,26 @@ async function relationalQuery( ); let excludeDomain = ''; + let joinEventData = ''; + let filterEventDataOnFieldName = ''; if (column === 'referrer_domain') { excludeDomain = 'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)'; + } else if (column === 'custom') { + joinEventData = 'join event_data on event_data.website_event_id = website_event.event_id'; + filterEventDataOnFieldName = 'and event_data.event_key = {{fieldName}}'; } return rawQuery( ` - select ${column} x, count(*) y + select ${column === 'custom' ? `event_data.string_value` : column} x, count(*) y from website_event + ${joinEventData} ${joinSession} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} + ${filterEventDataOnFieldName} ${excludeDomain} ${filterQuery} group by 1 @@ -52,7 +67,7 @@ async function relationalQuery( limit ${limit} offset ${offset} `, - params, + { ...params, fieldName }, ); } @@ -62,6 +77,8 @@ async function clickhouseQuery( filters: QueryFilters, limit: number = 500, offset: number = 0, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fieldName?: string, ): Promise<{ x: string; y: number }[]> { const column = FILTER_COLUMNS[type] || type; const { rawQuery, parseFilters } = clickhouse; diff --git a/src/tracker/index.js b/src/tracker/index.js index cc6b8dbbfe..f4c8ea1353 100644 --- a/src/tracker/index.js +++ b/src/tracker/index.js @@ -28,6 +28,8 @@ const endpoint = `${host.replace(/\/$/, '')}__COLLECT_API_ENDPOINT__`; const screen = `${width}x${height}`; const eventRegex = /data-umami-event-([\w-_]+)/; + const pageviewCustomPropertyRegex = /data-([\w-_]+)/; + const reservedDataAttributes = ['website-id', 'domains', 'umami-event', 'auto-track', 'host-url', 'exclude-search']; const eventNameAttribute = _data + 'umami-event'; const delayDuration = 300; @@ -61,6 +63,20 @@ return excludeSearch ? url.split('?')[0] : url; }; + const getPageviewEventData = () => + Object.fromEntries( + Array.from(currentScript.attributes) + .filter(attribute => attribute.name.match(pageviewCustomPropertyRegex)) + .filter( + attribute => + !reservedDataAttributes.some(reserved => _data + reserved === attribute.name), + ) + .map(attribute => { + const match = attribute.name.match(pageviewCustomPropertyRegex); + return [match[1], attribute.value]; + }), + ); + const getPayload = () => ({ website, hostname, @@ -230,7 +246,7 @@ } else if (typeof obj === 'function') { return send(obj(getPayload())); } - return send(getPayload()); + return send({ ...getPayload(), data: getPageviewEventData() }); }; const identify = data => send({ ...getPayload(), data }, 'identify');