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) {
return <h1>poop</h1>;
return null;
}
return (

View File

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

View File

@ -35,7 +35,7 @@ export function ShareUrl({
const url = `${hostUrl || process.env.hostUrl || window?.location.origin}${
process.env.basePath
}/share/${id}/${encodeURIComponent(domain)}`;
}/share/${id}/${domain}`;
const handleGenerate = () => {
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 FilterSelectForm from 'app/(main)/reports/[reportId]/FilterSelectForm';
import { useFields, useMessages, useNavigation } from 'components/hooks';
import { OPERATORS } from 'lib/constants';
export function WebsiteFilterButton({
websiteId,
@ -14,8 +15,18 @@ export function WebsiteFilterButton({
const { renderUrl, router } = useNavigation();
const { fields } = useFields();
const handleAddFilter = ({ name, value }) => {
router.push(renderUrl({ [name]: value }));
const handleAddFilter = ({ name, operator, 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 (
@ -26,7 +37,7 @@ export function WebsiteFilterButton({
</Icon>
<Text>{formatMessage(labels.filter)}</Text>
</Button>
<Popup position="bottom" alignment="start">
<Popup position="bottom" alignment="end">
{(close: () => void) => {
return (
<PopupForm>
@ -37,7 +48,6 @@ export function WebsiteFilterButton({
handleAddFilter(value);
close();
}}
allowFilterSelect={false}
/>
</PopupForm>
);

View File

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

View File

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

View File

@ -1,6 +1,13 @@
import { NextApiRequest } from 'next';
import { getAllowedUnits, getMinimumUnit } from './date';
import { getWebsiteDateRange } from '../queries';
import { FILTER_COLUMNS, OPERATORS } from 'lib/constants';
const OPERATOR_SYMBOLS = {
'!': 'neq',
'~': 'c',
'!~': 'dnc',
};
export async function parseDateRangeQuery(req: NextApiRequest) {
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,
};
}
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 { SESSION_COLUMNS, EVENT_COLUMNS, FILTER_COLUMNS, OPERATORS } from 'lib/constants';
import { getPageviewMetrics, getSessionMetrics } from 'queries';
import { parseDateRangeQuery } from 'lib/query';
import { getQueryFilters, parseDateRangeQuery } from 'lib/query';
import * as yup from 'yup';
export interface WebsiteMetricsRequestQuery {
@ -62,25 +62,7 @@ export default async (
await useAuth(req, res);
await useValidate(schema, req, res);
const {
websiteId,
type,
url,
referrer,
title,
query,
os,
browser,
device,
country,
region,
city,
language,
event,
limit,
offset,
search,
} = req.query;
const { websiteId, type, limit, offset, search } = req.query;
if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) {
@ -90,24 +72,13 @@ export default async (
const { startDate, endDate } = await parseDateRangeQuery(req);
const column = FILTER_COLUMNS[type] || type;
const filters = {
...getQueryFilters(req),
startDate,
endDate,
url,
referrer,
title,
query,
os,
browser,
device,
country,
region,
city,
language,
event,
};
if (search) {
filters[column] = {
filters[type] = {
column,
operator: OPERATORS.contains,
value: search,

View File

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

View File

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