Update insights report parameters. Added contains logic.

This commit is contained in:
Mike Cao 2024-03-22 23:33:45 -07:00
parent d59477deb5
commit 5daad2726e
15 changed files with 280 additions and 190 deletions

View file

@ -1,7 +1,7 @@
.popup { .popup {
display: flex; display: flex;
max-width: 300px; max-width: 300px;
max-height: 400px; max-height: 210px;
overflow-x: hidden; overflow-x: hidden;
} }
@ -19,6 +19,6 @@
min-width: 180px; min-width: 180px;
} }
.menu { .text {
min-width: 200px; min-width: 180px;
} }

View file

@ -1,6 +1,19 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { Form, FormRow, Item, Flexbox, Dropdown, Button } from 'react-basics'; import {
Form,
FormRow,
Item,
Flexbox,
Dropdown,
Button,
TextField,
Menu,
Popup,
PopupTrigger,
} from 'react-basics';
import { useMessages, useFilters, useFormat, useLocale } from 'components/hooks'; import { useMessages, useFilters, useFormat, useLocale } from 'components/hooks';
import { safeDecodeURIComponent } from 'next-basics';
import { OPERATORS } from 'lib/constants';
import styles from './FieldFilterForm.module.css'; import styles from './FieldFilterForm.module.css';
export interface FieldFilterFormProps { export interface FieldFilterFormProps {
@ -22,12 +35,11 @@ export default function FieldFilterForm({
}: FieldFilterFormProps) { }: FieldFilterFormProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [filter, setFilter] = useState('eq'); const [filter, setFilter] = useState('eq');
const [value, setValue] = useState(); const [value, setValue] = useState('');
const { getFilters } = useFilters(); const { getFilters } = useFilters();
const { formatValue } = useFormat(); const { formatValue } = useFormat();
const { locale } = useLocale(); const { locale } = useLocale();
const filters = getFilters(type); const filters = getFilters(type);
const [search, setSearch] = useState('');
const formattedValues = useMemo(() => { const formattedValues = useMemo(() => {
const formatted = {}; const formatted = {};
@ -45,21 +57,25 @@ export default function FieldFilterForm({
}, [formatValue, locale, name, values]); }, [formatValue, locale, name, values]);
const filteredValues = useMemo(() => { const filteredValues = useMemo(() => {
return search ? values.filter(n => n.includes(search)) : values; return value ? values.filter(n => n.includes(value)) : values;
}, [search, formattedValues]); }, [value, formattedValues]);
const renderFilterValue = value => { const renderFilterValue = value => {
return filters.find(f => f.value === value)?.label; return filters.find(f => f.value === value)?.label;
}; };
const renderValue = value => {
return formattedValues[value];
};
const handleAdd = () => { const handleAdd = () => {
onSelect({ name, type, filter, value }); onSelect({ name, type, filter, value });
}; };
const handleMenuSelect = value => {
setValue(value);
};
const showMenu =
[OPERATORS.equals, OPERATORS.notEquals].includes(filter as any) &&
!(filteredValues.length === 1 && filteredValues[0] === value);
return ( return (
<Form> <Form>
<FormRow label={label} className={styles.filter}> <FormRow label={label} className={styles.filter}>
@ -77,21 +93,24 @@ export default function FieldFilterForm({
}} }}
</Dropdown> </Dropdown>
)} )}
<Dropdown <PopupTrigger>
className={styles.dropdown} <TextField
popupProps={{ className: styles.popup }} className={styles.text}
menuProps={{ className: styles.menu }} value={decodeURIComponent(value)}
items={filteredValues} onChange={e => setValue(e.target.value)}
value={value} />
renderValue={renderValue} {showMenu && (
onChange={(key: any) => setValue(key)} <Popup className={styles.popup} alignment="end">
allowSearch={true} {filteredValues.length > 0 && (
onSearch={setSearch} <Menu variant="popup" onSelect={handleMenuSelect}>
> {filteredValues.map(value => {
{(value: string) => { return <Item key={value}>{safeDecodeURIComponent(value)}</Item>;
return <Item key={value}>{formattedValues[value]}</Item>; })}
}} </Menu>
</Dropdown> )}
</Popup>
)}
</PopupTrigger>
</Flexbox> </Flexbox>
<Button variant="primary" onClick={handleAdd} disabled={!filter || !value}> <Button variant="primary" onClick={handleAdd} disabled={!filter || !value}>
{formatMessage(labels.add)} {formatMessage(labels.add)}

