formulas
This commit is contained in:
@@ -8,7 +8,14 @@ import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { last } from 'ramda';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UsersIcon, BookmarkIcon } from 'lucide-react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
@@ -44,10 +51,14 @@ export function Chart({ data }: Props) {
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
events,
|
||||
breakdowns,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis, maxDomain },
|
||||
} = useReportChartContext();
|
||||
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [clickedData, setClickedData] = useState<{ date: string; serieId?: string } | null>(null);
|
||||
const dataLength = data.series[0]?.data?.length || 0;
|
||||
const trpc = useTRPC();
|
||||
const references = useQuery(
|
||||
@@ -130,18 +141,95 @@ export function Chart({ data }: Props) {
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0]) {
|
||||
const clickedData = e.activePayload[0].payload;
|
||||
if (clickedData.date) {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
const payload = e.activePayload[0].payload;
|
||||
const activeCoordinate = e.activeCoordinate;
|
||||
if (payload.date) {
|
||||
setClickedData({
|
||||
date: payload.date,
|
||||
serieId: e.activePayload[0].dataKey?.toString().replace(':count', ''),
|
||||
});
|
||||
setClickPosition({
|
||||
x: activeCoordinate?.x ?? 0,
|
||||
y: activeCoordinate?.y ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleViewUsers = useCallback(() => {
|
||||
if (!clickedData || !projectId || !startDate || !endDate) return;
|
||||
|
||||
// Find the event for the clicked serie
|
||||
const serie = series.find((s) => s.id === clickedData.serieId);
|
||||
const event = events.find((e) => {
|
||||
const normalized = 'type' in e ? e : { ...e, type: 'event' as const };
|
||||
if (normalized.type === 'event') {
|
||||
return serie?.event.id === normalized.id || serie?.event.name === normalized.name;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (event) {
|
||||
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
|
||||
if (normalized.type === 'event') {
|
||||
pushModal('ViewChartUsers', {
|
||||
projectId,
|
||||
event: normalized,
|
||||
date: clickedData.date,
|
||||
breakdowns: breakdowns || [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
filters: normalized.filters || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
}, [clickedData, projectId, startDate, endDate, events, series, breakdowns, interval]);
|
||||
|
||||
const handleAddReference = useCallback(() => {
|
||||
if (!clickedData) return;
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
});
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
}, [clickedData]);
|
||||
|
||||
return (
|
||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<DropdownMenu
|
||||
open={clickPosition !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: clickPosition?.x ?? -9999,
|
||||
top: clickPosition?.y ?? -9999,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={handleViewUsers}>
|
||||
<UsersIcon size={16} className="mr-2" />
|
||||
View Users
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleAddReference}>
|
||||
<BookmarkIcon size={16} className="mr-2" />
|
||||
Add Reference
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
@@ -86,24 +88,45 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.name = action.payload;
|
||||
},
|
||||
// Events
|
||||
// Events and Formulas
|
||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
id: shortId(),
|
||||
type: 'event',
|
||||
...action.payload,
|
||||
});
|
||||
} as IChartEventItem);
|
||||
},
|
||||
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
addFormula: (
|
||||
state,
|
||||
action: PayloadAction<Omit<IChartFormula, 'id'>>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
id: shortId(),
|
||||
})),
|
||||
id: shortId(),
|
||||
});
|
||||
...action.payload,
|
||||
} as IChartEventItem);
|
||||
},
|
||||
duplicateEvent: (
|
||||
state,
|
||||
action: PayloadAction<IChartEventItem>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
if (action.payload.type === 'event') {
|
||||
state.events.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
id: shortId(),
|
||||
})),
|
||||
id: shortId(),
|
||||
} as IChartEventItem);
|
||||
} else {
|
||||
state.events.push({
|
||||
...action.payload,
|
||||
id: shortId(),
|
||||
} as IChartEventItem);
|
||||
}
|
||||
},
|
||||
removeEvent: (
|
||||
state,
|
||||
@@ -113,13 +136,18 @@ export const reportSlice = createSlice({
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.filter(
|
||||
(event) => event.id !== action.payload.id,
|
||||
(event) => {
|
||||
// Handle both old format (no type) and new format
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
return eventId !== action.payload.id;
|
||||
},
|
||||
);
|
||||
},
|
||||
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
|
||||
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.map((event) => {
|
||||
if (event.id === action.payload.id) {
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
if (eventId === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
return event;
|
||||
@@ -280,6 +308,7 @@ export const {
|
||||
setReport,
|
||||
setName,
|
||||
addEvent,
|
||||
addFormula,
|
||||
removeEvent,
|
||||
duplicateEvent,
|
||||
changeEvent,
|
||||
|
||||
@@ -23,16 +23,19 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon } from 'lucide-react';
|
||||
import type { IChartEvent, IChartEventItem, IChartFormula } from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import {
|
||||
addEvent,
|
||||
addFormula,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
@@ -47,21 +50,29 @@ function SortableEvent({
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEvent;
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: event.id ?? '' });
|
||||
useSortable({ id: eventId ?? '' });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// Normalize event to have type field
|
||||
const normalizedEvent: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
|
||||
const isFormula = normalizedEvent.type === 'formula';
|
||||
const chartEvent = isFormula ? null : (normalizedEvent as IChartEventItem & { type: 'event' });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
@@ -76,16 +87,16 @@ function SortableEvent({
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
{(showSegment || showAddFilter) && (
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={event.segment}
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
@@ -94,13 +105,13 @@ function SortableEvent({
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={event}
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...event.filters,
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
@@ -124,14 +135,14 @@ function SortableEvent({
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && event.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={event} />
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -174,14 +185,15 @@ export function ReportEvents() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(removeEvent(event));
|
||||
return dispatch(removeEvent({ id: 'type' in event ? event.id : (event as IChartEvent).id }));
|
||||
}
|
||||
case 'duplicate': {
|
||||
return dispatch(duplicateEvent(event));
|
||||
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
|
||||
return dispatch(duplicateEvent(normalized));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -189,119 +201,184 @@ export function ReportEvents() {
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||
dispatch(changeEvent(formula));
|
||||
});
|
||||
|
||||
const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && chartType !== 'retention';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Events</h3>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))}
|
||||
items={selectedEvents.map((e) => ({ id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '' }))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event, index) => {
|
||||
// Normalize event to have type field
|
||||
const normalized: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
const isFormula = normalized.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
key={normalized.id}
|
||||
event={normalized}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? (event.filters[0]?.value ?? [])
|
||||
: event.name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
{isFormula ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<InputEnter
|
||||
placeholder="eg: A+B, A/B"
|
||||
value={normalized.formula}
|
||||
onChangeValue={(value) => {
|
||||
dispatchChangeFormula({
|
||||
...normalized,
|
||||
formula: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={`Formula (${alphabetIds[index]})`}
|
||||
defaultValue={normalized.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeFormula({
|
||||
...normalized,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportEventMore onClick={handleMore(normalized)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? ((normalized as IChartEventItem & { type: 'event' }).filters[0]?.value ?? [])
|
||||
: (normalized as IChartEventItem & { type: 'event' }).name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: normalized.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...normalized,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
(normalized as IChartEventItem & { type: 'event' }).name
|
||||
? `${(normalized as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...(normalized as IChartEventItem & { type: 'event' }),
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(normalized)} />
|
||||
</>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</SortableEvent>
|
||||
);
|
||||
})}
|
||||
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addEvent({
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addEvent({
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addFormula({
|
||||
type: 'formula',
|
||||
formula: '',
|
||||
displayName: '',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -9,17 +9,12 @@ import { ReportSettings } from './ReportSettings';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
const showBreakdown = chartType !== 'retention';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportEvents />
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
{showFormula && <ReportFormula />}
|
||||
<ReportSettings />
|
||||
</div>
|
||||
<SheetFooter>
|
||||
|
||||
@@ -31,6 +31,7 @@ import RequestPasswordReset from './request-reset-password';
|
||||
import SaveReport from './save-report';
|
||||
import SelectBillingPlan from './select-billing-plan';
|
||||
import ShareOverviewModal from './share-overview-modal';
|
||||
import ViewChartUsers from './view-chart-users';
|
||||
|
||||
const modals = {
|
||||
OverviewTopPagesModal: OverviewTopPagesModal,
|
||||
@@ -51,6 +52,7 @@ const modals = {
|
||||
EditReference: EditReference,
|
||||
ShareOverviewModal: ShareOverviewModal,
|
||||
AddReference: AddReference,
|
||||
ViewChartUsers: ViewChartUsers,
|
||||
Instructions: Instructions,
|
||||
OnboardingTroubleshoot: OnboardingTroubleshoot,
|
||||
DateRangerPicker: DateRangerPicker,
|
||||
|
||||
112
apps/start/src/modals/view-chart-users.tsx
Normal file
112
apps/start/src/modals/view-chart-users.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
|
||||
interface ViewChartUsersProps {
|
||||
projectId: string;
|
||||
event: IChartEvent;
|
||||
date: string;
|
||||
breakdowns?: Array<{ id?: string; name: string }>;
|
||||
interval: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
filters?: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
operator: string;
|
||||
value: Array<string | number | boolean | null>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ViewChartUsers({
|
||||
projectId,
|
||||
event,
|
||||
date,
|
||||
breakdowns = [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
filters = [],
|
||||
}: ViewChartUsersProps) {
|
||||
const trpc = useTRPC();
|
||||
const query = useQuery(
|
||||
trpc.chart.getProfiles.queryOptions({
|
||||
projectId,
|
||||
event,
|
||||
date,
|
||||
breakdowns,
|
||||
interval: interval as any,
|
||||
startDate,
|
||||
endDate,
|
||||
filters,
|
||||
}),
|
||||
);
|
||||
|
||||
const profiles = query.data ?? [];
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader
|
||||
title="View Users"
|
||||
description={`Users who triggered this event on ${new Date(date).toLocaleDateString()}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">Loading users...</div>
|
||||
</div>
|
||||
) : profiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">No users found</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<div className="flex flex-col gap-2">
|
||||
{profiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{profile.avatar ? (
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt={profile.firstName || profile.email}
|
||||
className="size-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
|
||||
<UsersIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{profile.firstName || profile.lastName
|
||||
? `${profile.firstName || ''} ${profile.lastName || ''}`.trim()
|
||||
: profile.email || 'Anonymous'}
|
||||
</div>
|
||||
{profile.email && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{profile.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ButtonContainer>
|
||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||
Close
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</div>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user