This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-10 21:55:24 +01:00
parent ba79ac570c
commit 347a01a941
35 changed files with 1544 additions and 1404 deletions

View File

@@ -0,0 +1,95 @@
import type { IServiceReport } from '@openpanel/db';
import { useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
const ResponsiveGridLayout = WidthProvider(Responsive);
export type Layout = ReactGridLayout.Layout;
export const useReportLayouts = (
reports: NonNullable<IServiceReport>[],
): ReactGridLayout.Layouts => {
return useMemo(() => {
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
};
export function GrafanaGrid({
layouts,
children,
transitions,
onLayoutChange,
onDragStop,
onResizeStop,
isDraggable,
isResizable,
}: {
children: React.ReactNode;
transitions?: boolean;
} & Pick<
ReactGridLayout.ResponsiveProps,
| 'layouts'
| 'onLayoutChange'
| 'onDragStop'
| 'onResizeStop'
| 'isDraggable'
| 'isResizable'
>) {
return (
<>
<style>{`
.react-grid-item {
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
}
.react-grid-item.react-grid-placeholder {
background: none !important;
opacity: 0.5;
transition-duration: 100ms;
border-radius: 0.5rem;
border: 1px dashed var(--primary);
}
.react-grid-item.resizing {
transition: none !important;
}
`}</style>
<div className="-m-4">
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
onLayoutChange={onLayoutChange}
onDragStop={onDragStop}
onResizeStop={onResizeStop}
isDraggable={isDraggable}
isResizable={isResizable}
>
{children}
</ResponsiveGridLayout>
</div>
</>
);
}

View File

@@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
</a> </a>
</li> </li>
<li> <li>
<a href="https://openpanel.dev/compare/mixpanel-alternative"> <a href="https://openpanel.dev/compare/posthog-alternative">
Posthog alternative Posthog alternative
</a> </a>
</li> </li>

View File

@@ -211,7 +211,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
<ReportChart <ReportChart
options={{ hideID: true }}
report={{ report={{
projectId, projectId,
startDate, startDate,
@@ -232,9 +231,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
}, },
], ],
chartType: 'map', chartType: 'map',
lineType: 'monotone',
interval: interval, interval: interval,
name: 'Top sources',
range: range, range: range,
previous: previous, previous: previous,
metric: 'sum', metric: 'sum',

View File

@@ -10,33 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportAreaChart() { export function ReportAreaChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.chart.queryOptions(
? trpc.chart.chartByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -10,33 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportBarChart() { export function ReportBarChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.aggregate.queryOptions(
? trpc.chart.aggregateByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -42,10 +42,10 @@ export function PreviousDiffIndicator({
className, className,
}: PreviousDiffIndicatorProps) { }: PreviousDiffIndicatorProps) {
const { const {
report: { previousIndicatorInverted, previous }, report: { previous },
} = useReportChartContext(); } = useReportChartContext();
const variant = getDiffIndicator( const variant = getDiffIndicator(
inverted ?? previousIndicatorInverted, inverted,
state, state,
'bg-emerald-300', 'bg-emerald-300',
'bg-rose-300', 'bg-rose-300',

View File

@@ -11,7 +11,6 @@ import type {
export type ReportChartContextType = { export type ReportChartContextType = {
options: Partial<{ options: Partial<{
columns: React.ReactNode[]; columns: React.ReactNode[];
hideID: boolean;
hideLegend: boolean; hideLegend: boolean;
hideXAxis: boolean; hideXAxis: boolean;
hideYAxis: boolean; hideYAxis: boolean;
@@ -28,11 +27,11 @@ export type ReportChartContextType = {
onClick: () => void; onClick: () => void;
}[]; }[];
}>; }>;
report: IChartProps & { id?: string }; report: IChartInput & { id?: string };
isLazyLoading: boolean; isLazyLoading: boolean;
isEditMode: boolean; isEditMode: boolean;
shareId?: string; shareId?: string;
shareType?: 'dashboard' | 'report'; reportId?: string;
}; };
type ReportChartContextProviderProps = ReportChartContextType & { type ReportChartContextProviderProps = ReportChartContextType & {
@@ -42,8 +41,6 @@ type ReportChartContextProviderProps = ReportChartContextType & {
export type ReportChartProps = Partial<ReportChartContextType> & { export type ReportChartProps = Partial<ReportChartContextType> & {
report: IChartInput; report: IChartInput;
lazy?: boolean; lazy?: boolean;
shareId?: string;
shareType?: 'dashboard' | 'report';
}; };
const context = createContext<ReportChartContextType | null>(null); const context = createContext<ReportChartContextType | null>(null);
@@ -58,20 +55,6 @@ export const useReportChartContext = () => {
return ctx; return ctx;
}; };
export const useSelectReportChartContext = <T,>(
selector: (ctx: ReportChartContextType) => T,
) => {
const ctx = useReportChartContext();
const [state, setState] = useState(selector(ctx));
useEffect(() => {
const newState = selector(ctx);
if (!isEqual(newState, state)) {
setState(newState);
}
}, [ctx]);
return state;
};
export const ReportChartProvider = ({ export const ReportChartProvider = ({
children, children,
...propsToContext ...propsToContext

View File

@@ -12,33 +12,27 @@ import { Chart } from './chart';
import { Summary } from './summary'; import { Summary } from './summary';
export function ReportConversionChart() { export function ReportConversionChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit); console.log(report.limit);
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.conversion.queryOptions(
? trpc.chart.conversionByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -25,50 +25,35 @@ export function ReportFunnelChart() {
endDate, endDate,
previous, previous,
breakdowns, breakdowns,
interval,
}, },
isLazyLoading, isLazyLoading,
shareId, shareId,
shareType,
} = useReportChartContext(); } = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions(); const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const trpc = useTRPC(); const trpc = useTRPC();
const res = useQuery(
shareId && shareType && id
? trpc.chart.funnelByReport.queryOptions(
{
reportId: id,
shareId,
shareType,
range: overviewRange ?? undefined,
startDate: overviewStartDate ?? undefined,
endDate: overviewEndDate ?? undefined,
interval: overviewInterval ?? undefined,
},
{
enabled: !isLazyLoading && series.length > 0,
},
)
: (() => {
const input: IChartInput = { const input: IChartInput = {
series, series,
range, range: overviewRange ?? range,
projectId, projectId,
interval: 'day', interval: overviewInterval ?? interval ?? 'day',
chartType: 'funnel', chartType: 'funnel',
breakdowns, breakdowns,
funnelWindow, funnelWindow,
funnelGroup, funnelGroup,
previous, previous,
metric: 'sum', metric: 'sum',
startDate, startDate: overviewStartDate ?? startDate,
endDate, endDate: overviewEndDate ?? endDate,
limit: 20, limit: 20,
shareId,
reportId: id,
}; };
return trpc.chart.funnel.queryOptions(input, { const res = useQuery(
trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0, enabled: !isLazyLoading && input.series.length > 0,
}); }),
})(),
); );
if (isLazyLoading || res.isLoading) { if (isLazyLoading || res.isLoading) {

View File

@@ -10,33 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportHistogramChart() { export function ReportHistogramChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.chart.queryOptions(
? trpc.chart.chartByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -11,33 +11,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportLineChart() { export function ReportLineChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.chart.queryOptions(
? trpc.chart.chartByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -10,33 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportMapChart() { export function ReportMapChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.chart.queryOptions(
? trpc.chart.chartByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -9,33 +9,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportMetricChart() { export function ReportMetricChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.chart.queryOptions(
? trpc.chart.chartByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -54,10 +54,7 @@ export function MetricCard({
metric, metric,
unit, unit,
}: MetricCardProps) { }: MetricCardProps) {
const { const { isEditMode } = useReportChartContext();
report: { previousIndicatorInverted },
isEditMode,
} = useReportChartContext();
const number = useNumber(); const number = useNumber();
const renderValue = (value: number | undefined, unitClassName?: string) => { const renderValue = (value: number | undefined, unitClassName?: string) => {
@@ -80,7 +77,7 @@ export function MetricCard({
const previous = serie.metrics.previous?.[metric]; const previous = serie.metrics.previous?.[metric];
const graphColors = getDiffIndicator( const graphColors = getDiffIndicator(
previousIndicatorInverted, false,
previous?.state, previous?.state,
'#6ee7b7', // green '#6ee7b7', // green
'#fda4af', // red '#fda4af', // red

View File

@@ -10,33 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart'; import { Chart } from './chart';
export function ReportPieChart() { export function ReportPieChart() {
const { isLazyLoading, report, shareId, shareType } = useReportChartContext(); const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions(); const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery( const res = useQuery(
shareId && shareType && 'id' in report && report.id trpc.chart.aggregate.queryOptions(
? trpc.chart.aggregateByReport.queryOptions(
{ {
reportId: report.id, ...report,
shareId, shareId,
shareType, reportId: 'id' in report ? report.id : undefined,
range: range ?? undefined, range: range ?? report.range,
startDate: startDate ?? undefined, startDate: startDate ?? report.startDate,
endDate: endDate ?? undefined, endDate: endDate ?? report.endDate,
interval: interval ?? undefined, interval: interval ?? report.interval,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1, staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading, enabled: !isLazyLoading,
}, },
) ),
: trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
); );
if ( if (

View File

@@ -24,7 +24,6 @@ export function ReportRetentionChart() {
}, },
isLazyLoading, isLazyLoading,
shareId, shareId,
shareType,
} = useReportChartContext(); } = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions(); const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const eventSeries = series.filter((item) => item.type === 'event'); const eventSeries = series.filter((item) => item.type === 'event');
@@ -34,33 +33,18 @@ export function ReportRetentionChart() {
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading; firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const trpc = useTRPC(); const trpc = useTRPC();
const res = useQuery( const res = useQuery(
shareId && shareType && id trpc.chart.cohort.queryOptions(
? trpc.chart.cohortByReport.queryOptions(
{
reportId: id,
shareId,
shareType,
range: overviewRange ?? undefined,
startDate: overviewStartDate ?? undefined,
endDate: overviewEndDate ?? undefined,
interval: overviewInterval ?? undefined,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: isEnabled,
},
)
: trpc.chart.cohort.queryOptions(
{ {
firstEvent, firstEvent,
secondEvent, secondEvent,
projectId, projectId,
range, range: overviewRange ?? range,
startDate, startDate: overviewStartDate ?? startDate,
endDate, endDate: overviewEndDate ?? endDate,
criteria, criteria,
interval, interval: overviewInterval ?? interval,
shareId,
reportId: id,
}, },
{ {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -0,0 +1,258 @@
import { ReportChart } from '@/components/report-chart';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { CopyIcon, MoreHorizontal, Trash } from 'lucide-react';
import { timeWindows } from '@openpanel/constants';
import { useRouter } from '@tanstack/react-router';
export function ReportItemSkeleton() {
return (
<div className="card h-full flex flex-col animate-pulse">
<div className="flex items-center justify-between border-b border-border p-4">
<div className="flex-1">
<div className="h-5 w-32 bg-muted rounded mb-2" />
<div className="h-4 w-24 bg-muted/50 rounded" />
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-muted rounded" />
<div className="w-8 h-8 bg-muted rounded" />
</div>
</div>
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
</div>
);
}
export function ReportItem({
report,
organizationId,
projectId,
range,
startDate,
endDate,
interval,
onDelete,
onDuplicate,
}: {
report: any;
organizationId: string;
projectId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
onDelete: (reportId: string) => void;
onDuplicate: (reportId: string) => void;
}) {
const router = useRouter();
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
<div
className="flex-1 cursor-pointer -m-4 p-4"
onClick={(event) => {
if (event.metaKey) {
window.open(
`/${organizationId}/${projectId}/reports/${report.id}`,
'_blank',
);
return;
}
router.navigate({
to: '/$organizationId/$projectId/reports/$reportId',
params: {
organizationId,
projectId,
reportId: report.id,
},
});
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
router.navigate({
to: '/$organizationId/$projectId/reports/$reportId',
params: {
organizationId,
projectId,
reportId: report.id,
},
});
}
}}
role="button"
tabIndex={0}
>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
className="opacity-30 hover:opacity-100"
>
<circle cx="4" cy="4" r="1.5" />
<circle cx="4" cy="8" r="1.5" />
<circle cx="4" cy="12" r="1.5" />
<circle cx="12" cy="4" r="1.5" />
<circle cx="12" cy="8" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
</svg>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDuplicate(report.id);
}}
>
<CopyIcon size={16} className="mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
onDelete(report.id);
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={
{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
} as any
}
/>
</div>
</div>
);
}
export function ReportItemReadOnly({
report,
shareId,
range,
startDate,
endDate,
interval,
}: {
report: any;
shareId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
}) {
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
<div className="flex-1">
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
type="inputs"
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
}}
shareId={shareId}
/>
</div>
</div>
);
}

View File

@@ -67,7 +67,7 @@ export function ReportSettings() {
return ( return (
<div> <div>
<h3 className="mb-2 font-medium">Settings</h3> <h3 className="mb-2 font-medium">Settings</h3>
<div className="col rounded-lg border bg-card p-4 gap-2"> <div className="col rounded-lg border bg-card p-4 gap-4">
{fields.includes('previous') && ( {fields.includes('previous') && (
<Label className="flex items-center justify-between mb-0"> <Label className="flex items-center justify-between mb-0">
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
@@ -81,7 +81,9 @@ export function ReportSettings() {
)} )}
{fields.includes('criteria') && ( {fields.includes('criteria') && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Criteria</span> <Label className="whitespace-nowrap font-medium mb-0">
Criteria
</Label>
<Combobox <Combobox
align="end" align="end"
placeholder="Select criteria" placeholder="Select criteria"
@@ -102,7 +104,7 @@ export function ReportSettings() {
)} )}
{fields.includes('unit') && ( {fields.includes('unit') && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Unit</span> <Label className="whitespace-nowrap font-medium mb-0">Unit</Label>
<Combobox <Combobox
align="end" align="end"
placeholder="Unit" placeholder="Unit"
@@ -125,7 +127,9 @@ export function ReportSettings() {
)} )}
{fields.includes('funnelGroup') && ( {fields.includes('funnelGroup') && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Group</span> <Label className="whitespace-nowrap font-medium mb-0">
Funnel Group
</Label>
<Combobox <Combobox
align="end" align="end"
placeholder="Default: Session" placeholder="Default: Session"
@@ -150,7 +154,9 @@ export function ReportSettings() {
)} )}
{fields.includes('funnelWindow') && ( {fields.includes('funnelWindow') && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Window</span> <Label className="whitespace-nowrap font-medium mb-0">
Funnel Window
</Label>
<InputEnter <InputEnter
type="number" type="number"
value={funnelWindow ? String(funnelWindow) : ''} value={funnelWindow ? String(funnelWindow) : ''}
@@ -168,7 +174,7 @@ export function ReportSettings() {
)} )}
{fields.includes('sankeyMode') && options?.type === 'sankey' && ( {fields.includes('sankeyMode') && options?.type === 'sankey' && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Mode</span> <Label className="whitespace-nowrap font-medium mb-0">Mode</Label>
<Combobox <Combobox
align="end" align="end"
placeholder="Select mode" placeholder="Select mode"
@@ -197,7 +203,7 @@ export function ReportSettings() {
)} )}
{fields.includes('sankeySteps') && options?.type === 'sankey' && ( {fields.includes('sankeySteps') && options?.type === 'sankey' && (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Steps</span> <Label className="whitespace-nowrap font-medium mb-0">Steps</Label>
<InputEnter <InputEnter
type="number" type="number"
value={options?.steps ? String(options.steps) : '5'} value={options?.steps ? String(options.steps) : '5'}
@@ -214,10 +220,10 @@ export function ReportSettings() {
</div> </div>
)} )}
{fields.includes('sankeyExclude') && options?.type === 'sankey' && ( {fields.includes('sankeyExclude') && options?.type === 'sankey' && (
<div className="flex flex-col gap-2"> <div className="flex flex-col">
<span className="whitespace-nowrap font-medium"> <Label className="whitespace-nowrap font-medium">
Exclude Events Exclude Events
</span> </Label>
<ComboboxEvents <ComboboxEvents
multiple multiple
searchable searchable
@@ -231,10 +237,10 @@ export function ReportSettings() {
</div> </div>
)} )}
{fields.includes('sankeyInclude') && options?.type === 'sankey' && ( {fields.includes('sankeyInclude') && options?.type === 'sankey' && (
<div className="flex flex-col gap-2"> <div className="flex flex-col">
<span className="whitespace-nowrap font-medium"> <Label className="whitespace-nowrap font-medium">
Include events Include events
</span> </Label>
<ComboboxEvents <ComboboxEvents
multiple multiple
searchable searchable

View File

@@ -5,7 +5,7 @@ import type { VariantProps } from 'class-variance-authority';
import * as React from 'react'; import * as React from 'react';
const labelVariants = cva( const labelVariants = cva(
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground/70',
); );
const Label = React.forwardRef< const Label = React.forwardRef<

View File

@@ -25,12 +25,40 @@ export function createTRPCClientWithHeaders(apiUrl: string) {
transformer: superjson, transformer: superjson,
url: `${apiUrl}/trpc`, url: `${apiUrl}/trpc`,
headers: () => getIsomorphicHeaders(), headers: () => getIsomorphicHeaders(),
fetch: (url, options) => { fetch: async (url, options) => {
return fetch(url, { try {
console.log('fetching', url, options);
const response = await fetch(url, {
...options, ...options,
mode: 'cors', mode: 'cors',
credentials: 'include', credentials: 'include',
}); });
// Log HTTP errors on server
if (!response.ok && typeof window === 'undefined') {
const text = await response.clone().text();
console.error('[tRPC SSR Error]', {
url: url.toString(),
status: response.status,
statusText: response.statusText,
body: text,
options,
});
}
return response;
} catch (error) {
// Log fetch errors on server
if (typeof window === 'undefined') {
console.error('[tRPC SSR Error]', {
url: url.toString(),
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
options,
});
}
throw error;
}
}, },
}), }),
], ],

View File

@@ -11,8 +11,11 @@ import type { z } from 'zod';
import { zShareDashboard } from '@openpanel/validation'; import { zShareDashboard } from '@openpanel/validation';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltiper } from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
@@ -27,20 +30,37 @@ export default function ShareDashboardModal({
}) { }) {
const { projectId, organizationId } = useAppParams(); const { projectId, organizationId } = useAppParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const { register, handleSubmit } = useForm<IForm>({ const trpc = useTRPC();
const queryClient = useQueryClient();
// Fetch current share status
const shareQuery = useQuery(
trpc.share.dashboard.queryOptions({
dashboardId,
}),
);
const existingShare = shareQuery.data;
const isShared = existingShare?.public ?? false;
const shareUrl = existingShare?.id
? `${window.location.origin}/share/dashboard/${existingShare.id}`
: '';
const { register, handleSubmit, watch } = useForm<IForm>({
resolver: zodResolver(validator), resolver: zodResolver(validator),
defaultValues: { defaultValues: {
public: true, public: true,
password: '', password: existingShare?.password ? '••••••••' : '',
projectId, projectId,
organizationId, organizationId,
dashboardId, dashboardId,
}, },
}); });
const trpc = useTRPC(); const password = watch('password');
const queryClient = useQueryClient();
const mutation = useMutation( const mutation = useMutation(
trpc.share.createDashboard.mutationOptions({ trpc.share.createDashboard.mutationOptions({
onError: handleError, onError: handleError,
@@ -50,7 +70,8 @@ export default function ShareDashboardModal({
description: `Your dashboard is now ${ description: `Your dashboard is now ${
res.public ? 'public' : 'private' res.public ? 'public' : 'private'
}`, }`,
action: { action: res.public
? {
label: 'View', label: 'View',
onClick: () => onClick: () =>
navigate({ navigate({
@@ -59,39 +80,113 @@ export default function ShareDashboardModal({
shareId: res.id, shareId: res.id,
}, },
}), }),
}, }
: undefined,
}); });
popModal(); popModal();
}, },
}), }),
); );
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast('Link copied to clipboard');
};
const handleMakePrivate = () => {
mutation.mutate({
public: false,
password: null,
projectId,
organizationId,
dashboardId,
});
};
return ( return (
<ModalContent className="max-w-md"> <ModalContent className="max-w-md">
<ModalHeader <ModalHeader
title="Dashboard public availability" title="Dashboard public availability"
text="You can choose if you want to add a password to make it a bit more private." text={
isShared
? 'Your dashboard is currently public and can be accessed by anyone with the link.'
: 'You can choose if you want to add a password to make it a bit more private.'
}
/> />
{isShared && (
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
<span className="font-medium">Currently shared</span>
</div>
<div className="flex items-center gap-1">
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
<Tooltiper content="Copy link">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyLink}
>
{copied ? (
<CheckCircle2 className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltiper>
<Tooltiper content="Open in new tab">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => window.open(shareUrl, '_blank')}
>
<ExternalLink className="size-4" />
</Button>
</Tooltiper>
<Tooltiper content="Make private">
<Button
type="button"
variant="destructive"
onClick={handleMakePrivate}
>
<TrashIcon className="size-4" />
</Button>
</Tooltiper>
</div>
</div>
)}
<form <form
onSubmit={handleSubmit((values) => { onSubmit={handleSubmit((values) => {
mutation.mutate(values); mutation.mutate({
...values,
// Only send password if it's not the placeholder
password:
values.password === '••••••••' ? null : values.password || null,
});
})} })}
> >
<Input <Input
{...register('password')} {...register('password')}
placeholder="Enter your password" placeholder="Enter your password (optional)"
size="large" size="large"
type={password === '••••••••' ? 'text' : 'password'}
/> />
<ButtonContainer> <ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}> <Button type="button" variant="outline" onClick={() => popModal()}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={mutation.isPending}>
Make it public <Button type="submit">
{isShared ? 'Update' : 'Make it public'}
</Button> </Button>
</ButtonContainer> </ButtonContainer>
</form> </form>
</ModalContent> </ModalContent>
); );
} }

View File

@@ -11,8 +11,11 @@ import type { z } from 'zod';
import { zShareOverview } from '@openpanel/validation'; import { zShareOverview } from '@openpanel/validation';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltiper } from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
@@ -23,19 +26,36 @@ type IForm = z.infer<typeof validator>;
export default function ShareOverviewModal() { export default function ShareOverviewModal() {
const { projectId, organizationId } = useAppParams(); const { projectId, organizationId } = useAppParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const { register, handleSubmit } = useForm<IForm>({ const trpc = useTRPC();
const queryClient = useQueryClient();
// Fetch current share status
const shareQuery = useQuery(
trpc.share.overview.queryOptions({
projectId,
}),
);
const existingShare = shareQuery.data;
const isShared = existingShare?.public ?? false;
const shareUrl = existingShare?.id
? `${window.location.origin}/share/overview/${existingShare.id}`
: '';
const { register, handleSubmit, watch } = useForm<IForm>({
resolver: zodResolver(validator), resolver: zodResolver(validator),
defaultValues: { defaultValues: {
public: true, public: true,
password: '', password: existingShare?.password ? '••••••••' : '',
projectId, projectId,
organizationId, organizationId,
}, },
}); });
const trpc = useTRPC(); const password = watch('password');
const queryClient = useQueryClient();
const mutation = useMutation( const mutation = useMutation(
trpc.share.createOverview.mutationOptions({ trpc.share.createOverview.mutationOptions({
onError: handleError, onError: handleError,
@@ -45,7 +65,8 @@ export default function ShareOverviewModal() {
description: `Your overview is now ${ description: `Your overview is now ${
res.public ? 'public' : 'private' res.public ? 'public' : 'private'
}`, }`,
action: { action: res.public
? {
label: 'View', label: 'View',
onClick: () => onClick: () =>
navigate({ navigate({
@@ -54,35 +75,109 @@ export default function ShareOverviewModal() {
shareId: res.id, shareId: res.id,
}, },
}), }),
}, }
: undefined,
}); });
popModal(); popModal();
}, },
}), }),
); );
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast('Link copied to clipboard');
};
const handleMakePrivate = () => {
mutation.mutate({
public: false,
password: null,
projectId,
organizationId,
});
};
return ( return (
<ModalContent className="max-w-md"> <ModalContent className="max-w-md">
<ModalHeader <ModalHeader
title="Dashboard public availability" title="Overview public availability"
text="You can choose if you want to add a password to make it a bit more private." text={
isShared
? 'Your overview is currently public and can be accessed by anyone with the link.'
: 'You can choose if you want to add a password to make it a bit more private.'
}
/> />
{isShared && (
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
<span className="font-medium">Currently shared</span>
</div>
<div className="flex items-center gap-1">
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
<Tooltiper content="Copy link">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyLink}
>
{copied ? (
<CheckCircle2 className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltiper>
<Tooltiper content="Open in new tab">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => window.open(shareUrl, '_blank')}
>
<ExternalLink className="size-4" />
</Button>
</Tooltiper>
<Tooltiper content="Make private">
<Button
type="button"
variant="destructive"
onClick={handleMakePrivate}
>
<TrashIcon className="size-4" />
</Button>
</Tooltiper>
</div>
</div>
)}
<form <form
onSubmit={handleSubmit((values) => { onSubmit={handleSubmit((values) => {
mutation.mutate(values); mutation.mutate({
...values,
// Only send password if it's not the placeholder
password:
values.password === '••••••••' ? null : values.password || null,
});
})} })}
> >
<Input <Input
{...register('password')} {...register('password')}
placeholder="Enter your password" placeholder="Enter your password (optional)"
size="large" size="large"
type={password === '••••••••' ? 'text' : 'password'}
/> />
<ButtonContainer> <ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}> <Button type="button" variant="outline" onClick={() => popModal()}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={mutation.isPending}>
Make it public <Button type="submit">
{isShared ? 'Update' : 'Make it public'}
</Button> </Button>
</ButtonContainer> </ButtonContainer>
</form> </form>

View File

@@ -11,8 +11,11 @@ import type { z } from 'zod';
import { zShareReport } from '@openpanel/validation'; import { zShareReport } from '@openpanel/validation';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltiper } from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
@@ -23,20 +26,37 @@ type IForm = z.infer<typeof validator>;
export default function ShareReportModal({ reportId }: { reportId: string }) { export default function ShareReportModal({ reportId }: { reportId: string }) {
const { projectId, organizationId } = useAppParams(); const { projectId, organizationId } = useAppParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const { register, handleSubmit } = useForm<IForm>({ const trpc = useTRPC();
const queryClient = useQueryClient();
// Fetch current share status
const shareQuery = useQuery(
trpc.share.report.queryOptions({
reportId,
}),
);
const existingShare = shareQuery.data;
const isShared = existingShare?.public ?? false;
const shareUrl = existingShare?.id
? `${window.location.origin}/share/report/${existingShare.id}`
: '';
const { register, handleSubmit, watch } = useForm<IForm>({
resolver: zodResolver(validator), resolver: zodResolver(validator),
defaultValues: { defaultValues: {
public: true, public: true,
password: '', password: existingShare?.password ? '••••••••' : '',
projectId, projectId,
organizationId, organizationId,
reportId, reportId,
}, },
}); });
const trpc = useTRPC(); const password = watch('password');
const queryClient = useQueryClient();
const mutation = useMutation( const mutation = useMutation(
trpc.share.createReport.mutationOptions({ trpc.share.createReport.mutationOptions({
onError: handleError, onError: handleError,
@@ -44,7 +64,8 @@ export default function ShareReportModal({ reportId }: { reportId: string }) {
queryClient.invalidateQueries(trpc.share.report.pathFilter()); queryClient.invalidateQueries(trpc.share.report.pathFilter());
toast('Success', { toast('Success', {
description: `Your report is now ${res.public ? 'public' : 'private'}`, description: `Your report is now ${res.public ? 'public' : 'private'}`,
action: { action: res.public
? {
label: 'View', label: 'View',
onClick: () => onClick: () =>
navigate({ navigate({
@@ -53,39 +74,113 @@ export default function ShareReportModal({ reportId }: { reportId: string }) {
shareId: res.id, shareId: res.id,
}, },
}), }),
}, }
: undefined,
}); });
popModal(); popModal();
}, },
}), }),
); );
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast('Link copied to clipboard');
};
const handleMakePrivate = () => {
mutation.mutate({
public: false,
password: null,
projectId,
organizationId,
reportId,
});
};
return ( return (
<ModalContent className="max-w-md"> <ModalContent className="max-w-md">
<ModalHeader <ModalHeader
title="Report public availability" title="Report public availability"
text="You can choose if you want to add a password to make it a bit more private." text={
isShared
? 'Your report is currently public and can be accessed by anyone with the link.'
: 'You can choose if you want to add a password to make it a bit more private.'
}
/> />
{isShared && (
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
<span className="font-medium">Currently shared</span>
</div>
<div className="flex items-center gap-1">
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
<Tooltiper content="Copy link">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyLink}
>
{copied ? (
<CheckCircle2 className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltiper>
<Tooltiper content="Open in new tab">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => window.open(shareUrl, '_blank')}
>
<ExternalLink className="size-4" />
</Button>
</Tooltiper>
<Tooltiper content="Make private">
<Button
type="button"
variant="destructive"
onClick={handleMakePrivate}
>
<TrashIcon className="size-4" />
</Button>
</Tooltiper>
</div>
</div>
)}
<form <form
onSubmit={handleSubmit((values) => { onSubmit={handleSubmit((values) => {
mutation.mutate(values); mutation.mutate({
...values,
// Only send password if it's not the placeholder
password:
values.password === '••••••••' ? null : values.password || null,
});
})} })}
> >
<Input <Input
{...register('password')} {...register('password')}
placeholder="Enter your password" placeholder="Enter your password (optional)"
size="large" size="large"
type={password === '••••••••' ? 'text' : 'password'}
/> />
<ButtonContainer> <ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}> <Button type="button" variant="outline" onClick={() => popModal()}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={mutation.isPending}>
Make it public <Button type="submit">
{isShared ? 'Update' : 'Make it public'}
</Button> </Button>
</ButtonContainer> </ButtonContainer>
</form> </form>
</ModalContent> </ModalContent>
); );
} }

View File

@@ -2,7 +2,6 @@ import {
HeadContent, HeadContent,
Scripts, Scripts,
createRootRouteWithContext, createRootRouteWithContext,
useRouteContext,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import 'flag-icons/css/flag-icons.min.css'; import 'flag-icons/css/flag-icons.min.css';

View File

@@ -1,6 +1,5 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportChart } from '@/components/report-chart';
import { Button, LinkButton } from '@/components/ui/button'; import { Button, LinkButton } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -9,49 +8,36 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { createProjectTitle } from '@/utils/title'; import { createProjectTitle } from '@/utils/title';
import { import {
CopyIcon,
LayoutPanelTopIcon, LayoutPanelTopIcon,
MoreHorizontal, MoreHorizontal,
PlusIcon, PlusIcon,
RotateCcw, RotateCcw,
ShareIcon, ShareIcon,
Trash,
TrashIcon, TrashIcon,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { timeWindows } from '@openpanel/constants';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
import {
GrafanaGrid,
type Layout,
useReportLayouts,
} from '@/components/grafana-grid';
import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range'; import { OverviewRange } from '@/components/overview/overview-range';
import { PageContainer } from '@/components/page-container'; import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import {
ReportItem,
ReportItemSkeleton,
} from '@/components/report/report-item';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react'; import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals'; import { pushModal, showConfirm } from '@/modals';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, useRouter } from '@tanstack/react-router';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
const ResponsiveGridLayout = WidthProvider(Responsive);
type Layout = {
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
};
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId/dashboards_/$dashboardId', '/_app/$organizationId/$projectId/dashboards_/$dashboardId',
@@ -95,180 +81,6 @@ export const Route = createFileRoute(
pendingComponent: FullPageLoadingState, pendingComponent: FullPageLoadingState,
}); });
// Report Skeleton Component
function ReportSkeleton() {
return (
<div className="card h-full flex flex-col animate-pulse">
<div className="flex items-center justify-between border-b border-border p-4">
<div className="flex-1">
<div className="h-5 w-32 bg-muted rounded mb-2" />
<div className="h-4 w-24 bg-muted/50 rounded" />
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-muted rounded" />
<div className="w-8 h-8 bg-muted rounded" />
</div>
</div>
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
</div>
);
}
// Report Item Component
function ReportItem({
report,
organizationId,
projectId,
range,
startDate,
endDate,
interval,
onDelete,
onDuplicate,
}: {
report: any;
organizationId: string;
projectId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
onDelete: (reportId: string) => void;
onDuplicate: (reportId: string) => void;
}) {
const router = useRouter();
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
<div
className="flex-1 cursor-pointer -m-4 p-4"
onClick={(event) => {
if (event.metaKey) {
window.open(
`/${organizationId}/${projectId}/reports/${report.id}`,
'_blank',
);
return;
}
router.navigate({
from: Route.fullPath,
to: '/$organizationId/$projectId/reports/$reportId',
params: {
reportId: report.id,
},
});
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
router.navigate({
from: Route.fullPath,
to: '/$organizationId/$projectId/reports/$reportId',
params: {
reportId: report.id,
},
});
}
}}
role="button"
tabIndex={0}
>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
className="opacity-30 hover:opacity-100"
>
<circle cx="4" cy="4" r="1.5" />
<circle cx="4" cy="8" r="1.5" />
<circle cx="4" cy="12" r="1.5" />
<circle cx="12" cy="4" r="1.5" />
<circle cx="12" cy="8" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
</svg>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDuplicate(report.id);
}}
>
<CopyIcon size={16} className="mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
onDelete(report.id);
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={
{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
} as any
}
/>
</div>
</div>
);
}
function Component() { function Component() {
const router = useRouter(); const router = useRouter();
const { organizationId, dashboardId, projectId } = Route.useParams(); const { organizationId, dashboardId, projectId } = Route.useParams();
@@ -364,26 +176,7 @@ function Component() {
); );
// Convert reports to grid layout format for all breakpoints // Convert reports to grid layout format for all breakpoints
const layouts = useMemo(() => { const layouts = useReportLayouts(reports);
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
// Create responsive layouts for different breakpoints
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
const handleLayoutChange = useCallback((newLayout: Layout[]) => { const handleLayoutChange = useCallback((newLayout: Layout[]) => {
// This is called during dragging/resizing, we'll save on drag/resize stop // This is called during dragging/resizing, we'll save on drag/resize stop
@@ -464,7 +257,7 @@ function Component() {
<PageHeader <PageHeader
title={dashboard.name} title={dashboard.name}
description="View and manage your reports" description="View and manage your reports"
className="mb-0" className="mb-4"
actions={ actions={
<> <>
<OverviewRange /> <OverviewRange />
@@ -486,7 +279,9 @@ function Component() {
<DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
onClick={() => pushModal('ShareDashboardModal', { dashboardId })} onClick={() =>
pushModal('ShareDashboardModal', { dashboardId })
}
> >
<ShareIcon className="mr-2 size-4" /> <ShareIcon className="mr-2 size-4" />
Share dashboard Share dashboard
@@ -539,47 +334,22 @@ function Component() {
</FullPageEmptyState> </FullPageEmptyState>
) : !isGridReady || reportsQuery.isLoading ? ( ) : !isGridReady || reportsQuery.isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ReportSkeleton /> <ReportItemSkeleton />
<ReportSkeleton /> <ReportItemSkeleton />
<ReportSkeleton /> <ReportItemSkeleton />
<ReportSkeleton /> <ReportItemSkeleton />
<ReportSkeleton /> <ReportItemSkeleton />
<ReportSkeleton /> <ReportItemSkeleton />
</div> </div>
) : ( ) : (
<div className="w-full overflow-hidden -mx-4"> <GrafanaGrid
<style>{` transitions={enableTransitions}
.react-grid-item {
transition: ${enableTransitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
}
.react-grid-item.react-grid-placeholder {
background: none !important;
opacity: 0.5;
transition-duration: 100ms;
border-radius: 0.5rem;
border: 1px dashed var(--primary);
}
.react-grid-item.resizing {
transition: none !important;
}
`}</style>
<ResponsiveGridLayout
className="layout"
layouts={layouts} layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
onLayoutChange={handleLayoutChange} onLayoutChange={handleLayoutChange}
onDragStop={handleDragStop} onDragStop={handleDragStop}
onResizeStop={handleResizeStop} onResizeStop={handleResizeStop}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isDraggable={true} isDraggable={true}
isResizable={true} isResizable={true}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
> >
{reports.map((report) => ( {reports.map((report) => (
<div key={report.id}> <div key={report.id}>
@@ -600,8 +370,7 @@ function Component() {
/> />
</div> </div>
))} ))}
</ResponsiveGridLayout> </GrafanaGrid>
</div>
)} )}
</PageContainer> </PageContainer>
); );

View File

@@ -274,20 +274,16 @@ const PageCard = memo(
</div> </div>
<ReportChart <ReportChart
options={{ options={{
hideID: true,
hideXAxis: true, hideXAxis: true,
hideYAxis: true, hideYAxis: true,
aspectRatio: 0.15, aspectRatio: 0.15,
}} }}
report={{ report={{
lineType: 'linear',
breakdowns: [], breakdowns: [],
name: 'screen_view',
metric: 'sum', metric: 'sum',
range, range,
interval, interval,
previous: true, previous: true,
chartType: 'linear', chartType: 'linear',
projectId, projectId,
series: [ series: [

View File

@@ -1,36 +1,23 @@
import { ShareEnterPassword } from '@/components/auth/share-enter-password'; import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
import { GrafanaGrid, useReportLayouts } from '@/components/grafana-grid';
import { LoginNavbar } from '@/components/login-navbar'; import { LoginNavbar } from '@/components/login-navbar';
import { ReportChart } from '@/components/report-chart';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { PageContainer } from '@/components/page-container'; import { ReportChart } from '@/components/report-chart';
import {
ReportItem,
ReportItemReadOnly,
ReportItemSkeleton,
} from '@/components/report/report-item';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { timeWindows } from '@openpanel/constants';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router'; import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
import { z } from 'zod'; import { z } from 'zod';
import { useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { cn } from '@/utils/cn';
import { timeWindows } from '@openpanel/constants';
const ResponsiveGridLayout = WidthProvider(Responsive);
type Layout = {
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
};
const shareSearchSchema = z.object({ const shareSearchSchema = z.object({
header: z.optional(z.number().or(z.string().or(z.boolean()))), header: z.optional(z.number().or(z.string().or(z.boolean()))),
@@ -77,79 +64,6 @@ export const Route = createFileRoute('/share/dashboard/$shareId')({
), ),
}); });
// Report Item Component for shared view
function ReportItem({
report,
shareId,
range,
startDate,
endDate,
interval,
}: {
report: any;
shareId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
}) {
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
<div className="flex-1">
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={
{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
} as any
}
shareId={shareId}
shareType="dashboard"
/>
</div>
</div>
);
}
function RouteComponent() { function RouteComponent() {
const { shareId } = Route.useParams(); const { shareId } = Route.useParams();
const { header } = useSearch({ from: '/share/dashboard/$shareId' }); const { header } = useSearch({ from: '/share/dashboard/$shareId' });
@@ -182,9 +96,7 @@ function RouteComponent() {
// Handle password protection // Handle password protection
if (share.password && !hasAccess) { if (share.password && !hasAccess) {
return ( return <ShareEnterPassword shareId={share.id} shareType="dashboard" />;
<ShareEnterPassword shareId={share.id} shareType="dashboard" />
);
} }
const isHeaderVisible = const isHeaderVisible =
@@ -193,26 +105,7 @@ function RouteComponent() {
const reports = reportsQuery.data ?? []; const reports = reportsQuery.data ?? [];
// Convert reports to grid layout format for all breakpoints // Convert reports to grid layout format for all breakpoints
const layouts = useMemo(() => { const layouts = useReportLayouts(reports);
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
// Create responsive layouts for different breakpoints
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
return ( return (
<div> <div>
@@ -221,7 +114,6 @@ function RouteComponent() {
<LoginNavbar className="relative p-4" /> <LoginNavbar className="relative p-4" />
</div> </div>
)} )}
<PageContainer>
<div className="sticky-header [animation-range:50px_100px]!"> <div className="sticky-header [animation-range:50px_100px]!">
<div className="p-4 col gap-2 mx-auto max-w-7xl"> <div className="p-4 col gap-2 mx-auto max-w-7xl">
<div className="row justify-between"> <div className="row justify-between">
@@ -232,36 +124,23 @@ function RouteComponent() {
</div> </div>
</div> </div>
</div> </div>
{reports.length === 0 ? ( <div className="mx-auto max-w-7xl p-4">
{reportsQuery.isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ReportItemSkeleton />
<ReportItemSkeleton />
<ReportItemSkeleton />
<ReportItemSkeleton />
<ReportItemSkeleton />
<ReportItemSkeleton />
</div>
) : reports.length === 0 ? (
<FullPageEmptyState title="No reports" /> <FullPageEmptyState title="No reports" />
) : ( ) : (
<div className="w-full overflow-hidden -mx-4"> <GrafanaGrid layouts={layouts}>
<style>{`
.react-grid-item {
transition: none !important;
}
.react-grid-item.react-grid-placeholder {
display: none !important;
}
`}</style>
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
isDraggable={false}
isResizable={false}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
>
{reports.map((report) => ( {reports.map((report) => (
<div key={report.id}> <div key={report.id}>
<ReportItem <ReportItemReadOnly
report={report} report={report}
shareId={shareId} shareId={shareId}
range={range} range={range}
@@ -271,11 +150,9 @@ function RouteComponent() {
/> />
</div> </div>
))} ))}
</ResponsiveGridLayout> </GrafanaGrid>
</div>
)} )}
</PageContainer> </div>
</div> </div>
); );
} }

View File

@@ -2,10 +2,9 @@ import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar'; import { LoginNavbar } from '@/components/login-navbar';
import { ReportChart } from '@/components/report-chart';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewInterval } from '@/components/overview/overview-interval';
import { PageContainer } from '@/components/page-container'; import { OverviewRange } from '@/components/overview/overview-range';
import { ReportChart } from '@/components/report-chart';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router'; import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
@@ -29,13 +28,7 @@ export const Route = createFileRoute('/share/report/$shareId')({
return { share: null }; return { share: null };
} }
const report = await context.queryClient.ensureQueryData( return { share };
context.trpc.report.get.queryOptions({
reportId: share.reportId,
}),
);
return { share, report };
}, },
head: ({ loaderData }) => { head: ({ loaderData }) => {
if (!loaderData || !loaderData.share) { if (!loaderData || !loaderData.share) {
@@ -51,7 +44,7 @@ export const Route = createFileRoute('/share/report/$shareId')({
return { return {
meta: [ meta: [
{ {
title: `${loaderData.report?.name || 'Report'} - ${loaderData.share.organization?.name} - OpenPanel.dev`, title: `${loaderData.share.report.name || 'Report'} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
}, },
], ],
}; };
@@ -76,12 +69,6 @@ function RouteComponent() {
}), }),
); );
const reportQuery = useSuspenseQuery(
trpc.report.get.queryOptions({
reportId: shareQuery.data!.reportId,
}),
);
const hasAccess = shareQuery.data?.hasAccess; const hasAccess = shareQuery.data?.hasAccess;
if (!shareQuery.data) { if (!shareQuery.data) {
@@ -93,7 +80,8 @@ function RouteComponent() {
} }
const share = shareQuery.data; const share = shareQuery.data;
const report = reportQuery.data;
console.log('share', share);
// Handle password protection // Handle password protection
if (share.password && !hasAccess) { if (share.password && !hasAccess) {
@@ -110,7 +98,6 @@ function RouteComponent() {
<LoginNavbar className="relative p-4" /> <LoginNavbar className="relative p-4" />
</div> </div>
)} )}
<PageContainer>
<div className="sticky-header [animation-range:50px_100px]!"> <div className="sticky-header [animation-range:50px_100px]!">
<div className="p-4 col gap-2 mx-auto max-w-7xl"> <div className="p-4 col gap-2 mx-auto max-w-7xl">
<div className="row justify-between"> <div className="row justify-between">
@@ -121,22 +108,16 @@ function RouteComponent() {
</div> </div>
</div> </div>
</div> </div>
<div className="p-4"> <div className="mx-auto max-w-7xl p-4">
<div className="card"> <div className="card">
<div className="p-4 border-b"> <div className="p-4 border-b">
<div className="font-medium text-xl">{report.name}</div> <div className="font-medium text-xl">{share.report.name}</div>
</div> </div>
<div className="p-4"> <div className="p-4">
<ReportChart <ReportChart report={share.report} shareId={shareId} />
report={report}
shareId={shareId}
shareType="report"
/>
</div> </div>
</div> </div>
</div> </div>
</PageContainer>
</div> </div>
); );
} }

View File

@@ -94,10 +94,11 @@ export function transformReport(
| 'on_or_after' | 'on_or_after'
| 'on' | 'on'
| undefined, | undefined,
layout: report.layout ?? undefined,
options: options ?? undefined,
// Depercated, just for frontend backward compatibility (will be removed)
funnelGroup: report.funnelGroup ?? undefined, funnelGroup: report.funnelGroup ?? undefined,
funnelWindow: report.funnelWindow ?? undefined, funnelWindow: report.funnelWindow ?? undefined,
options: options ?? undefined,
layout: report.layout ?? undefined,
}; };
} }

View File

@@ -1,4 +1,5 @@
import { db } from '../prisma-client'; import { db } from '../prisma-client';
import { getProjectAccess } from './access.service';
export function getShareOverviewById(id: string) { export function getShareOverviewById(id: string) {
return db.shareOverview.findFirst({ return db.shareOverview.findFirst({
@@ -96,7 +97,8 @@ export async function validateReportAccess(
} }
return share; return share;
} else { }
const share = await db.shareReport.findUnique({ const share = await db.shareReport.findUnique({
where: { id: shareId }, where: { id: shareId },
include: { include: {
@@ -114,4 +116,100 @@ export async function validateReportAccess(
return share; return share;
} }
// Unified validation for share access
export async function validateShareAccess(
shareId: string,
reportId: string,
ctx: {
cookies: Record<string, string | undefined>;
session?: { userId?: string | null };
},
): Promise<{ projectId: string; isValid: boolean }> {
// Check ShareDashboard first
const dashboardShare = await db.shareDashboard.findUnique({
where: { id: shareId },
include: {
dashboard: {
include: {
reports: {
where: { id: reportId },
},
},
},
},
});
if (
dashboardShare?.dashboard?.reports &&
dashboardShare.dashboard.reports.length > 0
) {
if (!dashboardShare.public) {
throw new Error('Share not found or not public');
}
const projectId = dashboardShare.projectId;
// If no password is set, share is public and accessible
if (!dashboardShare.password) {
return {
projectId,
isValid: true,
};
}
// If password is set, require cookie OR member access
const hasCookie = !!ctx.cookies[`shared-dashboard-${shareId}`];
const hasMemberAccess =
ctx.session?.userId &&
(await getProjectAccess({
userId: ctx.session.userId,
projectId,
}));
return {
projectId,
isValid: hasCookie || !!hasMemberAccess,
};
}
// Check ShareReport
const reportShare = await db.shareReport.findUnique({
where: { id: shareId, reportId },
include: {
report: true,
},
});
if (reportShare) {
if (!reportShare.public) {
throw new Error('Share not found or not public');
}
const projectId = reportShare.projectId;
// If no password is set, share is public and accessible
if (!reportShare.password) {
return {
projectId,
isValid: true,
};
}
// If password is set, require cookie OR member access
const hasCookie = !!ctx.cookies[`shared-report-${shareId}`];
const hasMemberAccess =
ctx.session?.userId &&
(await getProjectAccess({
userId: ctx.session.userId,
projectId,
}));
return {
projectId,
isValid: hasCookie || !!hasMemberAccess,
};
}
throw new Error('Share not found');
} }

View File

@@ -11,7 +11,6 @@ import {
clix, clix,
conversionService, conversionService,
createSqlBuilder, createSqlBuilder,
db,
formatClickhouseDate, formatClickhouseDate,
funnelService, funnelService,
getChartPrevStartEndDate, getChartPrevStartEndDate,
@@ -24,7 +23,7 @@ import {
getSettingsForProject, getSettingsForProject,
onlyReportEvents, onlyReportEvents,
sankeyService, sankeyService,
validateReportAccess, validateShareAccess,
} from '@openpanel/db'; } from '@openpanel/db';
import { import {
type IChartEvent, type IChartEvent,
@@ -334,15 +333,79 @@ export const chartRouter = createTRPCRouter({
}; };
}), }),
funnel: protectedProcedure.input(zChartInput).query(async ({ input }) => { funnel: publicProcedure
const { timezone } = await getSettingsForProject(input.projectId); .input(
const currentPeriod = getChartStartEndDate(input, timezone); zChartInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
}),
),
)
.query(async ({ input, ctx }) => {
let chartInput = input;
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
{
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
},
);
if (!shareValidation.isValid) {
throw TRPCAccessError('You do not have access to this share');
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
chartInput = {
...report,
// Only allow date overrides
range: input.range ?? report.range,
startDate: input.startDate ?? report.startDate,
endDate: input.endDate ?? report.endDate,
interval: input.interval ?? report.interval,
};
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(chartInput.projectId);
const currentPeriod = getChartStartEndDate(chartInput, timezone);
const previousPeriod = getChartPrevStartEndDate(currentPeriod); const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([ const [current, previous] = await Promise.all([
funnelService.getFunnel({ ...input, ...currentPeriod, timezone }), funnelService.getFunnel({ ...chartInput, ...currentPeriod, timezone }),
input.previous chartInput.previous
? funnelService.getFunnel({ ...input, ...previousPeriod, timezone }) ? funnelService.getFunnel({
...chartInput,
...previousPeriod,
timezone,
})
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
@@ -352,17 +415,85 @@ export const chartRouter = createTRPCRouter({
}; };
}), }),
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => { conversion: publicProcedure
const { timezone } = await getSettingsForProject(input.projectId); .input(
const currentPeriod = getChartStartEndDate(input, timezone); zChartInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
}),
),
)
.query(async ({ input, ctx }) => {
let chartInput = input;
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
{
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
},
);
if (!shareValidation.isValid) {
throw TRPCAccessError('You do not have access to this share');
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
chartInput = {
...report,
// Only allow date overrides
range: input.range ?? report.range,
startDate: input.startDate ?? report.startDate,
endDate: input.endDate ?? report.endDate,
interval: input.interval ?? report.interval,
};
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(chartInput.projectId);
const currentPeriod = getChartStartEndDate(chartInput, timezone);
const previousPeriod = getChartPrevStartEndDate(currentPeriod); const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const interval = chartInput.interval;
const [current, previous] = await Promise.all([ const [current, previous] = await Promise.all([
conversionService.getConversion({ ...input, ...currentPeriod, timezone }), conversionService.getConversion({
input.previous ...chartInput,
...currentPeriod,
interval,
timezone,
}),
chartInput.previous
? conversionService.getConversion({ ? conversionService.getConversion({
...input, ...chartInput,
...previousPeriod, ...previousPeriod,
interval,
timezone, timezone,
}) })
: Promise.resolve(null), : Promise.resolve(null),
@@ -414,76 +545,130 @@ export const chartRouter = createTRPCRouter({
chart: publicProcedure chart: publicProcedure
// .use(cacher) // .use(cacher)
.input(zChartInput) .input(
zChartInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
}),
),
)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.session.userId) { let chartInput = input;
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
ctx,
);
if (!shareValidation.isValid) {
throw TRPCAccessError('You do not have access to this share');
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
chartInput = {
...report,
// Only allow date overrides
range: input.range ?? report.range,
startDate: input.startDate ?? report.startDate,
endDate: input.endDate ?? report.endDate,
interval: input.interval ?? report.interval,
};
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({ const access = await getProjectAccess({
projectId: input.projectId, projectId: input.projectId,
userId: ctx.session.userId, userId: ctx.session.userId,
}); });
if (!access) { if (!access) {
const share = await db.shareOverview.findFirst({
where: {
projectId: input.projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project');
}
}
} else {
const share = await db.shareOverview.findFirst({
where: {
projectId: input.projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project'); throw TRPCAccessError('You do not have access to this project');
} }
} }
// Use new chart engine return ChartEngine.execute(chartInput);
return ChartEngine.execute(input);
}), }),
aggregate: publicProcedure aggregate: publicProcedure
.input(zChartInput) .input(
zChartInput.and(
z.object({
shareId: z.string().optional(),
reportId: z.string().optional(),
}),
),
)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.session.userId) { let chartInput = input;
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
{
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
},
);
if (!shareValidation.isValid) {
throw TRPCAccessError('You do not have access to this share');
}
// Fetch report and merge date overrides
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
chartInput = {
...report,
// Only allow date overrides
range: input.range ?? report.range,
startDate: input.startDate ?? report.startDate,
endDate: input.endDate ?? report.endDate,
interval: input.interval ?? report.interval,
};
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({ const access = await getProjectAccess({
projectId: input.projectId, projectId: input.projectId,
userId: ctx.session.userId, userId: ctx.session.userId,
}); });
if (!access) { if (!access) {
const share = await db.shareOverview.findFirst({
where: {
projectId: input.projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project');
}
}
} else {
const share = await db.shareOverview.findFirst({
where: {
projectId: input.projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project'); throw TRPCAccessError('You do not have access to this project');
} }
} }
// Use aggregate chart engine (optimized for bar/pie charts) return AggregateChartEngine.execute(chartInput);
return AggregateChartEngine.execute(input);
}), }),
cohort: protectedProcedure cohort: publicProcedure
.input( .input(
z.object({ z.object({
projectId: z.string(), projectId: z.string(),
@@ -494,26 +679,109 @@ export const chartRouter = createTRPCRouter({
endDate: z.string().nullish(), endDate: z.string().nullish(),
interval: zTimeInterval.default('day'), interval: zTimeInterval.default('day'),
range: zRange, range: zRange,
shareId: z.string().optional(),
reportId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const { timezone } = await getSettingsForProject(input.projectId); let projectId = input.projectId;
const { projectId, firstEvent, secondEvent } = input; let firstEvent = input.firstEvent;
const dates = getChartStartEndDate(input, timezone); let secondEvent = input.secondEvent;
let criteria = input.criteria;
let dateRange = input.range;
let startDate = input.startDate;
let endDate = input.endDate;
let interval = input.interval;
if (input.shareId) {
// Require reportId when shareId provided
if (!input.reportId) {
throw new Error('reportId required with shareId');
}
// Validate share access
const shareValidation = await validateShareAccess(
input.shareId,
input.reportId,
{
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
},
);
if (!shareValidation.isValid) {
throw TRPCAccessError('You do not have access to this share');
}
// Fetch report and extract events
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
projectId = report.projectId;
criteria = report.criteria ?? criteria;
dateRange = input.range ?? report.range;
startDate = input.startDate ?? report.startDate;
endDate = input.endDate ?? report.endDate;
interval = input.interval ?? report.interval;
// Extract events from report series
const eventSeries = onlyReportEvents(report.series);
const extractedFirstEvent = (
eventSeries[0]?.filters?.[0]?.value ?? []
).map(String);
const extractedSecondEvent = (
eventSeries[1]?.filters?.[0]?.value ?? []
).map(String);
if (
extractedFirstEvent.length === 0 ||
extractedSecondEvent.length === 0
) {
throw new Error('Report must have at least 2 event series');
}
firstEvent = extractedFirstEvent;
secondEvent = extractedSecondEvent;
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(projectId);
const dates = getChartStartEndDate(
{
range: dateRange,
startDate,
endDate,
},
timezone,
);
const diffInterval = { const diffInterval = {
minute: () => differenceInDays(dates.endDate, dates.startDate), minute: () => differenceInDays(dates.endDate, dates.startDate),
hour: () => differenceInDays(dates.endDate, dates.startDate), hour: () => differenceInDays(dates.endDate, dates.startDate),
day: () => differenceInDays(dates.endDate, dates.startDate), day: () => differenceInDays(dates.endDate, dates.startDate),
week: () => differenceInWeeks(dates.endDate, dates.startDate), week: () => differenceInWeeks(dates.endDate, dates.startDate),
month: () => differenceInMonths(dates.endDate, dates.startDate), month: () => differenceInMonths(dates.endDate, dates.startDate),
}[input.interval](); }[interval]();
const sqlInterval = { const sqlInterval = {
minute: 'DAY', minute: 'DAY',
hour: 'DAY', hour: 'DAY',
day: 'DAY', day: 'DAY',
week: 'WEEK', week: 'WEEK',
month: 'MONTH', month: 'MONTH',
}[input.interval]; }[interval];
const sqlToStartOf = { const sqlToStartOf = {
minute: 'toDate', minute: 'toDate',
@@ -521,9 +789,9 @@ export const chartRouter = createTRPCRouter({
day: 'toDate', day: 'toDate',
week: 'toStartOfWeek', week: 'toStartOfWeek',
month: 'toStartOfMonth', month: 'toStartOfMonth',
}[input.interval]; }[interval];
const countCriteria = input.criteria === 'on_or_after' ? '>=' : '='; const countCriteria = criteria === 'on_or_after' ? '>=' : '=';
const usersSelect = range(0, diffInterval + 1) const usersSelect = range(0, diffInterval + 1)
.map( .map(
@@ -817,397 +1085,6 @@ export const chartRouter = createTRPCRouter({
return profiles; return profiles;
}), }),
chartByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
// Build chart input from report, merging date overrides
const chartInput: z.infer<typeof zChartInput> = {
projectId: report.projectId,
chartType: report.chartType,
series: report.series,
breakdowns: report.breakdowns,
interval: input.interval ?? report.interval,
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
previous: report.previous,
formula: report.formula,
metric: report.metric,
};
return ChartEngine.execute(chartInput);
}),
aggregateByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
// Build chart input from report, merging date overrides
const chartInput: z.infer<typeof zChartInput> = {
projectId: report.projectId,
chartType: report.chartType,
series: report.series,
breakdowns: report.breakdowns,
interval: input.interval ?? report.interval,
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
previous: report.previous,
formula: report.formula,
metric: report.metric,
};
return AggregateChartEngine.execute(chartInput);
}),
funnelByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const currentPeriod = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
funnelService.getFunnel({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...currentPeriod,
timezone,
funnelGroup: report.funnelGroup,
funnelWindow: report.funnelWindow,
}),
report.previous
? funnelService.getFunnel({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...previousPeriod,
timezone,
funnelGroup: report.funnelGroup,
funnelWindow: report.funnelWindow,
})
: Promise.resolve(null),
]);
return {
current,
previous,
};
}),
cohortByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const eventSeries = onlyReportEvents(report.series);
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(
String,
);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(
String,
);
if (firstEvent.length === 0 || secondEvent.length === 0) {
throw new Error('Report must have at least 2 event series');
}
const dates = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const interval = (input.interval ?? report.interval) as
| 'minute'
| 'hour'
| 'day'
| 'week'
| 'month';
const diffInterval = {
minute: () => differenceInDays(dates.endDate, dates.startDate),
hour: () => differenceInDays(dates.endDate, dates.startDate),
day: () => differenceInDays(dates.endDate, dates.startDate),
week: () => differenceInWeeks(dates.endDate, dates.startDate),
month: () => differenceInMonths(dates.endDate, dates.startDate),
}[interval]();
const sqlInterval = {
minute: 'DAY',
hour: 'DAY',
day: 'DAY',
week: 'WEEK',
month: 'MONTH',
}[interval];
const sqlToStartOf = {
minute: 'toDate',
hour: 'toDate',
day: 'toDate',
week: 'toStartOfWeek',
month: 'toStartOfMonth',
}[interval];
const countCriteria =
(report.criteria ?? 'on_or_after') === 'on_or_after' ? '>=' : '=';
const usersSelect = range(0, diffInterval + 1)
.map(
(index) =>
`groupUniqArrayIf(profile_id, x_after_cohort ${countCriteria} ${index}) AS interval_${index}_users`,
)
.join(',\n');
const countsSelect = range(0, diffInterval + 1)
.map(
(index) =>
`length(interval_${index}_users) AS interval_${index}_user_count`,
)
.join(',\n');
const whereEventNameIs = (event: string[]) => {
if (event.length === 1) {
return `name = ${sqlstring.escape(event[0])}`;
}
return `name IN (${event.map((e) => sqlstring.escape(e)).join(',')})`;
};
const cohortQuery = `
WITH
cohort_users AS (
SELECT
profile_id AS userID,
project_id,
${sqlToStartOf}(created_at) AS cohort_interval
FROM ${TABLE_NAMES.cohort_events_mv}
WHERE ${whereEventNameIs(firstEvent)}
AND project_id = ${sqlstring.escape(report.projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}')
),
last_event AS
(
SELECT
profile_id,
project_id,
toDate(created_at) AS event_date
FROM cohort_events_mv
WHERE ${whereEventNameIs(secondEvent)}
AND project_id = ${sqlstring.escape(report.projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}') + INTERVAL ${diffInterval} ${sqlInterval}
),
retention_matrix AS
(
SELECT
f.cohort_interval,
l.profile_id,
dateDiff('${sqlInterval}', f.cohort_interval, ${sqlToStartOf}(l.event_date)) AS x_after_cohort
FROM cohort_users AS f
INNER JOIN last_event AS l ON f.userID = l.profile_id
WHERE (l.event_date >= f.cohort_interval)
AND (l.event_date <= (f.cohort_interval + INTERVAL ${diffInterval} ${sqlInterval}))
),
interval_users AS (
SELECT
cohort_interval,
${usersSelect}
FROM retention_matrix
GROUP BY cohort_interval
),
cohort_sizes AS (
SELECT
cohort_interval,
COUNT(DISTINCT userID) AS total_first_event_count
FROM cohort_users
GROUP BY cohort_interval
)
SELECT
cohort_interval,
cohort_sizes.total_first_event_count,
${countsSelect}
FROM interval_users
LEFT JOIN cohort_sizes AS cs ON cohort_interval = cs.cohort_interval
ORDER BY cohort_interval ASC
`;
const cohortData = await chQuery<{
cohort_interval: string;
total_first_event_count: number;
[key: string]: any;
}>(cohortQuery);
return processCohortData(cohortData, diffInterval);
}),
conversionByReport: publicProcedure
.input(
z.object({
reportId: z.string(),
shareId: z.string(),
shareType: z.enum(['dashboard', 'report']),
range: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
interval: zTimeInterval.optional(),
}),
)
.query(async ({ input }) => {
// Validate access
await validateReportAccess(
input.reportId,
input.shareId,
input.shareType,
);
// Load report from DB
const report = await getReportById(input.reportId);
if (!report) {
throw TRPCAccessError('Report not found');
}
const { timezone } = await getSettingsForProject(report.projectId);
const currentPeriod = getChartStartEndDate(
{
range: input.range ?? report.range,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
interval: input.interval ?? report.interval,
},
timezone,
);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([
conversionService.getConversion({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...currentPeriod,
timezone,
}),
report.previous
? conversionService.getConversion({
projectId: report.projectId,
series: report.series,
breakdowns: report.breakdowns,
...previousPeriod,
timezone,
})
: Promise.resolve(null),
]);
return {
current: current.map((serie, sIndex) => ({
...serie,
data: serie.data.map((d, dIndex) => ({
...d,
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
})),
})),
previous,
};
}),
}); });
function processCohortData( function processCohortData(

View File

@@ -2,12 +2,17 @@ import ShortUniqueId from 'short-unique-id';
import { import {
db, db,
getReportsByDashboardId,
getReportById, getReportById,
getReportsByDashboardId,
getShareDashboardById, getShareDashboardById,
getShareReportById, getShareReportById,
transformReport,
} from '@openpanel/db'; } from '@openpanel/db';
import { zShareDashboard, zShareOverview, zShareReport } from '@openpanel/validation'; import {
zShareDashboard,
zShareOverview,
zShareReport,
} from '@openpanel/validation';
import { hashPassword } from '@openpanel/auth'; import { hashPassword } from '@openpanel/auth';
import { z } from 'zod'; import { z } from 'zod';
@@ -231,11 +236,7 @@ export const shareRouter = createTRPCRouter({
name: true, name: true,
}, },
}, },
report: { report: true,
select: {
name: true,
},
},
}, },
where: where:
'reportId' in input 'reportId' in input
@@ -257,6 +258,7 @@ export const shareRouter = createTRPCRouter({
return { return {
...share, ...share,
hasAccess: !!ctx.cookies[`shared-report-${share?.id}`], hasAccess: !!ctx.cookies[`shared-report-${share?.id}`],
report: transformReport(share.report),
}; };
}), }),

View File

@@ -86,16 +86,12 @@ export const zChartBreakdown = z.object({
name: z.string(), name: z.string(),
}); });
// Support both old format (array of events without type) and new format (array of event/formula items)
// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
export const zChartSeries = z export const zChartSeries = z
.array(zChartEventItem) .array(zChartEventItem)
.describe( .describe(
'Array of series (events or formulas) to be tracked and displayed in the chart', 'Array of series (events or formulas) to be tracked and displayed in the chart',
); );
// Keep zChartEvents as an alias for backward compatibility during migration
export const zChartEvents = zChartSeries;
export const zChartBreakdowns = z.array(zChartBreakdown); export const zChartBreakdowns = z.array(zChartBreakdown);
export const zChartType = z.enum(objectToZodEnums(chartTypes)); export const zChartType = z.enum(objectToZodEnums(chartTypes));
@@ -501,7 +497,10 @@ export type IRequestResetPassword = z.infer<typeof zRequestResetPassword>;
export const zSignInShare = z.object({ export const zSignInShare = z.object({
password: z.string().min(1), password: z.string().min(1),
shareId: z.string().min(1), shareId: z.string().min(1),
shareType: z.enum(['overview', 'dashboard', 'report']).optional().default('overview'), shareType: z
.enum(['overview', 'dashboard', 'report'])
.optional()
.default('overview'),
}); });
export type ISignInShare = z.infer<typeof zSignInShare>; export type ISignInShare = z.infer<typeof zSignInShare>;

View File

@@ -1,28 +0,0 @@
import { zChartEvents } from '.';
const events = [
{
id: 'sAmT',
type: 'event',
name: 'session_end',
segment: 'event',
filters: [],
},
{
id: '5K2v',
type: 'event',
name: 'session_start',
segment: 'event',
filters: [],
},
{
id: 'lQiQ',
type: 'formula',
formula: 'A/B',
displayName: '',
},
];
const res = zChartEvents.safeParse(events);
console.log(res);

View File

@@ -28,7 +28,6 @@ export type IChartProps = z.infer<typeof zReportInput> & {
name: string; name: string;
lineType: IChartLineType; lineType: IChartLineType;
unit?: string; unit?: string;
previousIndicatorInverted?: boolean;
}; };
export type IChartEvent = z.infer<typeof zChartEvent>; export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartFormula = z.infer<typeof zChartFormula>; export type IChartFormula = z.infer<typeof zChartFormula>;