View file

@ -24,14 +24,14 @@ function useValues(websiteId: string, type: string) {
export interface FilterSelectFormProps { export interface FilterSelectFormProps {
websiteId: string; websiteId: string;
items: any[]; fields: any[];
onSelect?: (key: any) => void; onSelect?: (key: any) => void;
allowFilterSelect?: boolean; allowFilterSelect?: boolean;
} }
export default function FilterSelectForm({ export default function FilterSelectForm({
websiteId, websiteId,
items, fields,
onSelect, onSelect,
allowFilterSelect, allowFilterSelect,
}: FilterSelectFormProps) { }: FilterSelectFormProps) {
@ -39,7 +39,7 @@ export default function FilterSelectForm({
const { data, isLoading } = useValues(websiteId, field?.name); const { data, isLoading } = useValues(websiteId, field?.name);
if (!field) { if (!field) {
return <FieldSelectForm fields={items} onSelect={setField} showType={false} />; return <FieldSelectForm fields={fields} onSelect={setField} showType={false} />;
} }
if (isLoading) { if (isLoading) {

View file

@ -13,9 +13,4 @@
border: 1px solid var(--base400); border: 1px solid var(--base400);
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base400); box-shadow: 1px 1px 1px var(--base400);
gap: 10px;
}
.icon {
align-self: center;
} }

View file

@ -1,40 +1,36 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Icon, TooltipPopup } from 'react-basics'; import { Icon } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import Empty from 'components/common/Empty'; import Empty from 'components/common/Empty';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import styles from './ParameterList.module.css'; import styles from './ParameterList.module.css';
export interface ParameterListProps { export interface ParameterListProps {
items: any[]; children?: ReactNode;
children?: ReactNode | ((item: any) => ReactNode);
onRemove: (index: number, e: any) => void;
} }
export function ParameterList({ items = [], children, onRemove }: ParameterListProps) { export function ParameterList({ children }: ParameterListProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<div className={styles.list}> <div className={styles.list}>
{!items.length && <Empty message={formatMessage(labels.none)} />} {!children && <Empty message={formatMessage(labels.none)} />}
{items.map((item, index) => { {children}
return (
<div key={index} className={styles.item}>
{typeof children === 'function' ? children(item) : item}
<TooltipPopup
className={styles.icon}
label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={onRemove.bind(null, index)}>
<Icons.Close />
</Icon>
</TooltipPopup>
</div>
);
})}
</div> </div>
); );
} }
const Item = ({ children, onRemove }: { children?: ReactNode; onRemove?: () => void }) => {
return (
<div className={styles.item}>
{children}
<Icon onClick={onRemove}>
<Icons.Close />
</Icon>
</div>
);
};
ParameterList.Item = Item;
export default ParameterList; export default ParameterList;

View file

@ -2,4 +2,5 @@
display: grid; display: grid;
grid-template-rows: max-content 1fr; grid-template-rows: max-content 1fr;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr;
margin-bottom: 60px;
} }

View file

@ -0,0 +1,75 @@
import { useMessages } from 'components/hooks';
import Icons from 'components/icons';
import { useContext } from 'react';
import { Button, FormRow, Icon, Popup, PopupTrigger } from 'react-basics';
import FieldSelectForm from '../[reportId]/FieldSelectForm';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report';
export function InsightsFieldParameters() {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { parameters } = report || {};
const { fields } = parameters || {};
const fieldOptions = [
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
];
const handleAdd = (value: { name: any }) => {
if (!fields.find(({ name }) => name === value.name)) {
updateReport({ parameters: { fields: fields.concat(value) } });
}
};
const handleRemove = (name: string) => {
updateReport({ parameters: { fields: fields.filter(f => f.name !== name) } });
};
const AddButton = () => {
return (
<PopupTrigger>
<Button size="sm">
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup position="bottom" alignment="start">
<PopupForm>
<FieldSelectForm
fields={fieldOptions.filter(({ name }) => !fields.find(f => f.name === name))}
onSelect={handleAdd}
showType={false}
/>
</PopupForm>
</Popup>
</PopupTrigger>
);
};
return (
<FormRow label={formatMessage(labels.fields)} action={<AddButton />}>
<ParameterList>
{fields.map(({ name }) => {
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
{fieldOptions.find(f => f.name === name)?.label}
</ParameterList.Item>
);
})}
</ParameterList>
</FormRow>
);
}
export default InsightsFieldParameters;

