fix(dashboard): breakdowns on profile properties

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-04-16 20:52:22 +02:00
parent bfa1ee70e6
commit e2254e78a9
4 changed files with 105 additions and 67 deletions

View File

@@ -10,18 +10,20 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useEventProperties } from '@/hooks/useEventProperties'; import { useEventProperties } from '@/hooks/useEventProperties';
import { useDispatch } from '@/redux';
import { shortId } from '@openpanel/common';
import type { IChartEvent } from '@openpanel/validation'; import type { IChartEvent } from '@openpanel/validation';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { FilterIcon } from 'lucide-react';
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react'; import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list'; import VirtualList from 'rc-virtual-list';
import { useEffect, useState } from 'react'; import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { changeEvent } from '../../reportSlice';
interface FiltersComboboxProps { interface PropertiesComboboxProps {
event: IChartEvent; event?: IChartEvent;
children: (setOpen: Dispatch<SetStateAction<boolean>>) => React.ReactNode;
onSelect: (action: {
value: string;
label: string;
description: string;
}) => void;
} }
function SearchHeader({ function SearchHeader({
@@ -44,24 +46,23 @@ function SearchHeader({
placeholder="Search" placeholder="Search"
value={value} value={value}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
autoFocus
/> />
</div> </div>
); );
} }
export function FiltersCombobox({ event }: FiltersComboboxProps) { export function PropertiesCombobox({
const dispatch = useDispatch(); event,
children,
onSelect,
}: PropertiesComboboxProps) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const properties = useEventProperties( const properties = useEventProperties({
{ event: event?.name,
event: event.name, projectId,
projectId, });
},
{
enabled: !!event.name,
},
);
const [state, setState] = useState<'index' | 'event' | 'profile'>('index'); const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [direction, setDirection] = useState<'forward' | 'backward'>('forward'); const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
@@ -99,27 +100,14 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
description: string; description: string;
}) => { }) => {
setOpen(false); setOpen(false);
dispatch( onSelect(action);
changeEvent({
...event,
filters: [
...event.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}; };
const renderIndex = () => { const renderIndex = () => {
return ( return (
<DropdownMenuGroup> <DropdownMenuGroup>
<SearchHeader onSearch={() => {}} value={search} /> {/* <SearchHeader onSearch={() => {}} value={search} /> */}
<DropdownMenuSeparator /> {/* <DropdownMenuSeparator /> */}
<DropdownMenuItem <DropdownMenuItem
className="group justify-between" className="group justify-between"
onClick={(e) => { onClick={(e) => {
@@ -229,15 +217,7 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
setOpen(open); setOpen(open);
}} }}
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
onClick={() => setOpen((p) => !p)}
>
<FilterIcon size={12} /> Add filter
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-80" align="start"> <DropdownMenuContent className="max-w-80" align="start">
<AnimatePresence mode="wait" initial={false}> <AnimatePresence mode="wait" initial={false}>
{state === 'index' && ( {state === 'index' && (

View File

@@ -5,11 +5,13 @@ import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useEventProperties } from '@/hooks/useEventProperties'; import { useEventProperties } from '@/hooks/useEventProperties';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch, useSelector } from '@/redux';
import { SplitIcon } from 'lucide-react'; import { ChevronsUpDownIcon, SplitIcon } from 'lucide-react';
import type { IChartBreakdown } from '@openpanel/validation'; import type { IChartBreakdown } from '@openpanel/validation';
import { Button } from '@/components/ui/button';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice'; import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { PropertiesCombobox } from './PropertiesCombobox';
import { ReportBreakdownMore } from './ReportBreakdownMore'; import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore'; import type { ReportEventMoreProps } from './ReportEventMore';
@@ -46,42 +48,63 @@ export function ReportBreakdowns() {
<div key={item.name} className="rounded-lg border bg-def-100"> <div key={item.name} className="rounded-lg border bg-def-100">
<div className="flex items-center gap-2 p-2 px-4"> <div className="flex items-center gap-2 p-2 px-4">
<ColorSquare>{index}</ColorSquare> <ColorSquare>{index}</ColorSquare>
<Combobox <PropertiesCombobox
icon={SplitIcon} onSelect={(action) => {
className="flex-1"
searchable
value={item.name}
onChange={(value) => {
dispatch( dispatch(
changeBreakdown({ changeBreakdown({
...item, ...item,
name: value, name: action.value,
}), }),
); );
}} }}
items={properties} >
placeholder="Select..." {(setOpen) => (
/> <Button
variant={'outline'}
onClick={() => setOpen((prev) => !prev)}
size={'sm'}
autoHeight
className="flex-1"
>
<div className="row w-full gap-2 items-center">
<SplitIcon className="size-4" />
{item.name}
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
)}
</PropertiesCombobox>
<ReportBreakdownMore onClick={handleMore(item)} /> <ReportBreakdownMore onClick={handleMore(item)} />
</div> </div>
</div> </div>
); );
})} })}
<Combobox <PropertiesCombobox
icon={SplitIcon} onSelect={(action) => {
searchable
value={''}
onChange={(value) => {
dispatch( dispatch(
addBreakdown({ addBreakdown({
name: value, name: action.value,
}), }),
); );
}} }}
items={properties} >
placeholder="Select breakdown" {(setOpen) => (
/> <Button
variant={'outline'}
onClick={() => setOpen((prev) => !prev)}
size={'sm'}
autoHeight
className="flex-1"
>
<div className="row w-full gap-2 items-center">
<SplitIcon className="size-4" />
Select breakdown
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
)}
</PropertiesCombobox>
</div> </div>
</div> </div>
); );

