Support contains queries in overview page.

This commit is contained in:
Mike Cao 2024-03-27 02:17:55 -07:00
commit d945ed3a23
12 changed files with 75 additions and 105 deletions

View File

@ -211,7 +211,7 @@ const ResultsMenu = ({ values, type, isLoading, onSelect }) => {
} }
if (!values?.length) { if (!values?.length) {
return <h1>poop</h1>; return null;
} }
return ( return (

View File

@ -1,5 +1,4 @@
.form { .form {
position: absolute;
background: var(--base50); background: var(--base50);
min-width: 300px; min-width: 300px;
padding: 20px; padding: 20px;

View File

@ -35,7 +35,7 @@ export function ShareUrl({
const url = `${hostUrl || process.env.hostUrl || window?.location.origin}${ const url = `${hostUrl || process.env.hostUrl || window?.location.origin}${
process.env.basePath process.env.basePath
}/share/${id}/${encodeURIComponent(domain)}`; }/share/${id}/${domain}`;
const handleGenerate = () => { const handleGenerate = () => {
setId(generateId()); setId(generateId());

View File

@ -2,6 +2,7 @@ import { Button, Icon, Icons, Popup, PopupTrigger, Text } from 'react-basics';
import PopupForm from 'app/(main)/reports/[reportId]/PopupForm'; import PopupForm from 'app/(main)/reports/[reportId]/PopupForm';
import FilterSelectForm from 'app/(main)/reports/[reportId]/FilterSelectForm'; import FilterSelectForm from 'app/(main)/reports/[reportId]/FilterSelectForm';
import { useFields, useMessages, useNavigation } from 'components/hooks'; import { useFields, useMessages, useNavigation } from 'components/hooks';
import { OPERATORS } from 'lib/constants';
export function WebsiteFilterButton({ export function WebsiteFilterButton({
websiteId, websiteId,
@ -14,8 +15,18 @@ export function WebsiteFilterButton({
const { renderUrl, router } = useNavigation(); const { renderUrl, router } = useNavigation();
const { fields } = useFields(); const { fields } = useFields();
const handleAddFilter = ({ name, value }) => { const handleAddFilter = ({ name, operator, value }) => {
router.push(renderUrl({ [name]: value })); let prefix = '';
if (operator === OPERATORS.notEquals) {
prefix = '!';
} else if (operator === OPERATORS.contains) {
prefix = '~';
} else if (operator === OPERATORS.doesNotContain) {
prefix = '!~';
}
router.push(renderUrl({ [name]: prefix + value }));
}; };
return ( return (
@ -26,7 +37,7 @@ export function WebsiteFilterButton({
</Icon> </Icon>
<Text>{formatMessage(labels.filter)}</Text> <Text>{formatMessage(labels.filter)}</Text>
</Button> </Button>
<Popup position="bottom" alignment="start"> <Popup position="bottom" alignment="end">
{(close: () => void) => { {(close: () => void) => {
return ( return (
<PopupForm> <PopupForm>
@ -37,7 +48,6 @@ export function WebsiteFilterButton({
handleAddFilter(value); handleAddFilter(value);
close(); close();
}} }}
allowFilterSelect={false}
/> />
</PopupForm> </PopupForm>
); );

View File

@ -47,24 +47,15 @@ export function WebsiteMetricsBar({
value={views.value} value={views.value}
change={views.change} change={views.change}
/> />
<MetricCard
label={formatMessage(labels.visitors)}
value={visitors.value}
change={visitors.change}
/>
<MetricCard <MetricCard
label={formatMessage(labels.visits)} label={formatMessage(labels.visits)}
value={visits.value} value={visits.value}
change={visits.change} change={visits.change}
/> />
<MetricCard <MetricCard
label={formatMessage(labels.viewsPerVisit)} label={formatMessage(labels.visitors)}
value={visits.value ? views.value / visits.value : 0} value={visitors.value}
change={ change={visitors.change}
visits.value && visits.change
? views.value / visits.value - diffs.views / diffs.visits
: 0
}
/> />
<MetricCard <MetricCard
label={formatMessage(labels.bounceRate)} label={formatMessage(labels.bounceRate)}

View File

@ -117,7 +117,7 @@ export function DateFilter({
); );
} }
return options.find(e => e.value === value).label; return options.find(e => e.value === value)?.label;
}; };
return ( return (

View File

@ -62,30 +62,32 @@ function getDateFormat(date: Date) {
} }
function mapFilter(column: string, operator: string, name: string, type: string = 'String') { function mapFilter(column: string, operator: string, name: string, type: string = 'String') {
const value = `{${name}:${type}}`;
switch (operator) { switch (operator) {
case OPERATORS.equals: case OPERATORS.equals:
return `${column} = {${name}:${type}}`; return `${column} = ${value}`;
case OPERATORS.notEquals: case OPERATORS.notEquals:
return `${column} != {${name}:${type}}`; return `${column} != ${value}`;
case OPERATORS.contains: case OPERATORS.contains:
return `positionCaseInsensitive(${column}, {${name}:${type}}) > 0`; return `positionCaseInsensitive(${column}, ${value}) > 0`;
case OPERATORS.doesNotContain: case OPERATORS.doesNotContain:
return `positionCaseInsensitive(${column}, {${name}:${type}}) = 0`; return `positionCaseInsensitive(${column}, ${value}) = 0`;
default: default:
return ''; return '';
} }
} }
function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) { function getFilterQuery(filters: QueryFilters = {}, options: QueryOptions = {}) {
const query = Object.keys(filters).reduce((arr, name) => { const query = Object.keys(filters).reduce((arr, key) => {
const value = filters[name]; const filter = filters[key];
const filter = value?.filter ?? OPERATORS.equals; const operator = filter?.operator ?? OPERATORS.equals;
const column = value?.column ?? FILTER_COLUMNS[name] ?? options?.columns?.[name]; const column = filter?.column ?? FILTER_COLUMNS[key] ?? options?.columns?.[key];
if (value !== undefined && column !== undefined) { if (filter !== undefined && column !== undefined) {
arr.push(`and ${mapFilter(column, filter, name)}`); arr.push(`and ${mapFilter(column, operator, key)}`);
if (name === 'referrer') { if (key === 'referrer') {
arr.push('and referrer_domain != {websiteDomain:String}'); arr.push('and referrer_domain != {websiteDomain:String}');
} }
} }

View File

@ -1,6 +1,13 @@
import { NextApiRequest } from 'next'; import { NextApiRequest } from 'next';
import { getAllowedUnits, getMinimumUnit } from './date'; import { getAllowedUnits, getMinimumUnit } from './date';
import { getWebsiteDateRange } from '../queries'; import { getWebsiteDateRange } from '../queries';
import { FILTER_COLUMNS, OPERATORS } from 'lib/constants';
const OPERATOR_SYMBOLS = {
'!': 'neq',
'~': 'c',
'!~': 'dnc',
};
export async function parseDateRangeQuery(req: NextApiRequest) { export async function parseDateRangeQuery(req: NextApiRequest) {
const { websiteId, startAt, endAt, unit } = req.query; const { websiteId, startAt, endAt, unit } = req.query;
@ -29,3 +36,28 @@ export async function parseDateRangeQuery(req: NextApiRequest) {
unit: (getAllowedUnits(startDate, endDate).includes(unit as string) ? unit : minUnit) as string, unit: (getAllowedUnits(startDate, endDate).includes(unit as string) ? unit : minUnit) as string,
}; };
} }
export function getQueryFilters(req: NextApiRequest) {
return Object.keys(FILTER_COLUMNS).reduce((obj, key) => {
const value = req.query[key];
if (value) {
obj[key] = value;
}
if (typeof value === 'string') {
const [, prefix, paramValue] = value.match(/^(!~|!|~)?(.*)$/);
if (prefix && paramValue) {
obj[key] = {
name: key,
column: FILTER_COLUMNS[key],
operator: OPERATOR_SYMBOLS[prefix] || OPERATORS.equals,
value: paramValue,
};
}
}
return obj;
}, {});
}

View File

@ -5,7 +5,7 @@ import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware'; import { useAuth, useCors, useValidate } from 'lib/middleware';
import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants'; import { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants';
import { getPageviewMetrics, getSessionMetrics } from 'queries'; import { getPageviewMetrics, getSessionMetrics } from 'queries';
import { parseDateRangeQuery } from 'lib/query'; import { getQueryFilters, parseDateRangeQuery } from 'lib/query';
import * as yup from 'yup'; import * as yup from 'yup';
export interface WebsiteMetricsRequestQuery { export interface WebsiteMetricsRequestQuery {
@ -62,25 +62,7 @@ export default async (
await useAuth(req, res); await useAuth(req, res);
await useValidate(schema, req, res); await useValidate(schema, req, res);
const { const { websiteId, type, limit, offset, search } = req.query;
websiteId,
type,
url,
referrer,
title,
query,
os,
browser,
device,
country,
region,
city,
language,
event,
limit,
offset,
search,
} = req.query;
if (req.method === 'GET') { if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) { if (!(await canViewWebsite(req.auth, websiteId))) {
@ -90,24 +72,13 @@ export default async (
const { startDate, endDate } = await parseDateRangeQuery(req); const { startDate, endDate } = await parseDateRangeQuery(req);
const column = FILTER_COLUMNS[type] || type; const column = FILTER_COLUMNS[type] || type;
const filters = { const filters = {
...getQueryFilters(req),
startDate, startDate,
endDate, endDate,
url,
referrer,
title,
query,
os,
browser,
device,
country,
region,
city,
language,
event,
}; };
if (search) { if (search) {
filters[column] = { filters[type] = {
column, column,
operator: OPERATORS.contains, operator: OPERATORS.contains,
value: search, value: search,

View File

@ -1,6 +1,6 @@
import { canViewWebsite } from 'lib/auth'; import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware'; import { useAuth, useCors, useValidate } from 'lib/middleware';
import { parseDateRangeQuery } from 'lib/query'; import { getQueryFilters, parseDateRangeQuery } from 'lib/query';
import { NextApiRequestQueryBody, WebsitePageviews } from 'lib/types'; import { NextApiRequestQueryBody, WebsitePageviews } from 'lib/types';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { methodNotAllowed, ok, unauthorized } from 'next-basics';
@ -52,8 +52,7 @@ export default async (
await useAuth(req, res); await useAuth(req, res);
await useValidate(schema, req, res); await useValidate(schema, req, res);
const { websiteId, timezone, url, referrer, title, os, browser, device, country, region, city } = const { websiteId, timezone } = req.query;
req.query;
if (req.method === 'GET') { if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) { if (!(await canViewWebsite(req.auth, websiteId))) {
@ -63,19 +62,11 @@ export default async (
const { startDate, endDate, unit } = await parseDateRangeQuery(req); const { startDate, endDate, unit } = await parseDateRangeQuery(req);
const filters = { const filters = {
...getQueryFilters(req),
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit, unit,
url,
referrer,
title,
os,
browser,
device,
country,
region,
city,
}; };
const [pageviews, sessions] = await Promise.all([ const [pageviews, sessions] = await Promise.all([

View File

@ -4,7 +4,7 @@ import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { canViewWebsite } from 'lib/auth'; import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware'; import { useAuth, useCors, useValidate } from 'lib/middleware';
import { NextApiRequestQueryBody, WebsiteStats } from 'lib/types'; import { NextApiRequestQueryBody, WebsiteStats } from 'lib/types';
import { parseDateRangeQuery } from 'lib/query'; import { getQueryFilters, parseDateRangeQuery } from 'lib/query';
import { getWebsiteStats } from 'queries'; import { getWebsiteStats } from 'queries';
export interface WebsiteStatsRequestQuery { export interface WebsiteStatsRequestQuery {
@ -52,20 +52,7 @@ export default async (
await useAuth(req, res); await useAuth(req, res);
await useValidate(schema, req, res); await useValidate(schema, req, res);
const { const { websiteId } = req.query;
websiteId,
url,
referrer,
title,
query,
event,
os,
browser,
device,
country,
region,
city,
}: any & { websiteId: string } = req.query;
if (req.method === 'GET') { if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) { if (!(await canViewWebsite(req.auth, websiteId))) {
@ -77,19 +64,7 @@ export default async (
const prevStartDate = subMinutes(startDate, diff); const prevStartDate = subMinutes(startDate, diff);
const prevEndDate = subMinutes(endDate, diff); const prevEndDate = subMinutes(endDate, diff);
const filters = { const filters = getQueryFilters(req);
url,
referrer,
title,
query,
event,
os,
browser,
device,
country,
region,
city,
};
const metrics = await getWebsiteStats(websiteId, { ...filters, startDate, endDate }); const metrics = await getWebsiteStats(websiteId, { ...filters, startDate, endDate });

View File

@ -70,7 +70,6 @@ async function clickhouseQuery(
offset: number = 0, offset: number = 0,
): Promise<{ x: string; y: number }[]> { ): Promise<{ x: string; y: number }[]> {
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const { filterQuery, params } = await parseFilters(websiteId, { const { filterQuery, params } = await parseFilters(websiteId, {
...filters, ...filters,
eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,