View file

@ -0,0 +1,36 @@
.item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
overflow: hidden;
}
.label {
color: var(--base800);
border: 1px solid var(--base300);
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
white-space: nowrap;
}
.filter {
color: var(--blue900);
background-color: var(--blue100);
font-size: 12px;
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
text-transform: uppercase;
white-space: nowrap;
}
.value {
color: var(--base900);
background-color: var(--base100);
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
white-space: nowrap;
}

View file

@ -0,0 +1,88 @@
import { useMessages, useFormat, useFilters } from 'components/hooks';
import Icons from 'components/icons';
import { useContext } from 'react';
import { Button, FormRow, Icon, Popup, PopupTrigger } from 'react-basics';
import FilterSelectForm from '../[reportId]/FilterSelectForm';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report';
import styles from './InsightsFilterParameters.module.css';
import { safeDecodeURIComponent } from 'next-basics';
import { OPERATORS } from 'lib/constants';
export function InsightsFilterParameters() {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { filterLabels } = useFilters();
const { parameters } = report || {};
const { websiteId, filters } = parameters || {};
const fieldOptions = [
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
];
const handleAdd = (value: { name: any }) => {
if (!filters.find(({ name }) => name === value.name)) {
updateReport({ parameters: { filters: filters.concat(value) } });
}
};
const handleRemove = (name: string) => {
updateReport({ parameters: { filters: filters.filter(f => f.name !== name) } });
};
const AddButton = () => {
return (
<PopupTrigger>
<Button size="sm">
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup position="bottom" alignment="start">
<PopupForm>
<FilterSelectForm
websiteId={websiteId}
fields={fieldOptions.filter(({ name }) => !filters.find(f => f.name === name))}
onSelect={handleAdd}
/>
</PopupForm>
</Popup>
</PopupTrigger>
);
};
return (
<FormRow label={formatMessage(labels.filters)} action={<AddButton />}>
<ParameterList>
{filters.map(({ name, filter, value }) => {
const label = fieldOptions.find(f => f.name === name)?.label;
const isEquals = [OPERATORS.equals, OPERATORS.notEquals].includes(filter);
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
<div className={styles.item}>
<div className={styles.label}>{label}</div>
<div className={styles.filter}>{filterLabels[filter]}</div>
<div className={styles.value}>
{safeDecodeURIComponent(isEquals ? formatValue(value, name) : value)}
</div>
</div>
</ParameterList.Item>
);
})}
</ParameterList>
</FormRow>
);
}
export default InsightsFilterParameters;

View file