View File

@@ -25,9 +25,10 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation'; import type { IChartEvent } from '@openpanel/validation';
import { GanttChartIcon, HandIcon, Users } from 'lucide-react'; import { FilterIcon, GanttChartIcon, HandIcon, Users } from 'lucide-react';
import { import {
addEvent, addEvent,
changeEvent, changeEvent,
@@ -35,9 +36,9 @@ import {
reorderEvents, reorderEvents,
} from '../reportSlice'; } from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox'; import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore'; import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore'; import { ReportEventMore } from './ReportEventMore';
import { FiltersCombobox } from './filters/FiltersCombobox';
import { FiltersList } from './filters/FiltersList'; import { FiltersList } from './filters/FiltersList';
function SortableEvent({ function SortableEvent({
@@ -158,7 +159,37 @@ function SortableEvent({
</button> </button>
</DropdownMenuComposed> </DropdownMenuComposed>
)} )}
{showAddFilter && <FiltersCombobox event={event} />} {showAddFilter && (
<PropertiesCombobox
event={event}
onSelect={(action) => {
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && {showSegment &&
(event.segment === 'property_average' || (event.segment === 'property_average' ||

View File

@@ -88,8 +88,11 @@ export function getChartSql({
const anyFilterOnProfile = event.filters.some((filter) => const anyFilterOnProfile = event.filters.some((filter) =>
filter.name.startsWith('profile.'), filter.name.startsWith('profile.'),
); );
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
breakdown.name.startsWith('profile.'),
);
if (anyFilterOnProfile) { if (anyFilterOnProfile || anyBreakdownOnProfile) {
sb.joins.profiles = `LEFT ANY JOIN (SELECT * FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${escape(projectId)}) as profile on profile.id = profile_id`; sb.joins.profiles = `LEFT ANY JOIN (SELECT * FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${escape(projectId)}) as profile on profile.id = profile_id`;
} }
@@ -130,6 +133,7 @@ export function getChartSql({
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN ( sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (
SELECT ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')} SELECT ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
FROM ${TABLE_NAMES.events} FROM ${TABLE_NAMES.events}
${getJoins()}
${getWhere()} ${getWhere()}
GROUP BY ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')} GROUP BY ${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}
ORDER BY count(*) DESC ORDER BY count(*) DESC