Merge branch 'dev' of https://github.com/umami-software/umami into feat/um-376-retention-report

This commit is contained in:
Francis Cao 2023-08-08 12:04:00 -07:00
commit 72248ff04f
14 changed files with 130 additions and 72 deletions

View File

@ -1,16 +1,17 @@
import { useRouter } from 'next/router';
import FilterLink from 'components/common/FilterLink'; import FilterLink from 'components/common/FilterLink';
import MetricsTable from 'components/metrics/MetricsTable'; import MetricsTable from 'components/metrics/MetricsTable';
import { BROWSERS } from 'lib/constants';
import useMessages from 'hooks/useMessages'; import useMessages from 'hooks/useMessages';
import { useRouter } from 'next/router'; import useFormat from 'hooks/useFormat';
export function BrowsersTable({ websiteId, ...props }) { export function BrowsersTable({ websiteId, ...props }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { basePath } = useRouter(); const { basePath } = useRouter();
const { formatBrowser } = useFormat();
function renderLink({ x: browser }) { function renderLink({ x: browser }) {
return ( return (
<FilterLink id="browser" value={browser} label={BROWSERS[browser] || browser}> <FilterLink id="browser" value={browser} label={formatBrowser(browser)}>
<img <img
src={`${basePath}/images/browsers/${browser || 'unknown'}.png`} src={`${basePath}/images/browsers/${browser || 'unknown'}.png`}
alt={browser} alt={browser}

View File

@ -1,8 +1,7 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import FilterLink from 'components/common/FilterLink'; import FilterLink from 'components/common/FilterLink';
import useCountryNames from 'hooks/useCountryNames'; import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale'; import { useLocale, useMessages, useFormat } from 'hooks';
import useMessages from 'hooks/useMessages';
import MetricsTable from './MetricsTable'; import MetricsTable from './MetricsTable';
export function CountriesTable({ websiteId, ...props }) { export function CountriesTable({ websiteId, ...props }) {
@ -10,6 +9,7 @@ export function CountriesTable({ websiteId, ...props }) {
const countryNames = useCountryNames(locale); const countryNames = useCountryNames(locale);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { basePath } = useRouter(); const { basePath } = useRouter();
const { formatCountry } = useFormat();
function renderLink({ x: code }) { function renderLink({ x: code }) {
return ( return (
@ -17,7 +17,7 @@ export function CountriesTable({ websiteId, ...props }) {
id="country" id="country"
className={locale} className={locale}
value={countryNames[code] && code} value={countryNames[code] && code}
label={countryNames[code]} label={formatCountry(code)}
> >
<img src={`${basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} /> <img src={`${basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} />
</FilterLink> </FilterLink>

View File

@ -2,18 +2,16 @@ import MetricsTable from './MetricsTable';
import FilterLink from 'components/common/FilterLink'; import FilterLink from 'components/common/FilterLink';
import useMessages from 'hooks/useMessages'; import useMessages from 'hooks/useMessages';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useFormat } from 'hooks';
export function DevicesTable({ websiteId, ...props }) { export function DevicesTable({ websiteId, ...props }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { basePath } = useRouter(); const { basePath } = useRouter();
const { formatDevice } = useFormat();
function renderLink({ x: device }) { function renderLink({ x: device }) {
return ( return (
<FilterLink <FilterLink id="device" value={labels[device] && device} label={formatDevice(device)}>
id="device"
value={labels[device] && device}
label={formatMessage(labels[device] || labels.unknown)}
>
<img <img
src={`${basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`} src={`${basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`}
alt={device} alt={device}

View File

@ -2,14 +2,14 @@ import { Menu, Item, Form, FormRow } from 'react-basics';
import { useMessages } from 'hooks'; import { useMessages } from 'hooks';
import styles from './FieldSelectForm.module.css'; import styles from './FieldSelectForm.module.css';
export default function FieldSelectForm({ fields, onSelect }) { export default function FieldSelectForm({ items, onSelect }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<Form> <Form>
<FormRow label={formatMessage(labels.fields)}> <FormRow label={formatMessage(labels.fields)}>
<Menu className={styles.menu} onSelect={key => onSelect(fields[key])}> <Menu className={styles.menu} onSelect={key => onSelect(items[key])}>
{fields.map(({ label, name, type }, index) => { {items.map(({ name, label, type }, index) => {
return ( return (
<Item key={index} className={styles.item}> <Item key={index} className={styles.item}>
<div>{label || name}</div> <div>{label || name}</div>

View File

@ -2,11 +2,11 @@ import { useState } from 'react';
import FieldSelectForm from './FieldSelectForm'; import FieldSelectForm from './FieldSelectForm';
import FieldFilterForm from './FieldFilterForm'; import FieldFilterForm from './FieldFilterForm';
export default function FilterSelectForm({ fields, onSelect }) { export default function FilterSelectForm({ items, onSelect }) {
const [field, setField] = useState(); const [field, setField] = useState();
if (!field) { if (!field) {
return <FieldSelectForm fields={fields} onSelect={setField} />; return <FieldSelectForm items={items} onSelect={setField} />;
} }
return <FieldFilterForm name={field.name} type={field.type} onSelect={onSelect} />; return <FieldFilterForm name={field.name} type={field.type} onSelect={onSelect} />;

View File

@ -2,7 +2,6 @@ import { useContext, useRef } from 'react';
import { useMessages } from 'hooks'; import { useMessages } from 'hooks';
import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics'; import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics';
import { ReportContext } from 'components/pages/reports/Report'; import { ReportContext } from 'components/pages/reports/Report';
import { REPORT_PARAMETERS } from 'lib/constants';
import Icons from 'components/icons'; import Icons from 'components/icons';
import BaseParameters from '../BaseParameters'; import BaseParameters from '../BaseParameters';
import ParameterList from '../ParameterList'; import ParameterList from '../ParameterList';
@ -16,52 +15,52 @@ export function InsightsParameters() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const ref = useRef(null); const ref = useRef(null);
const { parameters } = report || {}; const { parameters } = report || {};
const { websiteId, dateRange, filters, groups } = parameters || {}; const { websiteId, dateRange, fields, filters } = parameters || {};
const queryEnabled = websiteId && dateRange && (filters?.length || groups?.length); const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length);
const fieldOptions = [ const fieldOptions = [
{ name: 'url_path', type: 'string', label: formatMessage(labels.url) }, { name: 'url_path', label: formatMessage(labels.url) },
{ name: 'page_title', type: 'string', label: formatMessage(labels.pageTitle) }, { name: 'page_title', label: formatMessage(labels.pageTitle) },
{ name: 'referrer_domain', type: 'string', label: formatMessage(labels.referrer) }, { name: 'referrer_domain', label: formatMessage(labels.referrer) },
{ name: 'url_query', type: 'string', label: formatMessage(labels.query) }, { name: 'url_query', label: formatMessage(labels.query) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) }, { name: 'browser', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) }, { name: 'os', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) }, { name: 'device', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) }, { name: 'country', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) }, { name: 'region', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) }, { name: 'city', label: formatMessage(labels.city) },
{ name: 'language', type: 'string', label: formatMessage(labels.language) }, { name: 'language', label: formatMessage(labels.language) },
]; ];
const parameterGroups = [ const parameterGroups = [
{ label: formatMessage(labels.breakdown), group: REPORT_PARAMETERS.groups }, { id: 'fields', label: formatMessage(labels.fields) },
{ label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters }, { id: 'filters', label: formatMessage(labels.filters) },
]; ];
const parameterData = { const parameterData = {
fields,
filters, filters,
groups,
}; };
const handleSubmit = values => { const handleSubmit = values => {
runReport(values); runReport(values);
}; };
const handleAdd = (group, value) => { const handleAdd = (id, value) => {
const data = parameterData[group]; const data = parameterData[id];
if (!data.find(({ name }) => name === value.name)) { if (!data.find(({ name }) => name === value.name)) {
updateReport({ parameters: { [group]: data.concat(value) } }); updateReport({ parameters: { [id]: data.concat(value) } });
} }
}; };
const handleRemove = (group, index) => { const handleRemove = (id, index) => {
const data = [...parameterData[group]]; const data = [...parameterData[id]];
data.splice(index, 1); data.splice(index, 1);
updateReport({ parameters: { [group]: data } }); updateReport({ parameters: { [id]: data } });
}; };
const AddButton = ({ group }) => { const AddButton = ({ id }) => {
return ( return (
<PopupTrigger> <PopupTrigger>
<Icon> <Icon>
@ -71,11 +70,11 @@ export function InsightsParameters() {
{(close, element) => { {(close, element) => {
return ( return (
<PopupForm element={element} onClose={close}> <PopupForm element={element} onClose={close}>
{group === REPORT_PARAMETERS.groups && ( {id === 'fields' && (
<FieldSelectForm fields={fieldOptions} onSelect={handleAdd.bind(null, group)} /> <FieldSelectForm items={fieldOptions} onSelect={handleAdd.bind(null, id)} />
)} )}
{group === REPORT_PARAMETERS.filters && ( {id === 'filters' && (
<FilterSelectForm fields={fieldOptions} onSelect={handleAdd.bind(null, group)} /> <FilterSelectForm items={fieldOptions} onSelect={handleAdd.bind(null, id)} />
)} )}
</PopupForm> </PopupForm>
); );
@ -88,22 +87,19 @@ export function InsightsParameters() {
return ( return (
<Form ref={ref} values={parameters} onSubmit={handleSubmit}> <Form ref={ref} values={parameters} onSubmit={handleSubmit}>
<BaseParameters /> <BaseParameters />
{parameterGroups.map(({ label, group }) => { {parameterGroups.map(({ id, label }) => {
return ( return (
<FormRow key={label} label={label} action={<AddButton group={group} onAdd={handleAdd} />}> <FormRow key={label} label={label} action={<AddButton id={id} onAdd={handleAdd} />}>
<ParameterList <ParameterList items={parameterData[id]} onRemove={index => handleRemove(id, index)}>
items={parameterData[group]}
onRemove={index => handleRemove(group, index)}
>
{({ value, label }) => { {({ value, label }) => {
return ( return (
<div className={styles.parameter}> <div className={styles.parameter}>
{group === REPORT_PARAMETERS.groups && ( {id === 'fields' && (
<> <>
<div>{label}</div> <div>{label}</div>
</> </>
)} )}
{group === REPORT_PARAMETERS.filters && ( {id === 'filters' && (
<> <>
<div>{label}</div> <div>{label}</div>
<div className={styles.op}>{value[0]}</div> <div className={styles.op}>{value[0]}</div>

View File

@ -1,20 +1,42 @@
import { useContext } from 'react'; import { useContext, useEffect, useState } from 'react';
import { GridTable, GridColumn } from 'react-basics'; import { GridTable, GridColumn } from 'react-basics';
import { useMessages } from 'hooks'; import { useFormat, useMessages } from 'hooks';
import { ReportContext } from '../Report'; import { ReportContext } from '../Report';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
export function InsightsTable() { export function InsightsTable() {
const [fields, setFields] = useState();
const { report } = useContext(ReportContext); const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { groups = [] } = report?.parameters || {}; const { formatValue } = useFormat();
useEffect(
() => {
setFields(report?.parameters?.fields);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[report?.data],
);
if (!fields) {
return <EmptyPlaceholder />;
}
return ( return (
<GridTable data={report?.data || []}> <GridTable data={report?.data || []}>
{groups.map(({ name, label }) => { {fields.map(({ name, label }) => {
return <GridColumn key={name} name={name} label={label} />; return (
<GridColumn key={name} name={name} label={label}>
{row => formatValue(row[name], name)}
</GridColumn>
);
})} })}
<GridColumn name="views" label={formatMessage(labels.views)} width="100px" /> <GridColumn name="visitors" label={formatMessage(labels.visitors)} width="100px">
<GridColumn name="visitors" label={formatMessage(labels.visitors)} width="100px" /> {row => row.visitors.toLocaleString()}
</GridColumn>
<GridColumn name="views" label={formatMessage(labels.views)} width="100px">
{row => row.views.toLocaleString()}
</GridColumn>
</GridTable> </GridTable>
); );
} }

View File

@ -6,6 +6,7 @@ export * from './useDocumentClick';
export * from './useEscapeKey'; export * from './useEscapeKey';
export * from './useFilters'; export * from './useFilters';
export * from './useForceUpdate'; export * from './useForceUpdate';
export * from './useFormat';
export * from './useLanguageNames'; export * from './useLanguageNames';
export * from './useLocale'; export * from './useLocale';
export * from './useMessages'; export * from './useMessages';

39
hooks/useFormat.js Normal file
View File

@ -0,0 +1,39 @@
import useMessages from './useMessages';
import { BROWSERS } from 'lib/constants';
import useLocale from './useLocale';
import useCountryNames from './useCountryNames';
export function useFormat() {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const formatBrowser = value => {
return BROWSERS[value] || value;
};
const formatCountry = value => {
return countryNames[value] || value;
};
const formatDevice = value => {
return formatMessage(labels[value] || labels.unknown);
};
const formatValue = (value, type) => {
switch (type) {
case 'browser':
return formatBrowser(value);
case 'country':
return formatCountry(value);
case 'device':
return formatDevice(value);
default:
return value;
}
};
return { formatBrowser, formatCountry, formatDevice, formatValue };
}
export default useFormat;

View File

@ -4,11 +4,11 @@ import { messages, labels } from 'components/messages';
export function useMessages() { export function useMessages() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
function getMessage(id) { const getMessage = id => {
const message = Object.values(messages).find(value => value.id === id); const message = Object.values(messages).find(value => value.id === id);
return message ? formatMessage(message) : id; return message ? formatMessage(message) : id;
} };
return { formatMessage, FormattedMessage, messages, labels, getMessage }; return { formatMessage, FormattedMessage, messages, labels, getMessage };
} }

View File

@ -25,7 +25,7 @@ export function flattenJSON(
).keyValues; ).keyValues;
} }
export function getDynamicDataType(value: any): string { export function getDataType(value: any): string {
let type: string = typeof value; let type: string = typeof value;
if ((type === 'string' && isValid(value)) || isValid(parseISO(value))) { if ((type === 'string' && isValid(value)) || isValid(parseISO(value))) {
@ -36,7 +36,7 @@ export function getDynamicDataType(value: any): string {
} }
function createKey(key, value, acc: { keyValues: any[]; parentKey: string }) { function createKey(key, value, acc: { keyValues: any[]; parentKey: string }) {
const type = getDynamicDataType(value); const type = getDataType(value);
let dynamicDataType = null; let dynamicDataType = null;

View File

@ -27,7 +27,7 @@ export default async (
const { const {
websiteId, websiteId,
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
groups, fields,
filters, filters,
} = req.body; } = req.body;
@ -35,7 +35,7 @@ export default async (
return unauthorized(res); return unauthorized(res);
} }
const data = await getInsights(websiteId, groups, { const data = await getInsights(websiteId, fields, {
...filters, ...filters,
startDate: new Date(startDate), startDate: new Date(startDate),
endDate: new Date(endDate), endDate: new Date(endDate),

View File

@ -25,7 +25,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
event_data.event_key as "fieldName", event_data.event_key as "fieldName",
event_data.data_type as "dataType", event_data.data_type as "dataType",
event_data.string_value as "value", event_data.string_value as "value",
count(*) as total count(*) as "total"
from event_data from event_data
inner join website_event inner join website_event
on website_event.event_id = event_data.website_event_id on website_event.event_id = event_data.website_event_id

View File

@ -5,7 +5,7 @@ import { EVENT_TYPE } from 'lib/constants';
import { QueryFilters } from 'lib/types'; import { QueryFilters } from 'lib/types';
export async function getInsights( export async function getInsights(
...args: [websiteId: string, groups: { name: string; type: string }[], filters: QueryFilters] ...args: [websiteId: string, fields: { name: string; type?: string }[], filters: QueryFilters]
) { ) {
return runQuery({ return runQuery({
[PRISMA]: () => relationalQuery(...args), [PRISMA]: () => relationalQuery(...args),
@ -15,7 +15,7 @@ export async function getInsights(
async function relationalQuery( async function relationalQuery(
websiteId: string, websiteId: string,
groups: { name: string; type: string }[], fields: { name: string; type?: string }[],
filters: QueryFilters, filters: QueryFilters,
): Promise< ): Promise<
{ {
@ -41,6 +41,7 @@ async function relationalQuery(
and website_event.event_type = {{eventType}} and website_event.event_type = {{eventType}}
${filterQuery} ${filterQuery}
group by 1 group by 1
limit 500
`, `,
params, params,
); );
@ -48,7 +49,7 @@ async function relationalQuery(
async function clickhouseQuery( async function clickhouseQuery(
websiteId: string, websiteId: string,
groups: { name: string; type: string }[], fields: { name: string; type?: string }[],
filters: QueryFilters, filters: QueryFilters,
): Promise< ): Promise<
{ {
@ -65,14 +66,14 @@ async function clickhouseQuery(
return rawQuery( return rawQuery(
` `
select select
${parseFields(groups)} ${parseFields(fields)}
from website_event from website_event
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime} and {endDate:DateTime} and created_at between {startDate:DateTime} and {endDate:DateTime}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}
${filterQuery} ${filterQuery}
group by ${groups.map(({ name }) => name).join(',')} group by ${fields.map(({ name }) => name).join(',')}
order by 1 desc order by 1 desc, 2 desc
limit 500 limit 500
`, `,
params, params,