@ -1,17 +0,0 @@
.parameter {
display: flex;
gap: 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.op {
font-weight: bold;
}
.popup {
margin-top: -10px;
margin-inline-start: 30px;
}

View file

@ -1,136 +1,29 @@
import { useFilters, useFormat, useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import Icons from 'components/icons';
import { useContext } from 'react'; import { useContext } from 'react';
import { import { Form, FormButtons, SubmitButton } from 'react-basics';
Form,
FormButtons,
FormRow,
Icon,
Popup,
PopupTrigger,
SubmitButton,
TooltipPopup,
} from 'react-basics';
import BaseParameters from '../[reportId]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import FieldSelectForm from '../[reportId]/FieldSelectForm';
import FilterSelectForm from '../[reportId]/FilterSelectForm';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import styles from './InsightsParameters.module.css'; import InsightsFieldParameters from './InsightsFieldParameters';
import InsightsFilterParameters from './InsightsFilterParameters';
export function InsightsParameters() { export function InsightsParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { report, runReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { filterLabels } = useFilters();
const { id, parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange, fields, filters } = parameters || {}; const { websiteId, dateRange, fields, filters } = parameters || {};
const { startDate, endDate } = dateRange || {}; const { startDate, endDate } = dateRange || {};
const parametersSelected = websiteId && startDate && endDate; const parametersSelected = websiteId && startDate && endDate;
const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length); const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length);
const fieldOptions = [
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
];
const parameterGroups = [
{ id: 'fields', label: formatMessage(labels.fields) },
{ id: 'filters', label: formatMessage(labels.filters) },
];
const parameterData = {
fields,
filters,
};
const handleSubmit = (values: any) => { const handleSubmit = (values: any) => {
runReport(values); runReport(values);
}; };
const handleAdd = (id: string | number, value: { name: any }) => {
const data = parameterData[id];
if (!data.find(({ name }) => name === value.name)) {
updateReport({ parameters: { [id]: data.concat(value) } });
}
};
const handleRemove = (id: string, index: number) => {
const data = [...parameterData[id]];
data.splice(index, 1);
updateReport({ parameters: { [id]: data } });
};
const AddButton = ({ id, onAdd }) => {
return (
<PopupTrigger>
<TooltipPopup label={formatMessage(labels.add)} position="top">
<Icon>
<Icons.Plus />
</Icon>
</TooltipPopup>
<Popup position="bottom" alignment="start" className={styles.popup}>
<PopupForm>
{id === 'fields' && (
<FieldSelectForm
fields={fieldOptions}
onSelect={onAdd.bind(null, id)}
showType={false}
/>
)}
{id === 'filters' && (
<FilterSelectForm
websiteId={websiteId}
items={fieldOptions}
onSelect={onAdd.bind(null, id)}
/>
)}
</PopupForm>
</Popup>
</PopupTrigger>
);
};
return ( return (
<Form values={parameters} onSubmit={handleSubmit}> <Form values={parameters} onSubmit={handleSubmit}>
<BaseParameters allowWebsiteSelect={!id} /> <BaseParameters allowWebsiteSelect={!id} />
{parametersSelected && {parametersSelected && <InsightsFieldParameters />}
parameterGroups.map(({ id, label }) => { {parametersSelected && <InsightsFilterParameters />}
return (
<FormRow key={label} label={label} action={<AddButton id={id} onAdd={handleAdd} />}>
<ParameterList items={parameterData[id]} onRemove={index => handleRemove(id, index)}>
{({ name, filter, value }) => {
return (
<div className={styles.parameter}>
{id === 'fields' && (
<>
<div>{fieldOptions.find(f => f.name === name)?.label}</div>
</>
)}
{id === 'filters' && (
<>
<div>{fieldOptions.find(f => f.name === name)?.label}</div>
<div className={styles.op}>{filterLabels[filter]}</div>
<div>{formatValue(value, name)}</div>
</>
)}
</div>
);
}}
</ParameterList>
</FormRow>
);
})}
<FormButtons> <FormButtons>
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}> <SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)} {formatMessage(labels.runQuery)}

View file

@ -1,5 +1,5 @@
.bar { .bar {
font-size: 14px; font-size: 12px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: var(--base600); color: var(--base600);

View file

@ -22,7 +22,7 @@ export function useFilters() {
}; };
const typeFilters = { const typeFilters = {
string: [OPERATORS.equals, OPERATORS.notEquals], string: [OPERATORS.equals, OPERATORS.notEquals, OPERATORS.contains, OPERATORS.doesNotContain],
array: [OPERATORS.contains, OPERATORS.doesNotContain], array: [OPERATORS.contains, OPERATORS.doesNotContain],
boolean: [OPERATORS.true, OPERATORS.false], boolean: [OPERATORS.true, OPERATORS.false],
number: [ number: [

View file

@ -69,6 +69,8 @@ function mapFilter(column: string, filter: string, name: string, type: string =
return `${column} != {${name}:${type}}`; return `${column} != {${name}:${type}}`;
case OPERATORS.contains: case OPERATORS.contains:
return `positionCaseInsensitive(${column}, {${name}:${type}}) > 0`; return `positionCaseInsensitive(${column}, {${name}:${type}}) > 0`;
case OPERATORS.doesNotContain:
return `positionCaseInsensitive(${column}, {${name}:${type}}) = 0`;
default: default:
return ''; return '';
} }

View file

@ -100,6 +100,8 @@ function mapFilter(column: string, filter: string, name: string, type = 'varchar
return `${column} != {{${name}::${type}}}`; return `${column} != {{${name}::${type}}}`;
case OPERATORS.contains: case OPERATORS.contains:
return `${column} like {{${name}::${type}}}`; return `${column} like {{${name}::${type}}}`;
case OPERATORS.doesNotContain:
return `${column} not like {{${name}::${type}}}`;
default: default:
return ''; return '';
} }