Board component, security, test updates

This commit is contained in:
Francis Cao committed 2026-06-29 18:17:57 -07:00
1 parent f525898b79
commit ab5b1bf354
23 files changed
+1128 -180

No files matched your search

+19 -6
View File
@@ -8,6 +8,7 @@ import {
Loading,
Row,
Select,
Text,
TextField,
} from '@umami/react-zen';
import { useBoardQuery, useMessages, useNavigation, useUpdateQuery } from '@/components/hooks';
@@ -97,6 +98,14 @@ export function BoardEditForm({
: type === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
const boardTypeLabel =
type === BOARD_TYPES.mixed
? t(labels.open)
: type === BOARD_TYPES.pixel
? t(labels.pixel)
: type === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
const handleTypeChange = (value: string) => {
setValue('type', value as BoardType, { shouldDirty: true });
@@ -126,12 +135,16 @@ export function BoardEditForm({
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
<Select value={type} onChange={handleTypeChange}>
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
</Select>
{boardId ? (
<Text>{boardTypeLabel}</Text>
) : (
<Select value={type} onChange={handleTypeChange}>
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
</Select>
)}
</Box>
</FormField>
{requiresBoardEntity(type) && (
@@ -9,7 +9,7 @@ import {
TextField,
} from '@umami/react-zen';
import { useEffect, useMemo, useState } from 'react';
import { useMessages } from '@/components/hooks';
import { useApi, useMessages } from '@/components/hooks';
import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
@@ -29,6 +29,16 @@ import {
} from '../boardComponentRegistry';
import { BoardComponentRenderer } from './BoardComponentRenderer';
const COMPONENT_GROUP_ORDER: string[] = [
'Traffic',
'Events',
'Behavior',
'Realtime',
'Growth',
'Revenue',
'Content',
];
export function BoardComponentSelect({
teamId,
boardType,
@@ -47,6 +57,7 @@ export function BoardComponentSelect({
onClose: () => void;
}) {
const { t, labels, messages } = useMessages();
const { get, useQuery } = useApi();
const initialEntity = getComponentEntity(initialConfig);
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
const [configValues, setConfigValues] = useState<Record<string, any>>({});
@@ -57,16 +68,17 @@ export function BoardComponentSelect({
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const allDefinitions = useMemo(
() => getComponentDefinitions().toSorted((a, b) => a.name.localeCompare(b.name)),
[],
);
const allDefinitions = useMemo(() => getComponentDefinitions(), []);
const activeEntityType = isOpenBoardType(boardType) ? selectedEntityType : boardEntityType;
const isSelectedDefSupported = selectedDef
? isBoardComponentSupported(selectedDef.type, activeEntityType)
: false;
const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => {
const getDefaultConfigValues = (
def: ComponentDefinition,
config?: BoardComponentConfig,
entityType?: BoardEntityType,
) => {
const defaults: Record<string, any> = {};
for (const field of def.configFields ?? []) {
@@ -77,6 +89,19 @@ export function BoardComponentSelect({
Object.assign(defaults, def.defaultProps);
}
for (const field of def.configFields ?? []) {
if (field.type !== 'select') {
continue;
}
const options = (entityType && field.optionsByEntityType?.[entityType]) ?? field.options;
const currentValue = defaults[field.name];
if (options?.length && !options.some(option => String(option.value) === String(currentValue))) {
defaults[field.name] = options[0].value;
}
}
if (config?.props) {
Object.assign(defaults, config.props);
}
@@ -96,10 +121,9 @@ export function BoardComponentSelect({
}
setSelectedDef(definition);
setConfigValues(getDefaultConfigValues(definition, initialConfig));
setSelectedEntityType(
initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website,
);
const entityType = initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website;
setConfigValues(getDefaultConfigValues(definition, initialConfig, entityType));
setSelectedEntityType(entityType);
setSelectedEntityId(initialEntity.entityId || boardEntityId);
setTitle(initialConfig.title ?? definition.name);
setDescription(initialConfig.description || '');
@@ -114,7 +138,7 @@ export function BoardComponentSelect({
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
setConfigValues(getDefaultConfigValues(def));
setConfigValues(getDefaultConfigValues(def, undefined, activeEntityType));
setTitle(def.name);
setDescription('');
};
@@ -136,10 +160,55 @@ export function BoardComponentSelect({
? selectedEntityId
: boardEntityId
: undefined;
const reportFields = selectedDef?.configFields?.filter(
field => field.type === 'report' && field.reportType,
);
const { data: reportOptionsData, isLoading: isLoadingReportOptions } = useQuery({
queryKey: [
'board-component-report-options',
{
websiteId: resolvedEntityId,
reportTypes: reportFields?.map(field => field.reportType).join(','),
},
],
queryFn: async () => {
const types = [...new Set(reportFields?.map(field => field.reportType).filter(Boolean))];
const entries = await Promise.all(
types.map(async type => {
const response = await get('/reports', {
websiteId: resolvedEntityId,
type,
pageSize: 1000,
});
return [type, response.data] as const;
}),
);
return Object.fromEntries(entries);
},
enabled: !!resolvedEntityId && !!reportFields?.length,
});
const reportOptionsByType = reportOptionsData ?? {};
const hasRequiredConfig = selectedDef
? (selectedDef.configFields ?? []).every(field => {
if (!field.required) {
return true;
}
const value = configValues[field.name];
return value !== undefined && value !== null && value !== '';
})
: false;
const handleEntityTypeChange = (value: string) => {
setSelectedEntityType(value as BoardEntityType);
setSelectedEntityId(undefined);
setSelectedDef(null);
setConfigValues({});
setTitle('');
setDescription('');
};
const handleAdd = () => {
@@ -186,7 +255,11 @@ export function BoardComponentSelect({
[selectedDef, configValues],
);
const canSave = !!selectedDef && isSelectedDefSupported && (!needsWebsite || !!resolvedEntityId);
const canSave =
!!selectedDef &&
isSelectedDefSupported &&
hasRequiredConfig &&
(!needsWebsite || !!resolvedEntityId);
const availableDefinitions = useMemo(
() =>
allDefinitions.filter(
@@ -195,6 +268,33 @@ export function BoardComponentSelect({
),
[activeEntityType, allDefinitions, selectedDef?.type],
);
const groupedDefinitions = useMemo(() => {
const order = new Map(COMPONENT_GROUP_ORDER.map((group, index) => [group, index]));
const groups = new Map<string, ComponentDefinition[]>();
for (const def of availableDefinitions) {
const group = def.group ?? 'Other';
const items = groups.get(group) ?? [];
items.push(def);
groups.set(group, items);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
const left = order.get(a) ?? Number.MAX_SAFE_INTEGER;
const right = order.get(b) ?? Number.MAX_SAFE_INTEGER;
if (left !== right) {
return left - right;
}
return a.localeCompare(b);
})
.map(([group, definitions]) => ({
group,
definitions,
}));
}, [availableDefinitions]);
return (
<Column gap="4">
@@ -310,6 +410,35 @@ export function BoardComponentSelect({
style={{ minHeight: 200 }}
/>
)}
{field.type === 'report' && (
<>
<Select
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
maxHeight={300}
popoverProps={{ style: { width: 220 } }}
>
{(reportOptionsByType[field.reportType ?? ''] ?? []).map(option => (
<ListItem key={option.id} id={option.id}>
{option.name}
</ListItem>
))}
</Select>
{isLoadingReportOptions && (
<Text size="xs" color="muted">
Loading options...
</Text>
)}
{!isLoadingReportOptions &&
resolvedEntityId &&
(reportOptionsByType[field.reportType ?? '']?.length ?? 0) === 0 && (
<Text size="xs" color="muted">
No saved items found.
</Text>
)}
</>
)}
</Column>
))}
</Column>
@@ -321,40 +450,47 @@ export function BoardComponentSelect({
<Column border="left" paddingLeft="4" height="100%" style={{ minHeight: 0 }}>
{hasSelectedEntity ? (
<Column gap="1" height="100%" style={{ overflowY: 'auto', minHeight: 0 }}>
{availableDefinitions.map(def => {
const Icon = def.icon;
{groupedDefinitions.map(({ group, definitions }) => (
<Column key={group} gap="1" paddingBottom="2">
<Text size="sm" color="muted" weight="bold">
{group}
</Text>
{definitions.map(def => {
const Icon = def.icon;
return (
<Focusable key={def.type}>
<Row
gap="3"
alignItems="flex-start"
paddingX="3"
paddingY="2"
borderRadius
backgroundColor={
selectedDef?.type === def.type ? 'surface-sunken' : undefined
}
hover={{ backgroundColor: 'surface-sunken' }}
style={{ cursor: 'pointer' }}
onClick={() => handleSelectComponent(def)}
>
<Icon size={16} />
<Column gap="1">
<Text
size="sm"
weight={selectedDef?.type === def.type ? 'bold' : undefined}
return (
<Focusable key={def.type}>
<Row
gap="3"
alignItems="flex-start"
paddingX="3"
paddingY="2"
borderRadius
backgroundColor={
selectedDef?.type === def.type ? 'surface-sunken' : undefined
}
hover={{ backgroundColor: 'surface-sunken' }}
style={{ cursor: 'pointer' }}
onClick={() => handleSelectComponent(def)}
>
{def.name}
</Text>
<Text size="xs" color="muted">
{def.description}
</Text>
</Column>
</Row>
</Focusable>
);
})}
<Icon size={16} />
<Column gap="1">
<Text
size="sm"
weight={selectedDef?.type === def.type ? 'bold' : undefined}
>
{def.name}
</Text>
<Text size="xs" color="muted">
{def.description}
</Text>
</Column>
</Row>
</Focusable>
);
})}
</Column>
))}
</Column>
) : (
<Column alignItems="center" justifyContent="center" height="100%">
+212 -10
View File
@@ -1,30 +1,47 @@
import type { ComponentType } from 'react';
import { TextBlock } from '@/app/(main)/boards/TextBlock';
import { BoardFunnel } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel';
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
import { BoardGoal } from '@/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal';
import { BoardRevenueChart } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart';
import { BoardRevenueMetricsBar } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar';
import { BoardRevenueMetricsTable } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable';
import { BoardUTM } from '@/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM';
import { EventsMetricsBar } from '@/app/(main)/websites/[websiteId]/events/EventsMetricsBar';
import { BoardRealtimeActiveUsers } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeActiveUsers';
import { BoardRealtimeChart } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart';
import { BoardRealtimeMetricsBar } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
import {
Activity,
Calendar,
ChartColumnBig,
ChartPie,
FileText,
GitBranch,
Globe,
PanelTop,
Sheet,
Target,
Users,
} from '@/components/icons';
import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap';
import { CURRENCIES, DEFAULT_CURRENCY } from '@/lib/constants';
export interface ConfigField {
name: string;
label: string;
type: 'select' | 'number' | 'text' | 'textarea';
type: 'select' | 'number' | 'text' | 'textarea' | 'report';
options?: { label: string; value: string }[];
optionsByEntityType?: Record<string, { label: string; value: string }[]>;
defaultValue?: any;
reportType?: string;
required?: boolean;
}
export interface ComponentDefinition {
@@ -32,6 +49,7 @@ export interface ComponentDefinition {
name: string;
description: string;
category: string;
group?: string;
icon: ComponentType<any>;
component: ComponentType<any>;
componentByEntityType?: Record<string, ComponentType<any>>;
@@ -76,18 +94,13 @@ const METRIC_TYPES = [
const PIXEL_LINK_METRIC_TYPES = METRIC_TYPES.filter(({ value }) =>
[
'referrer',
'country',
'region',
'city',
'channel',
'browser',
'os',
'device',
'query',
'utmSource',
'utmMedium',
'utmCampaign',
'utmContent',
'utmTerm',
'country',
'region',
'city',
].includes(value),
);
@@ -97,6 +110,26 @@ const LIMIT_OPTIONS = [
{ label: '20', value: '20' },
];
const UTM_PARAM_OPTIONS = [
{ label: 'Source', value: 'utm_source' },
{ label: 'Medium', value: 'utm_medium' },
{ label: 'Campaign', value: 'utm_campaign' },
{ label: 'Content', value: 'utm_content' },
{ label: 'Term', value: 'utm_term' },
];
const REVENUE_METRIC_TYPE_OPTIONS = [
{ label: 'Referrers', value: 'referrer' },
{ label: 'Channels', value: 'channel' },
{ label: 'Countries', value: 'country' },
{ label: 'Regions', value: 'region' },
];
const CURRENCY_OPTIONS = CURRENCIES.map(({ id, name }) => ({
label: `${id} - ${name}`,
value: id,
}));
const PixelMetricsBarAdapter = ({ websiteId }: { websiteId?: string }) =>
websiteId ? <PixelMetricsBar pixelId={websiteId} /> : null;
@@ -110,6 +143,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Metrics bar',
description: 'Key metrics: views, visitors, bounces, time on site',
category: 'overview',
group: 'Traffic',
icon: PanelTop,
component: WebsiteMetricsBar,
componentByEntityType: {
@@ -117,14 +151,177 @@ const componentDefinitions: ComponentDefinition[] = [
link: LinkMetricsBarAdapter,
},
},
{
type: 'EventsMetricsBar',
name: 'Event metrics bar',
description: 'Key metrics: visitors, visits, events, unique events',
category: 'overview',
group: 'Events',
icon: PanelTop,
component: EventsMetricsBar,
},
{
type: 'Goal',
name: 'Goal',
description: 'Conversion progress for a saved goal',
category: 'overview',
group: 'Behavior',
icon: Target,
component: BoardGoal,
configFields: [
{
name: 'reportId',
label: 'Saved goal',
type: 'report',
reportType: 'goal',
required: true,
},
],
},
{
type: 'Funnel',
name: 'Funnel',
description: 'Step conversion for a saved funnel',
category: 'overview',
group: 'Behavior',
icon: GitBranch,
component: BoardFunnel,
configFields: [
{
name: 'reportId',
label: 'Saved funnel',
type: 'report',
reportType: 'funnel',
required: true,
},
],
},
{
type: 'WebsiteChart',
name: 'Visitors chart',
description: 'Page views and visitors over time',
category: 'overview',
group: 'Traffic',
icon: ChartColumnBig,
component: WebsiteChart,
},
{
type: 'UTM',
name: 'UTM',
description: 'List and pie chart for one UTM parameter',
category: 'visualization',
group: 'Growth',
icon: ChartPie,
component: BoardUTM,
defaultProps: { param: 'utm_source', limit: 10 },
configFields: [
{
name: 'param',
label: 'UTM param',
type: 'select',
options: UTM_PARAM_OPTIONS,
defaultValue: 'utm_source',
},
{
name: 'limit',
label: 'Rows',
type: 'select',
options: LIMIT_OPTIONS,
defaultValue: '10',
},
],
},
{
type: 'RealtimeHeader',
name: 'Realtime metrics bar',
description: 'Live views, visitors, events, and countries',
category: 'overview',
group: 'Realtime',
icon: PanelTop,
component: BoardRealtimeMetricsBar,
},
{
type: 'RealtimeChart',
name: 'Realtime chart',
description: 'Live visitors and page views by minute',
category: 'visualization',
group: 'Realtime',
icon: Activity,
component: BoardRealtimeChart,
},
{
type: 'RealtimeActiveUsers',
name: 'Active users',
description: 'Current visitors online',
category: 'overview',
group: 'Realtime',
icon: Users,
component: BoardRealtimeActiveUsers,
},
{
type: 'RevenueMetricsBar',
name: 'Revenue metrics bar',
description: 'Revenue summary: total, AOV, ARPU, orders, and customers',
category: 'overview',
group: 'Revenue',
icon: PanelTop,
component: BoardRevenueMetricsBar,
defaultProps: { currency: DEFAULT_CURRENCY },
configFields: [
{
name: 'currency',
label: 'Currency',
type: 'select',
options: CURRENCY_OPTIONS,
defaultValue: DEFAULT_CURRENCY,
},
],
},
{
type: 'RevenueChart',
name: 'Revenue chart',
description: 'Revenue over time',
category: 'visualization',
group: 'Revenue',
icon: ChartColumnBig,
component: BoardRevenueChart,
defaultProps: { currency: DEFAULT_CURRENCY },
configFields: [
{
name: 'currency',
label: 'Currency',
type: 'select',
options: CURRENCY_OPTIONS,
defaultValue: DEFAULT_CURRENCY,
},
],
},
{
type: 'RevenueMetricsTable',
name: 'Revenue metrics table',
description: 'Revenue breakdown by source or location',
category: 'tables',
group: 'Revenue',
icon: Sheet,
component: BoardRevenueMetricsTable,
defaultProps: { type: 'referrer', currency: DEFAULT_CURRENCY },
configFields: [
{
name: 'type',
label: 'Metric type',
type: 'select',
options: REVENUE_METRIC_TYPE_OPTIONS,
defaultValue: 'referrer',
},
{
name: 'currency',
label: 'Currency',
type: 'select',
options: CURRENCY_OPTIONS,
defaultValue: DEFAULT_CURRENCY,
},
],
},
// Tables
{
@@ -132,6 +329,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Metrics table',
description: 'Table of metrics by dimension',
category: 'tables',
group: 'Traffic',
icon: Sheet,
component: MetricsTable,
defaultProps: { type: 'path', limit: 10 },
@@ -163,6 +361,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'World map',
description: 'Geographic distribution of visitors',
category: 'visualization',
group: 'Traffic',
icon: Globe,
component: WorldMap,
},
@@ -171,6 +370,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Weekly traffic',
description: 'Traffic heatmap by day and hour',
category: 'visualization',
group: 'Traffic',
icon: Calendar,
component: WeeklyTraffic,
},
@@ -179,6 +379,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Events chart',
description: 'Custom events over time',
category: 'visualization',
group: 'Events',
icon: ChartPie,
component: EventsChart,
},
@@ -189,6 +390,7 @@ const componentDefinitions: ComponentDefinition[] = [
name: 'Text',
description: 'Free-form text content',
category: 'content',
group: 'Content',
icon: FileText,
component: TextBlock,
requiresWebsite: false,
@@ -0,0 +1,41 @@
import { Text } from '@umami/react-zen';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
import { Funnel } from './Funnel';
export function BoardFunnel({ websiteId, reportId }: { websiteId: string; reportId?: string }) {
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
if (!reportId) {
return (
<EmptyPlaceholder title="Select a saved funnel" description="Choose an existing funnel." />
);
}
if (data && (data.type !== 'funnel' || data.websiteId !== websiteId)) {
return (
<EmptyPlaceholder
title="Funnel unavailable"
description="This saved funnel is no longer available for the selected website."
/>
);
}
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
{data ? (
<Funnel
id={data.id}
name={data.name}
type={data.type}
parameters={data.parameters}
websiteId={websiteId}
allowEdit={false}
/>
) : (
<Text color="muted">Funnel unavailable</Text>
)}
</LoadingPanel>
);
}
@@ -15,9 +15,17 @@ interface FunnelProps {
type: string;
parameters: Record<string, any>;
websiteId: string;
allowEdit?: boolean;
}
export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) {
export function Funnel({
id,
name,
type,
parameters,
websiteId,
allowEdit = true,
}: FunnelProps) {
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
@@ -39,7 +47,7 @@ export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) {
</Text>
</Row>
</Column>
{!isSharePage && (
{allowEdit && !isSharePage && (
<Column>
<ReportEditButton
id={id}
@@ -0,0 +1,45 @@
import { Text } from '@umami/react-zen';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange } from '@/components/hooks';
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
import { Goal } from './Goal';
export function BoardGoal({ websiteId, reportId }: { websiteId: string; reportId?: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange();
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
if (!reportId) {
return <EmptyPlaceholder title="Select a saved goal" description="Choose an existing goal." />;
}
if (data && (data.type !== 'goal' || data.websiteId !== websiteId)) {
return (
<EmptyPlaceholder
title="Goal unavailable"
description="This saved goal is no longer available for the selected website."
/>
);
}
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
{data ? (
<Goal
id={data.id}
name={data.name}
type={data.type}
parameters={data.parameters}
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
allowEdit={false}
/>
) : (
<Text color="muted">Goal unavailable</Text>
)}
</LoadingPanel>
);
}
@@ -19,11 +19,21 @@ export interface GoalProps {
websiteId: string;
startDate: Date;
endDate: Date;
allowEdit?: boolean;
}
export type GoalData = { num: number; total: number };
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
export function Goal({
id,
name,
type,
parameters,
websiteId,
startDate,
endDate,
allowEdit = true,
}: GoalProps) {
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
@@ -47,7 +57,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</Text>
</Row>
</Column>
{!isSharePage && (
{allowEdit && !isSharePage && (
<Column>
<ReportEditButton
id={id}
@@ -0,0 +1,34 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange, useTimezone } from '@/components/hooks';
import { useRevenueChartQuery } from '@/components/hooks/queries/useRevenueChartQuery';
import { RevenueChart } from './RevenueChart';
import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
export function BoardRevenueChart({
websiteId,
currency: selectedCurrency,
}: {
websiteId: string;
currency?: string;
}) {
const currency = useBoardRevenueCurrency(selectedCurrency);
const { timezone } = useTimezone();
const {
dateRange: { startDate, endDate, unit },
} = useDateRange({ timezone });
const { data, isLoading, isFetching, error } = useRevenueChartQuery(websiteId, currency);
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
{data && (
<RevenueChart
data={data.chart}
unit={unit}
minDate={startDate}
maxDate={endDate}
currency={currency}
/>
)}
</LoadingPanel>
);
}
@@ -0,0 +1,27 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange } from '@/components/hooks';
import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery';
import { RevenueMetricsBar } from './RevenueMetricsBar';
import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
export function BoardRevenueMetricsBar({
websiteId,
currency: selectedCurrency,
}: {
websiteId: string;
currency?: string;
}) {
const currency = useBoardRevenueCurrency(selectedCurrency);
const { compare } = useDateRange();
const { data, isLoading, isFetching, error } = useRevenueStatsQuery({
websiteId,
currency,
compare,
});
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
{data && <RevenueMetricsBar data={data} currency={currency} />}
</LoadingPanel>
);
}
@@ -0,0 +1,53 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange, useMessages } from '@/components/hooks';
import type { RevenueMetricType } from '@/components/hooks/queries/useRevenueMetricsQuery';
import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery';
import { RevenueMetricsTable } from './RevenueMetricsTable';
import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
function getRevenueTableTitle(type: RevenueMetricType, labels: Record<string, string>, t: (key: string) => string) {
switch (type) {
case 'referrer':
return t(labels.referrer);
case 'channel':
return t(labels.channel);
case 'country':
return t(labels.country);
case 'region':
return t(labels.region);
}
}
export function BoardRevenueMetricsTable({
websiteId,
type = 'referrer',
currency: selectedCurrency,
}: {
websiteId: string;
type?: RevenueMetricType;
currency?: string;
}) {
const currency = useBoardRevenueCurrency(selectedCurrency);
const { compare } = useDateRange();
const { t, labels } = useMessages();
const { data, isLoading, isFetching, error } = useRevenueStatsQuery({
websiteId,
currency,
compare,
});
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
{data && (
<RevenueMetricsTable
websiteId={websiteId}
currency={currency}
type={type}
title={getRevenueTableTitle(type, labels, t)}
totalRevenue={Number(data.sum) || 0}
enabled={true}
/>
)}
</LoadingPanel>
);
}
@@ -0,0 +1,6 @@
import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { getItem } from '@/lib/storage';
export function useBoardRevenueCurrency(currency?: string) {
return currency || getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY;
}
@@ -0,0 +1,112 @@
import { Column, Heading, Text } from '@umami/react-zen';
import { useEffect, useRef, useState } from 'react';
import { PieChart } from '@/components/charts/PieChart';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
import { ListTable } from '@/components/metrics/ListTable';
import { CHART_COLORS } from '@/lib/constants';
const STACKED_LAYOUT_WIDTH = 900;
export function BoardUTM({
websiteId,
param = 'utm_source',
limit = 10,
}: {
websiteId: string;
param?: string;
limit?: number | string;
}) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [isStacked, setIsStacked] = useState(false);
const {
dateRange: { startDate, endDate },
} = useDateRange();
const { t, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>('utm', {
websiteId,
startDate,
endDate,
});
const itemLimit = Number(limit) || 10;
const items = (data?.[param] ?? []).slice(0, itemLimit);
const total = items.reduce((sum, { views }) => {
return +sum + +views;
}, 0);
const chartData = {
labels: items.map(({ utm }) => utm),
datasets: [
{
data: items.map(({ views }) => views),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
useEffect(() => {
const updateLayout = () => {
const width = wrapperRef.current?.clientWidth ?? 0;
setIsStacked(current => {
const next = width > 0 && width < STACKED_LAYOUT_WIDTH;
return current === next ? current : next;
});
};
updateLayout();
const resizeObserver =
typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateLayout) : null;
if (wrapperRef.current && resizeObserver) {
resizeObserver.observe(wrapperRef.current);
}
window.addEventListener('resize', updateLayout);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateLayout);
};
}, []);
return (
<LoadingPanel data={items} isLoading={isLoading} error={error} minHeight="300px">
<div ref={wrapperRef}>
<div
style={{
display: 'grid',
gridTemplateColumns: isStacked
? 'minmax(0, 1fr)'
: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: 24,
alignItems: 'start',
}}
>
<Column style={{ minWidth: 0 }}>
<Heading>
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
</Heading>
<ListTable
metric={t(labels.views)}
data={items.map(({ utm, views }) => ({
label: utm,
count: views,
percent: total ? (views / total) * 100 : 0,
}))}
/>
</Column>
<Column style={{ minWidth: 0, overflow: 'hidden' }}>
<PieChart
type="doughnut"
chartData={chartData}
height={isStacked ? '240px' : '300px'}
/>
</Column>
</div>
</div>
</LoadingPanel>
);
}
@@ -1,40 +1,70 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages } from '@/components/hooks';
import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
import { useDateRange, useMessages } from '@/components/hooks';
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { formatLongNumber } from '@/lib/format';
export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
const { t, labels } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
const { isAllTime } = useDateRange();
const { t, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useEventStatsQuery({
websiteId,
});
const { events, visitors, visits, uniqueEvents, comparison } = data || {};
const metrics = data
? [
{
value: visitors,
label: t(labels.visitors),
change: visitors - comparison.visitors,
formatValue: formatLongNumber,
},
{
value: visits,
label: t(labels.visits),
change: visits - comparison.visits,
formatValue: formatLongNumber,
},
{
value: events,
label: t(labels.events),
change: events - comparison.events,
formatValue: formatLongNumber,
},
{
value: uniqueEvents,
label: t(labels.uniqueEvents),
change: uniqueEvents - comparison.uniqueEvents,
formatValue: formatLongNumber,
},
]
: null;
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
{data && (
<MetricsBar>
<MetricCard
value={data?.visitors?.value}
label={t(labels.visitors)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.visits?.value}
label={t(labels.visits)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.pageviews?.value}
label={t(labels.views)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.events?.value}
label={t(labels.events)}
formatValue={formatLongNumber}
/>
</MetricsBar>
)}
<LoadingPanel
data={metrics}
isLoading={isLoading}
isFetching={isFetching}
error={getErrorMessage(error)}
minHeight="136px"
>
<MetricsBar>
{metrics?.map(({ label, value, change, formatValue }) => {
return (
<MetricCard
key={label}
value={value}
label={label}
change={change}
formatValue={formatValue}
showChange={!isAllTime}
/>
);
})}
</MetricsBar>
</LoadingPanel>
);
}
@@ -3,90 +3,30 @@ import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { type Key, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages } from '@/components/hooks';
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
import { useMessages } from '@/components/hooks';
import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
import { EventProperties } from './EventProperties';
import { EventsDataTable } from './EventsDataTable';
import { EventsMetricsBar } from './EventsMetricsBar';
const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
const { isAllTime } = useDateRange();
const { t, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useEventStatsQuery({
websiteId,
});
const { t, labels } = useMessages();
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
setTab(value);
};
const { events, visitors, visits, uniqueEvents, comparison } = data || {};
const metrics = data
? [
{
value: visitors,
label: t(labels.visitors),
change: visitors - comparison.visitors,
formatValue: formatLongNumber,
},
{
value: visits,
label: t(labels.visits),
change: visits - comparison.visits,
formatValue: formatLongNumber,
},
{
value: events,
label: t(labels.events),
change: events - comparison.events,
formatValue: formatLongNumber,
},
{
value: uniqueEvents,
label: t(labels.uniqueEvents),
change: uniqueEvents - comparison.uniqueEvents,
formatValue: formatLongNumber,
},
]
: null;
return (
<Column gap="3">
<WebsiteControls websiteId={websiteId} />
<LoadingPanel
data={metrics}
isLoading={isLoading}
isFetching={isFetching}
error={getErrorMessage(error)}
minHeight="136px"
>
<MetricsBar>
{metrics?.map(({ label, value, change, formatValue }) => {
return (
<MetricCard
key={label}
value={value}
label={label}
change={change}
formatValue={formatValue}
showChange={!isAllTime}
/>
);
})}
</MetricsBar>
</LoadingPanel>
<EventsMetricsBar websiteId={websiteId} />
<Panel minWidth="0" width="100%" style={{ overflow: 'hidden' }}>
<Tabs
selectedKey={tab}
@@ -0,0 +1,25 @@
import { Column, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useActiveUsersQuery, useMessages } from '@/components/hooks';
import { REALTIME_INTERVAL } from '@/lib/constants';
export function BoardRealtimeActiveUsers({ websiteId }: { websiteId: string }) {
const { t, labels } = useMessages();
const { data, isLoading, error } = useActiveUsersQuery(websiteId, {
refetchInterval: REALTIME_INTERVAL,
});
const count = data?.visitors ?? 0;
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="136px">
<Column alignItems="center" justifyContent="center" height="100%" gap="2">
<Text size="6xl" weight="bold">
{count}
</Text>
<Text size="sm" color="muted">
{t(labels.online)}
</Text>
</Column>
</LoadingPanel>
);
}
@@ -0,0 +1,13 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useRealtimeQuery } from '@/components/hooks';
import { RealtimeChart } from '@/components/metrics/RealtimeChart';
export function BoardRealtimeChart({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useRealtimeQuery(websiteId);
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="320px">
<RealtimeChart data={data} unit="minute" />
</LoadingPanel>
);
}
@@ -0,0 +1,13 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useRealtimeQuery } from '@/components/hooks';
import { RealtimeHeader } from './RealtimeHeader';
export function BoardRealtimeMetricsBar({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useRealtimeQuery(websiteId);
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="136px">
<RealtimeHeader data={data} />
</LoadingPanel>
);
}
+4
View File
@@ -15,6 +15,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
const report = await getReport(reportId);
if (!report) {
return notFound();
}
if (!(await canViewReport(auth, report))) {
return unauthorized();
}
+1 -21
View File
@@ -4,33 +4,13 @@ import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema';
import {
getReportSection,
canUpdateWebsite,
canViewAuthenticatedWebsite,
canViewWebsiteSection,
} from '@/permissions';
import type { ShareSection } from '@/permissions';
import { createReport, getReports } from '@/queries/prisma';
function getReportSection(type?: z.infer<typeof reportTypeParam>): ShareSection | null {
switch (type) {
case 'attribution':
case 'breakdown':
case 'performance':
case 'retention':
case 'revenue':
case 'utm':
return type;
case 'funnel':
return 'funnels';
case 'goal':
return 'goals';
case 'journey':
return 'journeys';
default:
return null;
}
}
export async function GET(request: Request) {
const schema = z.object({
websiteId: z.uuid(),
+11
View File
@@ -1,7 +1,18 @@
import type { BoardEntityType } from './boards';
export const BOARD_COMPONENT_COMPATIBILITY_MATRIX = {
EventsMetricsBar: ['website'],
EventsChart: ['website'],
Funnel: ['website'],
Goal: ['website'],
RealtimeActiveUsers: ['website'],
RealtimeHeader: ['website'],
RealtimeChart: ['website'],
RevenueMetricsBar: ['website'],
RevenueChart: ['website'],
RevenueMetricsTable: ['website'],
UTM: ['website'],
WeeklyTraffic: ['website'],
} as const satisfies Partial<Record<string, readonly BoardEntityType[]>>;
export function getSupportedBoardComponentEntityTypes(componentType: string) {
+88
View File
@@ -1,4 +1,5 @@
import { expect, test } from 'vitest';
import { getComponentDefinition } from '@/app/(main)/boards/boardComponentRegistry';
import {
BOARD_COMPONENT_COMPATIBILITY_MATRIX,
getSupportedBoardComponentEntityTypes,
@@ -14,14 +15,101 @@ test('isBoardComponentSupported rejects events chart on pixel and link boards',
expect(isBoardComponentSupported('EventsChart', BOARD_ENTITY_TYPES.link)).toBe(false);
});
test('isBoardComponentSupported rejects event metrics bar and weekly traffic on pixel and link boards', () => {
expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.pixel)).toBe(false);
expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.link)).toBe(false);
expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.pixel)).toBe(false);
expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.link)).toBe(false);
});
test('board component compatibility matrix defines website-only components explicitly', () => {
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsMetricsBar).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsChart).toEqual([BOARD_ENTITY_TYPES.website]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Funnel).toEqual([BOARD_ENTITY_TYPES.website]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Goal).toEqual([BOARD_ENTITY_TYPES.website]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeActiveUsers).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeHeader).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeChart).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsBar).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueChart).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsTable).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.UTM).toEqual([BOARD_ENTITY_TYPES.website]);
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.WeeklyTraffic).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('EventsMetricsBar')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('EventsChart')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('Funnel')).toEqual([BOARD_ENTITY_TYPES.website]);
expect(getSupportedBoardComponentEntityTypes('Goal')).toEqual([BOARD_ENTITY_TYPES.website]);
expect(getSupportedBoardComponentEntityTypes('RealtimeActiveUsers')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('RealtimeHeader')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('RealtimeChart')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('RevenueMetricsBar')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('RevenueChart')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('RevenueMetricsTable')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
expect(getSupportedBoardComponentEntityTypes('UTM')).toEqual([BOARD_ENTITY_TYPES.website]);
expect(getSupportedBoardComponentEntityTypes('WeeklyTraffic')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
});
test('isBoardComponentSupported leaves other components available for all board entities', () => {
expect(isBoardComponentSupported('WebsiteChart', BOARD_ENTITY_TYPES.pixel)).toBe(true);
expect(isBoardComponentSupported('TextBlock', BOARD_ENTITY_TYPES.link)).toBe(true);
});
test('metrics table limits pixel and link boards to overview-page metric types', () => {
const definition = getComponentDefinition('MetricsTable');
const options = definition?.configFields?.find(field => field.name === 'type')?.optionsByEntityType;
expect(options?.pixel?.map(option => option.value)).toEqual([
'referrer',
'channel',
'country',
'region',
'city',
'browser',
'os',
'device',
]);
expect(options?.link?.map(option => option.value)).toEqual([
'referrer',
'channel',
'country',
'region',
'city',
'browser',
'os',
'device',
]);
});
+125
View File
@@ -0,0 +1,125 @@
import { expect, test, vi } from 'vitest';
import { ENTITY_TYPE } from '@/lib/constants';
import { canViewReport, getReportSection } from './report';
import { canViewWebsite } from './website';
vi.mock('./website', () => ({
canViewWebsite: vi.fn(),
}));
test('getReportSection maps saved report types to share sections', () => {
expect(getReportSection('goal')).toBe('goals');
expect(getReportSection('funnel')).toBe('funnels');
expect(getReportSection('journey')).toBe('journeys');
expect(getReportSection('revenue')).toBe('revenue');
expect(getReportSection('heatmap')).toBe(null);
});
test('canViewReport allows board shares to fetch included saved goal reports', async () => {
await expect(
canViewReport(
{
shareToken: {
shareType: ENTITY_TYPE.board,
websiteIds: ['website-1'],
parameters: {},
},
},
{
id: 'report-1',
userId: 'owner-1',
websiteId: 'website-1',
type: 'goal',
} as any,
),
).resolves.toBe(true);
});
test('canViewReport respects share section flags for saved funnel and goal reports', async () => {
await expect(
canViewReport(
{
shareToken: {
shareType: ENTITY_TYPE.website,
websiteId: 'website-1',
parameters: {
overview: true,
funnels: false,
goals: false,
},
},
},
{
id: 'report-1',
userId: 'owner-1',
websiteId: 'website-1',
type: 'funnel',
} as any,
),
).resolves.toBe(false);
await expect(
canViewReport(
{
shareToken: {
shareType: ENTITY_TYPE.website,
websiteId: 'website-1',
parameters: {
overview: true,
funnels: false,
goals: true,
},
},
},
{
id: 'report-2',
userId: 'owner-1',
websiteId: 'website-1',
type: 'goal',
} as any,
),
).resolves.toBe(true);
});
test('canViewReport denies share-token access to report types without a share section', async () => {
await expect(
canViewReport(
{
shareToken: {
shareType: ENTITY_TYPE.website,
websiteId: 'website-1',
parameters: {},
},
},
{
id: 'report-1',
userId: 'owner-1',
websiteId: 'website-1',
type: 'heatmap',
} as any,
),
).resolves.toBe(false);
});
test('canViewReport falls back to website access for authenticated users', async () => {
vi.mocked(canViewWebsite).mockResolvedValue(true);
await expect(
canViewReport(
{
user: {
id: 'user-1',
username: 'user',
role: 'user',
isAdmin: false,
},
},
{
id: 'report-1',
userId: 'owner-1',
websiteId: 'website-1',
type: 'heatmap',
} as any,
),
).resolves.toBe(true);
});
+34 -2
View File
@@ -1,8 +1,34 @@
import type { Report } from '@/generated/prisma/client';
import type { Auth } from '@/lib/types';
import type { ShareSection } from './share';
import { canViewWebsiteSection } from './share';
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from './website';
export async function canViewReport(auth: Auth, report: Report) {
export function getReportSection(type?: string): ShareSection | null {
switch (type) {
case 'attribution':
case 'breakdown':
case 'performance':
case 'retention':
case 'revenue':
case 'utm':
return type;
case 'funnel':
return 'funnels';
case 'goal':
return 'goals';
case 'journey':
return 'journeys';
default:
return null;
}
}
export async function canViewReport(auth: Auth, report: Report | null) {
if (!report) {
return false;
}
if (auth.user?.isAdmin) {
return true;
}
@@ -11,7 +37,13 @@ export async function canViewReport(auth: Auth, report: Report) {
return true;
}
return !!(await canViewWebsite(auth, report.websiteId));
const section = getReportSection(report.type);
if (section) {
return !!(await canViewWebsiteSection(auth, report.websiteId, section));
}
return !!auth.user && !!(await canViewWebsite(auth, report.websiteId));
}
export async function canUpdateReport(auth: Auth, report: Report) {