wip 1
This commit is contained in:
95
apps/start/src/components/grafana-grid.tsx
Normal file
95
apps/start/src/components/grafana-grid.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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 input: IChartInput = {
|
||||||
|
series,
|
||||||
|
range: overviewRange ?? range,
|
||||||
|
projectId,
|
||||||
|
interval: overviewInterval ?? interval ?? 'day',
|
||||||
|
chartType: 'funnel',
|
||||||
|
breakdowns,
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
previous,
|
||||||
|
metric: 'sum',
|
||||||
|
startDate: overviewStartDate ?? startDate,
|
||||||
|
endDate: overviewEndDate ?? endDate,
|
||||||
|
limit: 20,
|
||||||
|
shareId,
|
||||||
|
reportId: id,
|
||||||
|
};
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
shareId && shareType && id
|
trpc.chart.funnel.queryOptions(input, {
|
||||||
? trpc.chart.funnelByReport.queryOptions(
|
enabled: !isLazyLoading && input.series.length > 0,
|
||||||
{
|
}),
|
||||||
reportId: id,
|
|
||||||
shareId,
|
|
||||||
shareType,
|
|
||||||
range: overviewRange ?? undefined,
|
|
||||||
startDate: overviewStartDate ?? undefined,
|
|
||||||
endDate: overviewEndDate ?? undefined,
|
|
||||||
interval: overviewInterval ?? undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !isLazyLoading && series.length > 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: (() => {
|
|
||||||
const input: IChartInput = {
|
|
||||||
series,
|
|
||||||
range,
|
|
||||||
projectId,
|
|
||||||
interval: 'day',
|
|
||||||
chartType: 'funnel',
|
|
||||||
breakdowns,
|
|
||||||
funnelWindow,
|
|
||||||
funnelGroup,
|
|
||||||
previous,
|
|
||||||
metric: 'sum',
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
limit: 20,
|
|
||||||
};
|
|
||||||
return trpc.chart.funnel.queryOptions(input, {
|
|
||||||
enabled: !isLazyLoading && input.series.length > 0,
|
|
||||||
});
|
|
||||||
})(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLazyLoading || res.isLoading) {
|
if (isLazyLoading || res.isLoading) {
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
{
|
||||||
{
|
...report,
|
||||||
reportId: report.id,
|
shareId,
|
||||||
shareId,
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
shareType,
|
range: range ?? report.range,
|
||||||
range: range ?? undefined,
|
startDate: startDate ?? report.startDate,
|
||||||
startDate: startDate ?? undefined,
|
endDate: endDate ?? report.endDate,
|
||||||
endDate: endDate ?? undefined,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? undefined,
|
},
|
||||||
},
|
{
|
||||||
{
|
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 (
|
||||||
|
|||||||
@@ -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,40 +33,25 @@ 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(
|
{
|
||||||
{
|
firstEvent,
|
||||||
reportId: id,
|
secondEvent,
|
||||||
shareId,
|
projectId,
|
||||||
shareType,
|
range: overviewRange ?? range,
|
||||||
range: overviewRange ?? undefined,
|
startDate: overviewStartDate ?? startDate,
|
||||||
startDate: overviewStartDate ?? undefined,
|
endDate: overviewEndDate ?? endDate,
|
||||||
endDate: overviewEndDate ?? undefined,
|
criteria,
|
||||||
interval: overviewInterval ?? undefined,
|
interval: overviewInterval ?? interval,
|
||||||
},
|
shareId,
|
||||||
{
|
reportId: id,
|
||||||
placeholderData: keepPreviousData,
|
},
|
||||||
staleTime: 1000 * 60 * 1,
|
{
|
||||||
enabled: isEnabled,
|
placeholderData: keepPreviousData,
|
||||||
},
|
staleTime: 1000 * 60 * 1,
|
||||||
)
|
enabled: isEnabled,
|
||||||
: trpc.chart.cohort.queryOptions(
|
},
|
||||||
{
|
),
|
||||||
firstEvent,
|
|
||||||
secondEvent,
|
|
||||||
projectId,
|
|
||||||
range,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
criteria,
|
|
||||||
interval,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
staleTime: 1000 * 60 * 1,
|
|
||||||
enabled: isEnabled,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
|
|||||||
258
apps/start/src/components/report/report-item.tsx
Normal file
258
apps/start/src/components/report/report-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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 {
|
||||||
...options,
|
console.log('fetching', url, options);
|
||||||
mode: 'cors',
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
...options,
|
||||||
});
|
mode: 'cors',
|
||||||
|
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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,48 +70,123 @@ 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',
|
? {
|
||||||
onClick: () =>
|
label: 'View',
|
||||||
navigate({
|
onClick: () =>
|
||||||
to: '/share/dashboard/$shareId',
|
navigate({
|
||||||
params: {
|
to: '/share/dashboard/$shareId',
|
||||||
shareId: res.id,
|
params: {
|
||||||
},
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,44 +65,119 @@ 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',
|
? {
|
||||||
onClick: () =>
|
label: 'View',
|
||||||
navigate({
|
onClick: () =>
|
||||||
to: '/share/overview/$shareId',
|
navigate({
|
||||||
params: {
|
to: '/share/overview/$shareId',
|
||||||
shareId: res.id,
|
params: {
|
||||||
},
|
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>
|
||||||
|
|||||||
@@ -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,48 +64,123 @@ 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',
|
? {
|
||||||
onClick: () =>
|
label: 'View',
|
||||||
navigate({
|
onClick: () =>
|
||||||
to: '/share/report/$shareId',
|
navigate({
|
||||||
params: {
|
to: '/share/report/$shareId',
|
||||||
shareId: res.id,
|
params: {
|
||||||
},
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,69 +334,43 @@ 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 {
|
layouts={layouts}
|
||||||
transition: ${enableTransitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
|
onLayoutChange={handleLayoutChange}
|
||||||
}
|
onDragStop={handleDragStop}
|
||||||
.react-grid-item.react-grid-placeholder {
|
onResizeStop={handleResizeStop}
|
||||||
background: none !important;
|
isDraggable={true}
|
||||||
opacity: 0.5;
|
isResizable={true}
|
||||||
transition-duration: 100ms;
|
>
|
||||||
border-radius: 0.5rem;
|
{reports.map((report) => (
|
||||||
border: 1px dashed var(--primary);
|
<div key={report.id}>
|
||||||
}
|
<ReportItem
|
||||||
.react-grid-item.resizing {
|
report={report}
|
||||||
transition: none !important;
|
organizationId={organizationId}
|
||||||
}
|
projectId={projectId}
|
||||||
`}</style>
|
range={range}
|
||||||
<ResponsiveGridLayout
|
startDate={startDate}
|
||||||
className="layout"
|
endDate={endDate}
|
||||||
layouts={layouts}
|
interval={interval}
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
onDelete={(reportId) => {
|
||||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
reportDeletion.mutate({ reportId });
|
||||||
rowHeight={100}
|
}}
|
||||||
onLayoutChange={handleLayoutChange}
|
onDuplicate={(reportId) => {
|
||||||
onDragStop={handleDragStop}
|
reportDuplicate.mutate({ reportId });
|
||||||
onResizeStop={handleResizeStop}
|
}}
|
||||||
draggableHandle=".drag-handle"
|
/>
|
||||||
compactType="vertical"
|
</div>
|
||||||
preventCollision={false}
|
))}
|
||||||
isDraggable={true}
|
</GrafanaGrid>
|
||||||
isResizable={true}
|
|
||||||
margin={[16, 16]}
|
|
||||||
transformScale={1}
|
|
||||||
useCSSTransforms={true}
|
|
||||||
>
|
|
||||||
{reports.map((report) => (
|
|
||||||
<div key={report.id}>
|
|
||||||
<ReportItem
|
|
||||||
report={report}
|
|
||||||
organizationId={organizationId}
|
|
||||||
projectId={projectId}
|
|
||||||
range={range}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
interval={interval}
|
|
||||||
onDelete={(reportId) => {
|
|
||||||
reportDeletion.mutate({ reportId });
|
|
||||||
}}
|
|
||||||
onDuplicate={(reportId) => {
|
|
||||||
reportDuplicate.mutate({ reportId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ResponsiveGridLayout>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,61 +114,45 @@ 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">
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<OverviewRange />
|
||||||
<OverviewRange />
|
<OverviewInterval />
|
||||||
<OverviewInterval />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{reports.length === 0 ? (
|
</div>
|
||||||
|
<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>{`
|
{reports.map((report) => (
|
||||||
.react-grid-item {
|
<div key={report.id}>
|
||||||
transition: none !important;
|
<ReportItemReadOnly
|
||||||
}
|
report={report}
|
||||||
.react-grid-item.react-grid-placeholder {
|
shareId={shareId}
|
||||||
display: none !important;
|
range={range}
|
||||||
}
|
startDate={startDate}
|
||||||
`}</style>
|
endDate={endDate}
|
||||||
<ResponsiveGridLayout
|
interval={interval}
|
||||||
className="layout"
|
/>
|
||||||
layouts={layouts}
|
</div>
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
))}
|
||||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
</GrafanaGrid>
|
||||||
rowHeight={100}
|
|
||||||
draggableHandle=".drag-handle"
|
|
||||||
compactType="vertical"
|
|
||||||
preventCollision={false}
|
|
||||||
isDraggable={false}
|
|
||||||
isResizable={false}
|
|
||||||
margin={[16, 16]}
|
|
||||||
transformScale={1}
|
|
||||||
useCSSTransforms={true}
|
|
||||||
>
|
|
||||||
{reports.map((report) => (
|
|
||||||
<div key={report.id}>
|
|
||||||
<ReportItem
|
|
||||||
report={report}
|
|
||||||
shareId={shareId}
|
|
||||||
range={range}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
interval={interval}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ResponsiveGridLayout>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,33 +98,26 @@ 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">
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<OverviewRange />
|
||||||
<OverviewRange />
|
<OverviewInterval />
|
||||||
<OverviewInterval />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
</div>
|
||||||
<div className="card">
|
<div className="mx-auto max-w-7xl p-4">
|
||||||
<div className="p-4 border-b">
|
<div className="card">
|
||||||
<div className="font-medium text-xl">{report.name}</div>
|
<div className="p-4 border-b">
|
||||||
</div>
|
<div className="font-medium text-xl">{share.report.name}</div>
|
||||||
<div className="p-4">
|
</div>
|
||||||
<ReportChart
|
<div className="p-4">
|
||||||
report={report}
|
<ReportChart report={share.report} shareId={shareId} />
|
||||||
shareId={shareId}
|
|
||||||
shareType="report"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +97,119 @@ export async function validateReportAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return share;
|
return share;
|
||||||
} else {
|
}
|
||||||
const share = await db.shareReport.findUnique({
|
|
||||||
where: { id: shareId },
|
|
||||||
include: {
|
|
||||||
report: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!share || !share.public) {
|
const share = await db.shareReport.findUnique({
|
||||||
|
where: { id: shareId },
|
||||||
|
include: {
|
||||||
|
report: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!share || !share.public) {
|
||||||
|
throw new Error('Share not found or not public');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.reportId !== reportId) {
|
||||||
|
throw new Error('Report ID mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
throw new Error('Share not found or not public');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (share.reportId !== reportId) {
|
const projectId = dashboardShare.projectId;
|
||||||
throw new Error('Report ID mismatch');
|
|
||||||
|
// If no password is set, share is public and accessible
|
||||||
|
if (!dashboardShare.password) {
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return share;
|
// 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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,51 +333,183 @@ 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(
|
||||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
z.object({
|
||||||
|
shareId: z.string().optional(),
|
||||||
|
reportId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
let chartInput = input;
|
||||||
|
|
||||||
const [current, previous] = await Promise.all([
|
if (input.shareId) {
|
||||||
funnelService.getFunnel({ ...input, ...currentPeriod, timezone }),
|
// Require reportId when shareId provided
|
||||||
input.previous
|
if (!input.reportId) {
|
||||||
? funnelService.getFunnel({ ...input, ...previousPeriod, timezone })
|
throw new Error('reportId required with shareId');
|
||||||
: Promise.resolve(null),
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
// Validate share access
|
||||||
current,
|
const shareValidation = await validateShareAccess(
|
||||||
previous,
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
// Fetch report and merge date overrides
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
const report = await getReportById(input.reportId);
|
||||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
if (!report) {
|
||||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
throw TRPCAccessError('Report not found');
|
||||||
|
}
|
||||||
|
|
||||||
const [current, previous] = await Promise.all([
|
chartInput = {
|
||||||
conversionService.getConversion({ ...input, ...currentPeriod, timezone }),
|
...report,
|
||||||
input.previous
|
// Only allow date overrides
|
||||||
? conversionService.getConversion({
|
range: input.range ?? report.range,
|
||||||
...input,
|
startDate: input.startDate ?? report.startDate,
|
||||||
...previousPeriod,
|
endDate: input.endDate ?? report.endDate,
|
||||||
timezone,
|
interval: input.interval ?? report.interval,
|
||||||
})
|
};
|
||||||
: Promise.resolve(null),
|
} 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const { timezone } = await getSettingsForProject(chartInput.projectId);
|
||||||
current: current.map((serie, sIndex) => ({
|
const currentPeriod = getChartStartEndDate(chartInput, timezone);
|
||||||
...serie,
|
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||||
data: serie.data.map((d, dIndex) => ({
|
|
||||||
...d,
|
const [current, previous] = await Promise.all([
|
||||||
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
funnelService.getFunnel({ ...chartInput, ...currentPeriod, timezone }),
|
||||||
|
chartInput.previous
|
||||||
|
? funnelService.getFunnel({
|
||||||
|
...chartInput,
|
||||||
|
...previousPeriod,
|
||||||
|
timezone,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
conversion: publicProcedure
|
||||||
|
.input(
|
||||||
|
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 interval = chartInput.interval;
|
||||||
|
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
conversionService.getConversion({
|
||||||
|
...chartInput,
|
||||||
|
...currentPeriod,
|
||||||
|
interval,
|
||||||
|
timezone,
|
||||||
|
}),
|
||||||
|
chartInput.previous
|
||||||
|
? conversionService.getConversion({
|
||||||
|
...chartInput,
|
||||||
|
...previousPeriod,
|
||||||
|
interval,
|
||||||
|
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,
|
||||||
previous,
|
};
|
||||||
};
|
}),
|
||||||
}),
|
|
||||||
|
|
||||||
sankey: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
sankey: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user