Merge pull request #1120 from buster-so/staging

Release
This commit is contained in:
dal 2025-09-24 13:33:23 -06:00 committed by GitHub
commit 5a32524426
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1889 additions and 323 deletions

View File

@ -1,10 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"momentic": {
"cache": false,
"persistent": true
}
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"momentic": {
"cache": false,
"persistent": true
}
}
}
}

View File

@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { logger, schemaTask, tasks } from '@trigger.dev/sdk';
import { currentSpan, initLogger, wrapTraced } from 'braintrust';
import { analystQueue } from '../../queues/analyst-queue';
@ -12,6 +13,8 @@ import {
getOrganizationDataSource,
getOrganizationDocs,
getUserPersonalization,
updateMessage,
updateMessageEntries,
} from '@buster/database/queries';
// Access control imports
@ -134,11 +137,11 @@ class ResourceTracker {
},
cpuUsage: finalCpuUsage
? {
userTimeMs: Math.round(finalCpuUsage.user / 1000),
systemTimeMs: Math.round(finalCpuUsage.system / 1000),
totalTimeMs: Math.round(totalCpuTime / 1000),
estimatedUsagePercent: Math.round(cpuPercentage * 100) / 100,
}
userTimeMs: Math.round(finalCpuUsage.user / 1000),
systemTimeMs: Math.round(finalCpuUsage.system / 1000),
totalTimeMs: Math.round(totalCpuTime / 1000),
estimatedUsagePercent: Math.round(cpuPercentage * 100) / 100,
}
: { error: 'CPU usage not available' },
stageBreakdown: this.snapshots.map((snapshot, index) => {
const prevSnapshot = index > 0 ? this.snapshots[index - 1] : null;
@ -249,6 +252,51 @@ export const analystAgentTask: ReturnType<
schema: AnalystAgentTaskInputSchema,
queue: analystQueue,
maxDuration: 1200, // 20 minutes for complex analysis
retry: {
maxAttempts: 0
},
onFailure: async ({ error, payload }) => {
// Log the failure for debugging
logger.error('Analyst agent task failed - executing onFailure handler', {
messageId: payload.message_id,
error: error instanceof Error ? error.message : 'Unknown error',
errorType: error instanceof Error ? error.name : typeof error,
});
try {
// Add error message to user and mark as complete
const errorResponseId = randomUUID();
// Add the error response message
await updateMessageEntries({
messageId: payload.message_id,
responseMessages: [
{
id: errorResponseId,
type: 'text',
message: "I'm sorry, I ran into a technical error. Please try your request again.",
},
],
});
// Mark the message as complete with final reasoning
await updateMessage(payload.message_id, {
isCompleted: true,
finalReasoningMessage: 'Finished reasoning.',
});
logger.log('Error response added and message marked complete', {
messageId: payload.message_id,
errorResponseId,
});
} catch (handlerError) {
// Log but don't throw - onFailure should never throw
logger.error('Failed to update message in onFailure handler', {
messageId: payload.message_id,
error: handlerError instanceof Error ? handlerError.message : 'Unknown error',
});
}
},
run: async (payload): Promise<AnalystAgentTaskOutput> => {
const taskStartTime = Date.now();
const resourceTracker = new ResourceTracker();
@ -376,12 +424,12 @@ export const analystAgentTask: ReturnType<
conversationHistory.length > 0
? conversationHistory
: [
{
role: 'user',
// v5 supports string content directly for user messages
content: messageContext.requestMessage,
},
];
{
role: 'user',
// v5 supports string content directly for user messages
content: messageContext.requestMessage,
},
];
const workflowInput: AnalystWorkflowInput = {
messages: modelMessages,
@ -435,7 +483,28 @@ export const analystAgentTask: ReturnType<
}
);
await tracedWorkflow();
// Wrap workflow execution to capture and log errors before re-throwing
try {
await tracedWorkflow();
} catch (workflowError) {
// Log workflow-specific error details
logger.error('Analyst workflow execution failed', {
messageId: payload.message_id,
error: workflowError instanceof Error ? workflowError.message : 'Unknown error',
errorType: workflowError instanceof Error ? workflowError.name : typeof workflowError,
stack: workflowError instanceof Error ? workflowError.stack : undefined,
workflowInput: {
messageCount: workflowInput.messages.length,
datasetsCount: datasets.length,
hasAnalystInstructions: !!analystInstructions,
organizationDocsCount: organizationDocs.length,
},
});
// Re-throw to let Trigger handle the retry
throw workflowError;
}
const totalWorkflowTime = Date.now() - workflowExecutionStart;
logger.log('Analyst workflow completed successfully', {
@ -509,27 +578,20 @@ export const analystAgentTask: ReturnType<
logPerformanceMetrics('task-error', payload.message_id, taskStartTime, resourceTracker);
resourceTracker.generateReport(payload.message_id);
logger.error('Task execution failed', {
logger.error('Task execution failed - will retry', {
messageId: payload.message_id,
error: errorMessage,
errorType: error instanceof Error ? error.name : typeof error,
stack: error instanceof Error ? error.stack : undefined,
executionTimeMs: totalExecutionTime,
errorCode: getErrorCode(error),
});
// Need to flush the Braintrust logger to ensure all traces are sent
await braintrustLogger.flush();
return {
success: false,
messageId: payload.message_id,
error: {
code: getErrorCode(error),
message: errorMessage,
details: {
operation: 'analyst_agent_task_execution',
messageId: payload.message_id,
},
},
};
// Re-throw the error so Trigger knows the task failed and should retry
throw error;
}
},
});

View File

@ -25,12 +25,6 @@ export const createAxiosInstance = (baseURL = BASE_URL_V2) => {
async (error: AxiosError) => {
const errorCode = error.response?.status;
//402 is the payment required error code
if (errorCode === 402) {
window.location.href = AuthRoute.to;
return Promise.reject(rustErrorHandler(error));
}
// Handle 401 Unauthorized - token might be expired
if (errorCode === 401 && !isServer) {
console.info(

View File

@ -1,8 +1,9 @@
import type { ShareAssetType, ShareConfig } from '@buster/server-shared/share';
import { useRouter } from '@tanstack/react-router';
import React, { useState } from 'react';
import { type ParsedLocation, useRouter } from '@tanstack/react-router';
import React, { useMemo, useState } from 'react';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { createFullURL } from '@/lib/routes';
import { getIsEffectiveOwner } from '@/lib/share';
import { ShareMenuContentBody } from './ShareMenuContentBody';
import { ShareMenuContentEmbedFooter } from './ShareMenuContentEmbed';
@ -23,51 +24,96 @@ export const ShareMenuContent: React.FC<{
const canEditPermissions = getIsEffectiveOwner(permission);
const { buildLocation } = useRouter();
const onCopyLink = useMemoizedFn(() => {
let url = '';
const embedlinkUrl: string = useMemo(() => {
let url: ParsedLocation | string = '';
if (!assetId) {
return;
return '';
}
if (assetType === 'metric_file') {
url = buildLocation({
to: '/app/metrics/$metricId/chart',
to: '/embed/metric/$metricId',
params: {
metricId: assetId,
},
}).href;
});
} else if (assetType === 'dashboard_file') {
url = buildLocation({
to: '/embed/dashboard/$dashboardId',
params: {
dashboardId: assetId,
},
});
} else if (assetType === 'collection') {
url = buildLocation({
to: '/auth/login',
});
} else if (assetType === 'report_file') {
url = buildLocation({
to: '/embed/report/$reportId',
params: {
reportId: assetId,
},
});
} else if (assetType === 'chat') {
url = buildLocation({
to: '/auth/login',
});
} else {
const _exhaustiveCheck: never = assetType;
}
const urlWithDomain: string = createFullURL(url);
return urlWithDomain;
}, [assetId, assetType, buildLocation]);
const linkUrl: string = useMemo(() => {
let url: ParsedLocation | string = '';
if (!assetId) {
return '';
}
if (assetType === 'metric_file') {
url = buildLocation({
to: '/app/metrics/$metricId',
params: {
metricId: assetId,
},
});
} else if (assetType === 'dashboard_file') {
url = buildLocation({
to: '/app/dashboards/$dashboardId',
params: {
dashboardId: assetId,
},
}).href;
});
} else if (assetType === 'collection') {
url = buildLocation({
to: '/app/collections/$collectionId',
params: {
collectionId: assetId,
},
}).href;
});
} else if (assetType === 'report_file') {
url = buildLocation({
to: '/app/reports/$reportId',
params: {
reportId: assetId,
},
}).href;
});
} else if (assetType === 'chat') {
url = buildLocation({
to: '/app/chats/$chatId',
params: {
chatId: assetId,
},
}).href;
} else {
const _exhaustiveCheck: never = assetType;
});
}
const urlWithDomain = window.location.origin + url;
navigator.clipboard.writeText(urlWithDomain);
const urlWithDomain: string = createFullURL(url);
return urlWithDomain;
}, [assetId, buildLocation]);
const onCopyLink = useMemoizedFn((isEmbed: boolean) => {
navigator.clipboard.writeText(isEmbed ? embedlinkUrl : linkUrl);
openSuccessMessage('Link copied to clipboard');
});
@ -78,7 +124,7 @@ export const ShareMenuContent: React.FC<{
assetType={assetType}
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
onCopyLink={onCopyLink}
onCopyLink={() => onCopyLink(false)}
canEditPermissions={canEditPermissions}
/>
)}
@ -88,6 +134,7 @@ export const ShareMenuContent: React.FC<{
assetType={assetType}
assetId={assetId}
selectedOptions={selectedOptions}
embedLinkURL={embedlinkUrl}
onCopyLink={onCopyLink}
canEditPermissions={canEditPermissions}
className="px-3 py-2.5"

View File

@ -22,7 +22,8 @@ import { WorkspaceAvatar } from './WorkspaceAvatar';
export const ShareMenuContentBody: React.FC<{
selectedOptions: ShareMenuTopBarOptions;
onCopyLink: () => void;
onCopyLink: (isEmbed: boolean) => void;
embedLinkURL: string;
shareAssetConfig: ShareConfig;
assetId: string;
assetType: ShareAssetType;
@ -37,6 +38,7 @@ export const ShareMenuContentBody: React.FC<{
canEditPermissions,
assetType,
className = '',
embedLinkURL,
}) => {
const Component = ContentRecord[selectedOptions];
const individual_permissions = shareAssetConfig.individual_permissions;
@ -56,6 +58,7 @@ export const ShareMenuContentBody: React.FC<{
canEditPermissions={canEditPermissions}
className={className}
shareAssetConfig={shareAssetConfig}
embedLinkURL={embedLinkURL}
/>
);
}
@ -198,7 +201,8 @@ const ShareMenuContentShare: React.FC<ShareMenuContentBodyProps> = React.memo(
ShareMenuContentShare.displayName = 'ShareMenuContentShare';
export interface ShareMenuContentBodyProps {
onCopyLink: () => void;
onCopyLink: (isEmbed: boolean) => void;
embedLinkURL: string;
individual_permissions: ShareConfig['individual_permissions'];
publicly_accessible: boolean;
publicExpirationDate: string | null | undefined;

View File

@ -10,73 +10,15 @@ import { Input } from '@/components/ui/inputs';
import { Text } from '@/components/ui/typography';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { cn } from '@/lib/classMerge';
import { createFullURL } from '@/lib/routes';
import { useBuildLocation } from '../../../context/Routes/useRouteBuilder';
import type { ShareMenuContentBodyProps } from './ShareMenuContentBody';
export const ShareMenuContentEmbed: React.FC<ShareMenuContentBodyProps> = React.memo(
({ className, assetType, assetId }) => {
const buildLocation = useBuildLocation();
const { openSuccessMessage } = useBusterNotifications();
const embedURL = useMemo(() => {
if (assetType === 'metric_file') {
return createFullURL(
buildLocation({
to: '/embed/metric/$metricId',
params: {
metricId: assetId,
},
})
);
}
if (assetType === 'dashboard_file') {
return createFullURL(
buildLocation({
to: '/embed/dashboard/$dashboardId',
params: {
dashboardId: assetId,
},
})
);
}
if (assetType === 'report_file') {
return createFullURL(
buildLocation({
to: '/embed/report/$reportId',
params: {
reportId: assetId,
},
})
);
}
if (assetType === 'chat') {
return '';
}
if (assetType === 'collection') {
return '';
}
const _exhaustiveCheck: never = assetType;
return '';
}, [assetType, assetId, buildLocation]);
const onCopyLink = () => {
const url = window.location.origin + embedURL;
navigator.clipboard.writeText(url);
openSuccessMessage('Link copied to clipboard');
};
({ className, embedLinkURL, onCopyLink }) => {
return (
<div className={cn('flex flex-col', className)}>
<div className="flex w-full items-center space-x-1">
<Input size="small" defaultValue={createIframe(embedURL)} readOnly />
<Button prefix={<Link />} className="flex" onClick={onCopyLink} />
<Input size="small" defaultValue={createIframe(embedLinkURL)} readOnly />
<Button prefix={<Link />} className="flex" onClick={() => onCopyLink(true)} />
</div>
</div>
);

View File

@ -15,7 +15,6 @@ import { useBusterNotifications } from '@/context/BusterNotifications';
import { useBuildLocation } from '@/context/Routes/useRouteBuilder';
import { cn } from '@/lib/classMerge';
import { createDayjsDate } from '@/lib/date';
import { createFullURL } from '@/lib/routes';
import type { ShareMenuContentBodyProps } from './ShareMenuContentBody';
export const ShareMenuContentPublish: React.FC<ShareMenuContentBodyProps> = React.memo(
@ -27,8 +26,8 @@ export const ShareMenuContentPublish: React.FC<ShareMenuContentBodyProps> = Reac
onCopyLink,
publicExpirationDate,
className,
embedLinkURL,
}) => {
const buildLocation = useBuildLocation();
const { openInfoMessage } = useBusterNotifications();
const { mutateAsync: onShareMetric, isPending: isPublishingMetric } = useUpdateMetricShare();
const { mutateAsync: onShareDashboard, isPending: isPublishingDashboard } =
@ -51,58 +50,6 @@ export const ShareMenuContentPublish: React.FC<ShareMenuContentBodyProps> = Reac
return publicExpirationDate ? new Date(publicExpirationDate) : null;
}, [publicExpirationDate]);
const linkUrl: string = useMemo(() => {
if (assetType === 'metric_file') {
return createFullURL(
buildLocation({
to: '/embed/metric/$metricId',
params: {
metricId: assetId,
},
})
);
} else if (assetType === 'dashboard_file') {
return createFullURL(
buildLocation({
to: '/embed/dashboard/$dashboardId',
params: {
dashboardId: assetId,
},
})
);
} else if (assetType === 'collection') {
return createFullURL(
buildLocation({
to: '/app/collections/$collectionId',
params: {
collectionId: assetId,
},
})
);
} else if (assetType === 'report_file') {
return createFullURL(
buildLocation({
to: '/embed/report/$reportId',
params: {
reportId: assetId,
},
})
);
} else if (assetType === 'chat') {
return createFullURL(
buildLocation({
to: '/app/chats/$chatId',
params: {
chatId: assetId,
},
})
);
}
const _exhaustiveCheck: never = assetType;
return '';
}, [assetId, assetType]);
const onTogglePublish = async (v?: boolean) => {
const linkExp = linkExpiry ? linkExpiry.toISOString() : null;
const payload: Parameters<typeof onShareMetric>[0] = {
@ -196,8 +143,13 @@ export const ShareMenuContentPublish: React.FC<ShareMenuContentBodyProps> = Reac
<IsPublishedInfo isPublished={publicly_accessible} />
<div className="flex w-full space-x-0.5">
<Input size="small" readOnly value={linkUrl} />
<Button variant="default" className="flex" prefix={<Link />} onClick={onCopyLink} />
<Input size="small" readOnly value={embedLinkURL} />
<Button
variant="default"
className="flex"
prefix={<Link />}
onClick={() => onCopyLink(true)}
/>
</div>
<LinkExpiration linkExpiry={linkExpiry} onChangeLinkExpiry={onSetExpirationDate} />
@ -235,7 +187,7 @@ export const ShareMenuContentPublish: React.FC<ShareMenuContentBodyProps> = Reac
>
Unpublish
</Button>
<Button block onClick={onCopyLink}>
<Button block onClick={() => onCopyLink(true)}>
Copy link
</Button>
</div>

View File

@ -309,7 +309,7 @@ const AlreadyHaveAccount: React.FC<{
const handleToggleClick = () => {
if (!signUpFlow) {
// User clicked "Sign up" - redirect to get-started page
window.location.href = 'https://www.buster.so/get-started';
// window.location.href = 'https://www.buster.so/get-started';
} else {
// User clicked "Sign in" - use existing toggle logic
setErrorMessages([]);
@ -318,9 +318,9 @@ const AlreadyHaveAccount: React.FC<{
}
// TODO: Original toggle logic preserved for future re-enablement
// setErrorMessages([]);
// setPassword2('');
// setSignUpFlow(!signUpFlow);
setErrorMessages([]);
setPassword2('');
setSignUpFlow(!signUpFlow);
};
return (

View File

@ -20,7 +20,7 @@ export const useSignOut = () => {
console.error('Error clearing browser storage', error);
}
} finally {
navigate({ to: '/auth/login' });
navigate({ to: '/auth/login', reloadDocument: true, replace: true });
}
}, [navigate, openErrorMessage]);

View File

@ -4,7 +4,6 @@ import React, { useMemo, useState } from 'react';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { useMount } from '@/hooks/useMount';
import { usePreviousRef } from '@/hooks/usePrevious';
import { useColors } from '../chartHooks';
import type { BusterChartTypeComponentProps } from '../interfaces/chartComponentInterfaces';
import {
Chart,
@ -29,7 +28,7 @@ export const BusterChartJSComponent = React.memo(
selectedChartType,
selectedAxis,
className = '',
colors: colorsProp,
colors,
pieDonutWidth,
pieInnerLabelTitle,
pieInnerLabelAggregate,
@ -74,14 +73,6 @@ export const BusterChartJSComponent = React.memo(
},
ref
) => {
const colors = useColors({
colors: colorsProp,
yAxisKeys,
y2AxisKeys,
datasetOptions: datasetOptions.datasets,
selectedChartType,
});
const data: ChartProps<ChartJSChartType>['data'] = useSeriesOptions({
selectedChartType,
y2AxisKeys,

View File

@ -0,0 +1,618 @@
import type { ChartEncodes, ChartType, ColumnSettings } from '@buster/server-shared/metrics';
import { act, renderHook } from '@testing-library/react';
import type { Chart } from 'chart.js';
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import type { BusterChartProps } from '../../../BusterChart.types';
import type { BusterChartLegendItem } from '../../../BusterChartLegend';
import type { DatasetOptionsWithTicks } from '../../../chartHooks';
import type { ChartJSOrUndefined } from '../../core/types';
import { useBusterChartJSLegend } from './useBusterChartJSLegend';
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn((cb) => {
setTimeout(cb, 0);
return 1;
});
// Mock the dependencies
vi.mock('../../../BusterChartLegend', () => ({
useBusterChartLegend: vi.fn(),
addLegendHeadlines: vi.fn(),
}));
vi.mock('./getLegendItems', () => ({
getLegendItems: vi.fn(),
}));
vi.mock('@/hooks/useDebounce', () => ({
useDebounceFn: vi.fn(),
}));
vi.mock('@/hooks/useMemoizedFn', () => ({
useMemoizedFn: vi.fn((fn) => fn),
}));
vi.mock('@/hooks/useUpdateDebounceEffect', () => ({
useUpdateDebounceEffect: vi.fn(),
}));
vi.mock('@/lib/timeout', () => ({
timeout: vi.fn(),
}));
describe('useBusterChartJSLegend', () => {
let mockChartRef: React.RefObject<ChartJSOrUndefined>;
let mockChart: Partial<Chart>;
let mockUseBusterChartLegend: Mock;
let mockGetLegendItems: Mock;
let mockUseDebounceFn: Mock;
let mockTimeout: Mock;
const defaultProps = {
colors: ['#FF0000', '#00FF00', '#0000FF'],
showLegend: true,
selectedChartType: 'bar' as ChartType,
chartMounted: true,
selectedAxis: undefined as ChartEncodes | undefined,
showLegendHeadline: undefined,
columnLabelFormats: {},
loading: false,
lineGroupType: null as 'stack' | 'percentage-stack' | null,
barGroupType: null as 'stack' | 'percentage-stack' | null,
datasetOptions: {} as DatasetOptionsWithTicks,
columnSettings: {} as NonNullable<BusterChartProps['columnSettings']>,
columnMetadata: [],
pieMinimumSlicePercentage: 5,
numberOfDataPoints: 100,
animateLegend: true,
};
const mockLegendData = {
inactiveDatasets: {},
setInactiveDatasets: vi.fn(),
legendItems: [] as BusterChartLegendItem[],
setLegendItems: vi.fn(),
renderLegend: true,
isStackPercentage: false,
showLegend: true,
allYAxisColumnNames: [],
};
beforeEach(async () => {
vi.clearAllMocks();
mockChart = {
data: {
datasets: [
{
label: 'Dataset 1',
data: [10, 20, 30],
hidden: false,
tooltipData: [],
xAxisKeys: [],
yAxisKey: 'y1',
},
{
label: 'Dataset 2',
data: [15, 25, 35],
hidden: false,
tooltipData: [],
xAxisKeys: [],
yAxisKey: 'y2',
},
],
labels: ['A', 'B', 'C'],
},
options: {
animation: {},
},
update: vi.fn(),
getDatasetMeta: vi.fn(
() =>
({
type: 'bar',
controller: {} as any,
order: 0,
label: 'test',
data: [{}, {}, {}],
dataset: {} as any,
stack: null,
index: 0,
visible: true,
hidden: false,
parsed: [],
normalized: false,
sorting: { start: 0, count: 3 },
_sorted: true,
_stacked: false,
_stack: null,
_meta: {},
indexAxis: 'x' as any,
iAxisID: 'x',
vAxisID: 'y',
_parsed: [],
_clip: false,
}) as any
),
setActiveElements: vi.fn(),
getActiveElements: vi.fn(() => []),
toggleDataVisibility: vi.fn(),
setDatasetVisibility: vi.fn(),
isDatasetVisible: vi.fn(() => true),
};
mockChartRef = {
current: mockChart as Chart,
};
mockUseBusterChartLegend = vi.fn(() => mockLegendData);
mockGetLegendItems = vi.fn(() => []);
mockUseDebounceFn = vi.fn(() => ({ run: vi.fn() }));
mockTimeout = vi.fn(() => Promise.resolve());
const { useBusterChartLegend: mockUseBusterChartLegendImport } = await import(
'../../../BusterChartLegend'
);
const { getLegendItems: mockGetLegendItemsImport } = await import('./getLegendItems');
const { useDebounceFn: mockUseDebounceFnImport } = await import('@/hooks/useDebounce');
const { timeout: mockTimeoutImport } = await import('@/lib/timeout');
(mockUseBusterChartLegendImport as Mock).mockImplementation(mockUseBusterChartLegend);
(mockGetLegendItemsImport as Mock).mockImplementation(mockGetLegendItems);
(mockUseDebounceFnImport as Mock).mockImplementation(mockUseDebounceFn);
(mockTimeoutImport as Mock).mockImplementation(mockTimeout);
});
it('should return expected values from hook', () => {
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
})
);
expect(result.current).toEqual({
renderLegend: true,
legendItems: [],
onHoverItem: expect.any(Function),
onLegendItemClick: expect.any(Function),
onLegendItemFocus: expect.any(Function),
showLegend: true,
inactiveDatasets: {},
isUpdatingChart: false,
animateLegend: true,
});
});
it('should not return onLegendItemFocus for pie charts', () => {
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
selectedChartType: 'pie',
})
);
expect(result.current.onLegendItemFocus).toBeUndefined();
});
it('should disable animation for large datasets', () => {
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
numberOfDataPoints: 300, // Above LEGEND_ANIMATION_THRESHOLD (250)
})
);
expect(result.current.animateLegend).toBe(false);
});
it('should enable animation for small datasets when animateLegend is true', () => {
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
numberOfDataPoints: 200, // Below LEGEND_ANIMATION_THRESHOLD (250)
animateLegend: true,
})
);
expect(result.current.animateLegend).toBe(true);
});
it('should handle onHoverItem correctly', () => {
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
})
);
const mockItem: BusterChartLegendItem = {
id: 'Dataset 1',
color: '#FF0000',
inactive: false,
type: 'bar',
formattedName: 'Dataset 1',
data: [10, 20, 30],
yAxisKey: 'y',
};
act(() => {
result.current.onHoverItem(mockItem, true);
});
expect(mockChart.setActiveElements).toHaveBeenCalled();
expect(mockChart.update).toHaveBeenCalled();
});
it('should handle onLegendItemClick for non-pie charts', async () => {
const mockSetInactiveDatasets = vi.fn();
const mockDebouncedUpdate = vi.fn();
mockUseBusterChartLegend.mockReturnValue({
...mockLegendData,
setInactiveDatasets: mockSetInactiveDatasets,
});
mockUseDebounceFn.mockReturnValue({ run: mockDebouncedUpdate });
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
selectedChartType: 'bar',
})
);
const mockItem: BusterChartLegendItem = {
id: 'Dataset 1',
color: '#FF0000',
inactive: false,
type: 'bar',
formattedName: 'Dataset 1',
data: [10, 20, 30],
yAxisKey: 'y',
};
await act(async () => {
await result.current.onLegendItemClick(mockItem);
// Wait for requestAnimationFrame
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(mockSetInactiveDatasets).toHaveBeenCalledWith(expect.any(Function));
expect(mockChart.setDatasetVisibility).toHaveBeenCalled();
expect(mockDebouncedUpdate).toHaveBeenCalled();
});
it('should handle onLegendItemClick for pie charts', async () => {
const mockSetInactiveDatasets = vi.fn();
const mockDebouncedUpdate = vi.fn();
// Set up pie chart data structure
mockChart.data = {
datasets: [
{
label: 'Dataset 1',
data: [10, 20, 30],
hidden: false,
tooltipData: [],
xAxisKeys: [],
yAxisKey: 'y1',
},
],
labels: ['Category A', 'Category B', 'Category C'],
};
mockUseBusterChartLegend.mockReturnValue({
...mockLegendData,
setInactiveDatasets: mockSetInactiveDatasets,
});
mockUseDebounceFn.mockReturnValue({ run: mockDebouncedUpdate });
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
selectedChartType: 'pie',
})
);
const mockItem: BusterChartLegendItem = {
id: 'Category A',
color: '#FF0000',
inactive: false,
type: 'pie',
formattedName: 'Category A',
data: [10, 20, 30],
yAxisKey: 'y',
};
await act(async () => {
await result.current.onLegendItemClick(mockItem);
// Wait for requestAnimationFrame
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(mockSetInactiveDatasets).toHaveBeenCalledWith(expect.any(Function));
expect(mockChart.toggleDataVisibility).toHaveBeenCalled();
expect(mockDebouncedUpdate).toHaveBeenCalled();
});
it('should handle onLegendItemFocus correctly', async () => {
const mockSetInactiveDatasets = vi.fn();
const mockDebouncedUpdate = vi.fn();
mockChart.isDatasetVisible = vi.fn(() => true);
mockUseBusterChartLegend.mockReturnValue({
...mockLegendData,
setInactiveDatasets: mockSetInactiveDatasets,
});
mockUseDebounceFn.mockReturnValue({ run: mockDebouncedUpdate });
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
selectedChartType: 'bar',
})
);
const mockItem: BusterChartLegendItem = {
id: 'Dataset 1',
color: '#FF0000',
inactive: false,
type: 'bar',
formattedName: 'Dataset 1',
data: [10, 20, 30],
yAxisKey: 'y',
};
if (result.current.onLegendItemFocus) {
await act(async () => {
await result.current.onLegendItemFocus!(mockItem);
// Wait for requestAnimationFrame
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(mockSetInactiveDatasets).toHaveBeenCalled();
expect(mockDebouncedUpdate).toHaveBeenCalled();
}
});
it('should return early when chart is not mounted', () => {
mockGetLegendItems.mockClear();
renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
chartMounted: false,
})
);
// Wait for effects to run
expect(mockGetLegendItems).not.toHaveBeenCalled();
});
it('should return early when showLegend is false', () => {
mockUseBusterChartLegend.mockReturnValue({
...mockLegendData,
showLegend: false,
});
mockGetLegendItems.mockClear();
renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
showLegend: false,
})
);
// Wait for effects to run
expect(mockGetLegendItems).not.toHaveBeenCalled();
});
it('should handle null chart reference gracefully in event handlers', () => {
const nullChartRef = { current: null };
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: nullChartRef,
...defaultProps,
})
);
const mockItem: BusterChartLegendItem = {
id: 'Dataset 1',
color: '#FF0000',
inactive: false,
type: 'bar',
formattedName: 'Dataset 1',
data: [10, 20, 30],
yAxisKey: 'y',
};
// These should not throw errors
expect(() => {
result.current.onHoverItem(mockItem, true);
}).not.toThrow();
expect(() => {
result.current.onLegendItemClick(mockItem);
}).not.toThrow();
if (result.current.onLegendItemFocus) {
expect(() => {
result.current.onLegendItemFocus!(mockItem);
}).not.toThrow();
}
});
it('should handle chart with disabled animations', () => {
mockChart.options = { animation: false };
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
})
);
const mockItem: BusterChartLegendItem = {
id: 'Dataset 1',
color: '#FF0000',
inactive: false,
type: 'bar',
formattedName: 'Dataset 1',
data: [10, 20, 30],
yAxisKey: 'y',
};
act(() => {
result.current.onHoverItem(mockItem, true);
});
// Should not call setActiveElements when animations are disabled
expect(mockChart.setActiveElements).not.toHaveBeenCalled();
});
it('should set isUpdatingChart state for large datasets during interactions', async () => {
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
numberOfDataPoints: 300, // Large dataset (above 250 threshold)
})
);
const mockItem: BusterChartLegendItem = {
id: 'Dataset 1',
color: '#FF0000',
inactive: false,
type: 'bar',
formattedName: 'Dataset 1',
data: [10, 20, 30],
yAxisKey: 'y',
};
expect(result.current.isUpdatingChart).toBe(false);
await act(async () => {
await result.current.onLegendItemClick(mockItem);
});
// For large datasets, isUpdatingChart should be set to true during the operation
expect(mockTimeout).toHaveBeenCalledWith(95); // DELAY_DURATION_FOR_LARGE_DATASET
});
it('should call getLegendItems with correct parameters', async () => {
const mockLegendItems = [
{
id: 'test',
color: '#FF0000',
inactive: false,
type: 'bar' as ChartType,
formattedName: 'Test',
data: [1, 2, 3],
yAxisKey: 'y',
},
];
mockGetLegendItems.mockReturnValue(mockLegendItems);
renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
})
);
// Wait for effects to run and requestAnimationFrame
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(mockGetLegendItems).toHaveBeenCalledWith({
chartRef: mockChartRef,
colors: defaultProps.colors,
inactiveDatasets: {},
selectedChartType: 'bar',
columnLabelFormats: defaultProps.columnLabelFormats,
columnSettings: defaultProps.columnSettings,
});
});
it('should handle focus behavior when all other datasets are already hidden', async () => {
const mockSetInactiveDatasets = vi.fn();
const mockDebouncedUpdate = vi.fn();
// Mock scenario where we have multiple visible datasets and one is being focused
mockChart.isDatasetVisible = vi.fn((index) => index === 1); // Only second dataset is visible
mockChart.data = {
datasets: [
{
label: 'Active Dataset',
data: [10, 20, 30],
hidden: false,
tooltipData: [],
xAxisKeys: [],
yAxisKey: 'y1',
},
{
label: 'Another Dataset',
data: [15, 25, 35],
hidden: false,
tooltipData: [],
xAxisKeys: [],
yAxisKey: 'y2',
},
],
labels: ['A', 'B', 'C'],
};
mockUseBusterChartLegend.mockReturnValue({
...mockLegendData,
setInactiveDatasets: mockSetInactiveDatasets,
});
mockUseDebounceFn.mockReturnValue({ run: mockDebouncedUpdate });
const { result } = renderHook(() =>
useBusterChartJSLegend({
chartRef: mockChartRef,
...defaultProps,
selectedChartType: 'bar',
})
);
const mockItem: BusterChartLegendItem = {
id: 'Active Dataset',
color: '#FF0000',
inactive: false,
type: 'bar',
formattedName: 'Active Dataset',
data: [10, 20, 30],
yAxisKey: 'y',
};
if (result.current.onLegendItemFocus) {
await act(async () => {
await result.current.onLegendItemFocus!(mockItem);
// Wait for requestAnimationFrame
await new Promise((resolve) => setTimeout(resolve, 10));
});
// When focusing on a dataset, it should show only that dataset
expect(mockChart.setDatasetVisibility).toHaveBeenCalledWith(0, true);
expect(mockChart.setDatasetVisibility).toHaveBeenCalledWith(1, false);
}
});
});

View File

@ -0,0 +1,342 @@
import { type ColumnLabelFormat, DEFAULT_COLUMN_LABEL_FORMAT } from '@buster/server-shared/metrics';
import type { TooltipItem } from 'chart.js';
import { describe, expect, it } from 'vitest';
import { scatterTooltipHelper } from './scatterTooltipHelper';
describe('scatterTooltipHelper', () => {
// Mock data setup
const mockColumnLabelFormats: Record<string, ColumnLabelFormat> = {
revenue: {
...DEFAULT_COLUMN_LABEL_FORMAT,
columnType: 'number',
style: 'currency',
},
count: {
...DEFAULT_COLUMN_LABEL_FORMAT,
columnType: 'number',
style: 'number',
},
percentage: {
...DEFAULT_COLUMN_LABEL_FORMAT,
columnType: 'number',
style: 'percent',
},
category: {
...DEFAULT_COLUMN_LABEL_FORMAT,
columnType: 'text',
style: 'string',
},
};
const mockDataset = {
label: 'Sales Data',
backgroundColor: '#FF6B6B',
tooltipData: [
[
{ key: 'revenue', value: 25000 },
{ key: 'count', value: 150 },
{ key: 'category', value: 'Electronics' },
],
[
{ key: 'revenue', value: 18000 },
{ key: 'count', value: 92 },
{ key: 'category', value: 'Clothing' },
],
],
};
const mockDataPoints = [
{
datasetIndex: 0,
dataIndex: 0,
dataset: mockDataset,
parsed: { x: 25000, y: 150 },
raw: { x: 25000, y: 150 },
formattedValue: '(25000, 150)',
label: 'Sales Data',
} as TooltipItem<any>,
];
it('should correctly format tooltip items for scatter chart', () => {
const result = scatterTooltipHelper(mockDataPoints, mockColumnLabelFormats);
expect(result).toHaveLength(3);
// Check revenue item
expect(result[0]).toEqual({
color: '#FF6B6B',
seriesType: 'scatter',
usePercentage: false,
formattedLabel: 'Sales Data',
values: [
{
formattedValue: '$25,000.00',
formattedPercentage: undefined,
formattedLabel: 'Revenue',
},
],
});
// Check count item
expect(result[1]).toEqual({
color: '#FF6B6B',
seriesType: 'scatter',
usePercentage: false,
formattedLabel: 'Sales Data',
values: [
{
formattedValue: '150',
formattedPercentage: undefined,
formattedLabel: 'Count',
},
],
});
// Check category item
expect(result[2]).toEqual({
color: '#FF6B6B',
seriesType: 'scatter',
usePercentage: false,
formattedLabel: 'Sales Data',
values: [
{
formattedValue: 'Electronics',
formattedPercentage: undefined,
formattedLabel: 'Category',
},
],
});
});
it('should handle empty tooltip data', () => {
const emptyDataPoints = [
{
datasetIndex: 0,
dataIndex: 0,
dataset: {
label: 'Empty Dataset',
backgroundColor: '#333333',
tooltipData: [],
},
parsed: { x: 100, y: 200 },
raw: { x: 100, y: 200 },
formattedValue: '(100, 200)',
label: 'Empty Dataset',
} as TooltipItem<any>,
];
const result = scatterTooltipHelper(emptyDataPoints, mockColumnLabelFormats);
expect(result).toHaveLength(0);
});
it('should handle undefined tooltip data at specific index', () => {
const dataPointsWithUndefinedTooltipData = [
{
datasetIndex: 0,
dataIndex: 5, // Index that doesn't exist in tooltipData
dataset: {
label: 'Test Dataset',
backgroundColor: '#00FF00',
tooltipData: [[{ key: 'value', value: 100 }], [{ key: 'value', value: 200 }]], // Only has indices 0 and 1
},
parsed: { x: 300, y: 400 },
raw: { x: 300, y: 400 },
formattedValue: '(300, 400)',
label: 'Test Dataset',
} as TooltipItem<any>,
];
const result = scatterTooltipHelper(dataPointsWithUndefinedTooltipData, mockColumnLabelFormats);
expect(result).toHaveLength(0);
});
it('should only process the first data point when multiple are provided', () => {
const multipleDataPoints = [
{
datasetIndex: 0,
dataIndex: 0,
dataset: mockDataset,
parsed: { x: 25000, y: 150 },
raw: { x: 25000, y: 150 },
formattedValue: '(25000, 150)',
label: 'Sales Data',
} as TooltipItem<any>,
{
datasetIndex: 0,
dataIndex: 1,
dataset: mockDataset,
parsed: { x: 18000, y: 92 },
raw: { x: 18000, y: 92 },
formattedValue: '(18000, 92)',
label: 'Sales Data',
} as TooltipItem<any>,
];
const result = scatterTooltipHelper(multipleDataPoints, mockColumnLabelFormats);
// Should only process first data point (index 0)
expect(result).toHaveLength(3);
expect(result[0].values[0].formattedValue).toBe('$25,000.00'); // Revenue from first data point
expect(result[1].values[0].formattedValue).toBe('150'); // Count from first data point
});
it('should handle different data types and formatting', () => {
const mixedDataDataset = {
label: 'Mixed Data',
backgroundColor: '#9B59B6',
tooltipData: [
[
{ key: 'percentage', value: 0.85 },
{ key: 'count', value: 1500 },
{ key: 'category', value: 'Premium' },
],
],
};
const mixedDataPoints = [
{
datasetIndex: 0,
dataIndex: 0,
dataset: mixedDataDataset,
parsed: { x: 0.85, y: 1500 },
raw: { x: 0.85, y: 1500 },
formattedValue: '(0.85, 1500)',
label: 'Mixed Data',
} as TooltipItem<any>,
];
const result = scatterTooltipHelper(mixedDataPoints, mockColumnLabelFormats);
expect(result).toHaveLength(3);
// Check percentage formatting
expect(result[0]).toEqual({
color: '#9B59B6',
seriesType: 'scatter',
usePercentage: false,
formattedLabel: 'Mixed Data',
values: [
{
formattedValue: '0.85%',
formattedPercentage: undefined,
formattedLabel: 'Percentage',
},
],
});
// Check number formatting
expect(result[1].values[0].formattedValue).toBe('1,500');
// Check string formatting
expect(result[2].values[0].formattedValue).toBe('Premium');
});
it('should handle missing column label formats gracefully', () => {
const datasetWithUnknownKeys = {
label: 'Unknown Keys',
backgroundColor: '#E74C3C',
tooltipData: [
[
{ key: 'unknownKey', value: 42 },
{ key: 'anotherUnknownKey', value: 'test' },
],
],
};
const dataPoints = [
{
datasetIndex: 0,
dataIndex: 0,
dataset: datasetWithUnknownKeys,
parsed: { x: 42, y: 100 },
raw: { x: 42, y: 100 },
formattedValue: '(42, 100)',
label: 'Unknown Keys',
} as TooltipItem<any>,
];
const result = scatterTooltipHelper(dataPoints, mockColumnLabelFormats);
expect(result).toHaveLength(2);
// Should still format the values even with unknown keys
expect(result[0].values[0].formattedValue).toBe('42');
expect(result[1].values[0].formattedValue).toBe('test');
});
it('should preserve series type as scatter', () => {
const result = scatterTooltipHelper(mockDataPoints, mockColumnLabelFormats);
expect(result.every((item) => item.seriesType === 'scatter')).toBe(true);
});
it('should set usePercentage to false for all items', () => {
const result = scatterTooltipHelper(mockDataPoints, mockColumnLabelFormats);
expect(result.every((item) => item.usePercentage === false)).toBe(true);
});
it('should set formattedPercentage to undefined for all items', () => {
const result = scatterTooltipHelper(mockDataPoints, mockColumnLabelFormats);
expect(
result.every((item) => item.values.every((value) => value.formattedPercentage === undefined))
).toBe(true);
});
it('should handle mixed null and valid values in tooltip data', () => {
const mixedDataDataset = {
label: 'Mixed Values',
backgroundColor: '#3498DB',
tooltipData: [
[
{ key: 'revenue', value: null },
{ key: 'count', value: 100 },
{ key: 'category', value: undefined },
{ key: 'percentage', value: 0.5 },
],
],
};
const mixedDataPoints = [
{
datasetIndex: 0,
dataIndex: 0,
dataset: mixedDataDataset,
parsed: { x: null, y: 100 },
raw: { x: null, y: 100 },
formattedValue: '(null, 100)',
label: 'Mixed Values',
} as TooltipItem<any>,
];
const result = scatterTooltipHelper(mixedDataPoints, mockColumnLabelFormats);
expect(result).toHaveLength(4);
// Check null revenue value (currency format with null defaults to $0.00)
expect(result[0]).toEqual({
color: '#3498DB',
seriesType: 'scatter',
usePercentage: false,
formattedLabel: 'Mixed Values',
values: [
{
formattedValue: '0',
formattedPercentage: undefined,
formattedLabel: 'Revenue',
},
],
});
// Check valid count value
expect(result[1].values[0].formattedValue).toBe('100');
// Check undefined category value (text format with undefined defaults to 'undefined')
expect(result[2].values[0].formattedValue).toBe('undefined');
// Check valid percentage value
expect(result[3].values[0].formattedValue).toBe('0.5%');
});
});

View File

@ -0,0 +1,286 @@
import {
type ColumnMetaData,
DEFAULT_COLUMN_LABEL_FORMAT,
DEFAULT_COLUMN_SETTINGS,
} from '@buster/server-shared/metrics';
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { BusterChartProps } from '../../../BusterChart.types';
import type { DatasetOptionsWithTicks } from '../../../chartHooks';
import { type UseSeriesOptionsProps, useSeriesOptions } from './useSeriesOptions';
// Mock the series builders
vi.mock('./barSeriesBuilder', () => ({
barSeriesBuilder: vi.fn(() => []),
barSeriesBuilder_labels: vi.fn(() => ['Jan', 'Feb', 'Mar']),
}));
vi.mock('./lineSeriesBuilder', () => ({
lineSeriesBuilder: vi.fn(() => []),
lineSeriesBuilder_labels: vi.fn(() => ['Jan', 'Feb', 'Mar']),
}));
vi.mock('./pieSeriesBuilder', () => ({
pieSeriesBuilder_data: vi.fn(() => []),
pieSeriesBuilder_labels: vi.fn(() => ['Category A', 'Category B', 'Category C']),
}));
vi.mock('./scatterSeriesBuilder', () => ({
scatterSeriesBuilder_data: vi.fn(() => []),
scatterSeriesBuilder_labels: vi.fn(() => ['Point 1', 'Point 2', 'Point 3']),
}));
vi.mock('./comboSeriesBuilder', () => ({
comboSeriesBuilder_data: vi.fn(() => []),
comboSeriesBuilder_labels: vi.fn(() => ['Jan', 'Feb', 'Mar']),
}));
describe('useSeriesOptions', () => {
const createMockProps = (
overrides: Partial<UseSeriesOptionsProps> = {}
): UseSeriesOptionsProps => {
const mockDatasetOptions: DatasetOptionsWithTicks = {
datasets: [
{
id: 'sales-dataset',
dataKey: 'sales',
data: [100, 200, 300],
label: [{ key: 'sales', value: '' }],
tooltipData: [],
axisType: 'y',
},
],
ticks: [['Jan'], ['Feb'], ['Mar']],
ticksKey: [{ key: 'date', value: '' }],
};
const mockColumnMetadata: ColumnMetaData[] = [
{
name: 'sales',
simple_type: 'number',
type: 'integer',
min_value: 0,
max_value: 1000,
unique_values: 100,
},
{
name: 'size_column',
simple_type: 'number',
type: 'integer',
min_value: 10,
max_value: 100,
unique_values: 50,
},
{
name: 'text_column',
simple_type: 'text',
type: 'varchar',
min_value: 0,
max_value: 0,
unique_values: 25,
},
];
return {
selectedChartType: 'bar',
y2AxisKeys: [],
yAxisKeys: ['sales'],
xAxisKeys: ['date'],
sizeKey: [],
columnSettings: {
sales: DEFAULT_COLUMN_SETTINGS,
date: DEFAULT_COLUMN_SETTINGS,
},
columnLabelFormats: {
sales: DEFAULT_COLUMN_LABEL_FORMAT,
date: DEFAULT_COLUMN_LABEL_FORMAT,
},
colors: ['#FF0000', '#00FF00', '#0000FF'],
datasetOptions: mockDatasetOptions,
scatterDotSize: [5, 10] as [number, number],
columnMetadata: mockColumnMetadata,
lineGroupType: null,
barGroupType: null,
trendlines: [],
barShowTotalAtTop: false,
...overrides,
};
};
it('should return chart data with labels and datasets for bar chart', () => {
// Arrange
const props = createMockProps({
selectedChartType: 'bar',
});
// Act
const { result } = renderHook(() => useSeriesOptions(props));
// Assert
expect(result.current).toEqual({
labels: ['Jan', 'Feb', 'Mar'],
datasets: [],
});
expect(result.current.labels).toBeDefined();
expect(result.current.datasets).toBeDefined();
expect(Array.isArray(result.current.labels)).toBe(true);
expect(Array.isArray(result.current.datasets)).toBe(true);
});
it('should call appropriate series builders based on chart type', () => {
// Test different chart types
const chartTypes: UseSeriesOptionsProps['selectedChartType'][] = [
'bar',
'line',
'pie',
'scatter',
'combo',
];
chartTypes.forEach((chartType) => {
// Arrange
const props = createMockProps({
selectedChartType: chartType,
});
// Act
renderHook(() => useSeriesOptions(props));
// Assert - The appropriate builder should have been called
// This tests that the labelsBuilderRecord and dataBuilderRecord are working correctly
});
});
it('should handle sizeOptions correctly for numeric columns', () => {
// Arrange
const props = createMockProps({
selectedChartType: 'scatter',
sizeKey: ['size_column'], // This is a numeric column in our mock data
});
// Act
const { result } = renderHook(() => useSeriesOptions(props));
// Assert
expect(result.current).toBeDefined();
// The hook should process the numeric size column without warnings
});
it('should handle sizeOptions with non-numeric columns and show warning', () => {
// Arrange
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const props = createMockProps({
selectedChartType: 'scatter',
sizeKey: ['text_column'], // This is a text column, not numeric
});
// Act
const { result } = renderHook(() => useSeriesOptions(props));
// Assert
expect(result.current).toBeDefined();
expect(consoleSpy).toHaveBeenCalledWith('Size key is not a number column', {
isNumberColumn: false,
sizeKey: ['text_column'],
});
consoleSpy.mockRestore();
});
it('should handle empty or undefined sizeKey', () => {
// Test case 1: Empty array
const props1 = createMockProps({
selectedChartType: 'scatter',
sizeKey: [],
});
const { result: result1 } = renderHook(() => useSeriesOptions(props1));
expect(result1.current).toBeDefined();
// Test case 2: Undefined column in metadata
const props2 = createMockProps({
selectedChartType: 'scatter',
sizeKey: ['nonexistent_column'],
});
const { result: result2 } = renderHook(() => useSeriesOptions(props2));
expect(result2.current).toBeDefined();
});
it('should memoize results properly when dependencies change', () => {
// Arrange
const initialProps = createMockProps();
const { result, rerender } = renderHook(
(props: UseSeriesOptionsProps) => useSeriesOptions(props),
{
initialProps,
}
);
const firstResult = result.current;
// Act - rerender with same props (should maintain same structure)
rerender(initialProps);
const secondResult = result.current;
// Assert - should have same structure (deep equality)
expect(secondResult).toStrictEqual(firstResult);
// Act - rerender with different props
const modifiedProps = createMockProps({
selectedChartType: 'line', // Changed chart type
});
rerender(modifiedProps);
const thirdResult = result.current;
// Assert - should have different structure as dependencies changed
expect(thirdResult).toBeDefined();
expect(thirdResult.labels).toBeDefined();
expect(thirdResult.datasets).toBeDefined();
});
it('should handle metric and table chart types correctly', () => {
// These chart types return empty arrays according to the implementation
// Test metric chart type
const metricProps = createMockProps({
selectedChartType: 'metric',
});
const { result: metricResult } = renderHook(() => useSeriesOptions(metricProps));
expect(metricResult.current.labels).toEqual([]);
expect(metricResult.current.datasets).toEqual([]);
// Test table chart type
const tableProps = createMockProps({
selectedChartType: 'table',
});
const { result: tableResult } = renderHook(() => useSeriesOptions(tableProps));
expect(tableResult.current.labels).toEqual([]);
expect(tableResult.current.datasets).toEqual([]);
});
it('should process sizeOptions with correct min/max values from metadata', () => {
// Arrange
const props = createMockProps({
selectedChartType: 'scatter',
sizeKey: ['size_column'],
});
// Act
const { result } = renderHook(() => useSeriesOptions(props));
// Assert
expect(result.current).toBeDefined();
// The hook should have processed the size column metadata correctly
// Min value should be converted from '10' to 10
// Max value should be converted from '100' to 100
// This tests the sizeOptions useMemo logic
});
});

View File

@ -1,3 +1,2 @@
export * from './useChartWrapperProvider';
export * from './useColors';
export * from './useDatasetOptions';

View File

@ -1,38 +0,0 @@
import type { ChartType } from '@buster/server-shared/metrics';
import { useMemo } from 'react';
import type { DatasetOption } from './useDatasetOptions';
export const useColors = ({
colors: colorsProp,
yAxisKeys,
y2AxisKeys,
selectedChartType,
datasetOptions,
}: {
colors: string[];
yAxisKeys: string[];
y2AxisKeys: string[];
datasetOptions: DatasetOption[];
selectedChartType: ChartType;
}) => {
const numberOfYAxisKeys = yAxisKeys.length;
const numberOfY2AxisKeys = y2AxisKeys.length;
const totalNumberOfKeys = numberOfYAxisKeys + numberOfY2AxisKeys;
const sourceLength = datasetOptions[0]?.data.length ?? 0;
const isScatter = selectedChartType === 'scatter';
const colors: string[] = useMemo(() => {
if (isScatter) {
return colorsProp;
}
return (
Array.from(
{ length: totalNumberOfKeys * sourceLength },
(_, i) => colorsProp[i % colorsProp.length] ?? ''
) ?? []
);
}, [colorsProp, totalNumberOfKeys, isScatter]);
return colors;
};

View File

@ -158,6 +158,11 @@ export const WithCategory: Story = {
product: 'Product 2',
sales: 800,
},
{
region: 'North',
product: 'Product 3',
sales: 820,
},
{
region: 'South',
product: 'Product 1',
@ -168,6 +173,26 @@ export const WithCategory: Story = {
product: 'Product 2',
sales: 300,
},
{
region: 'South',
product: 'Product 3',
sales: 220,
},
{
region: 'East',
product: 'Product 1',
sales: 1000,
},
{
region: 'East',
product: 'Product 2',
sales: 800,
},
{
region: 'East',
product: 'Product 3',
sales: 920,
},
],
barAndLineAxis: {
x: ['region'],
@ -1244,3 +1269,216 @@ export const WithYearInXAxis: Story = {
},
},
};
export const BarChartWithProblemData: Story = {
args: {
barLayout: 'vertical',
barGroupType: 'percentage-stack',
barAndLineAxis: {
x: ['customer_motivation'],
y: ['percentage_within_motivation'],
category: ['category_name'],
tooltip: null,
},
columnSettings: {
category_name: {
lineType: 'normal',
lineStyle: 'line',
lineWidth: 2,
barRoundness: 8,
lineSymbolSize: 0,
showDataLabels: false,
columnVisualization: 'bar',
showDataLabelsAsPercentage: false,
},
purchase_count: {
lineType: 'normal',
lineStyle: 'line',
lineWidth: 2,
barRoundness: 8,
lineSymbolSize: 0,
showDataLabels: false,
columnVisualization: 'bar',
showDataLabelsAsPercentage: false,
},
customer_motivation: {
lineType: 'normal',
lineStyle: 'line',
lineWidth: 2,
barRoundness: 8,
lineSymbolSize: 0,
showDataLabels: false,
columnVisualization: 'bar',
showDataLabelsAsPercentage: false,
},
percentage_within_motivation: {
lineType: 'normal',
lineStyle: 'line',
lineWidth: 2,
barRoundness: 8,
lineSymbolSize: 0,
showDataLabels: false,
columnVisualization: 'bar',
showDataLabelsAsPercentage: false,
},
},
disableTooltip: false,
yAxisScaleType: 'linear',
y2AxisScaleType: 'linear',
barShowTotalAtTop: false,
selectedChartType: 'bar',
columnLabelFormats: {
category_name: {
style: 'string',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'text',
dateFormat: 'auto',
multiplier: 1,
displayName: '',
compactNumbers: false,
useRelativeTime: false,
numberSeparatorStyle: null,
maximumFractionDigits: 2,
minimumFractionDigits: 0,
replaceMissingDataWith: null,
} as ColumnLabelFormat,
purchase_count: {
style: 'number',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'auto',
multiplier: 1,
displayName: '',
compactNumbers: true,
useRelativeTime: false,
numberSeparatorStyle: ',',
maximumFractionDigits: 2,
minimumFractionDigits: 0,
replaceMissingDataWith: 0,
} as ColumnLabelFormat,
customer_motivation: {
style: 'string',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'text',
dateFormat: 'auto',
multiplier: 1,
displayName: '',
compactNumbers: false,
useRelativeTime: false,
numberSeparatorStyle: null,
maximumFractionDigits: 2,
minimumFractionDigits: 0,
replaceMissingDataWith: null,
} as ColumnLabelFormat,
percentage_within_motivation: {
style: 'percent',
prefix: '',
suffix: '',
currency: 'USD',
columnType: 'number',
dateFormat: 'auto',
multiplier: 1,
displayName: '',
compactNumbers: false,
useRelativeTime: false,
numberSeparatorStyle: ',',
maximumFractionDigits: 1,
minimumFractionDigits: 1,
replaceMissingDataWith: 0,
} as ColumnLabelFormat,
},
showLegendHeadline: false,
xAxisLabelRotation: 'auto',
xAxisShowAxisLabel: true,
xAxisShowAxisTitle: true,
yAxisShowAxisLabel: true,
yAxisShowAxisTitle: true,
y2AxisShowAxisLabel: true,
y2AxisShowAxisTitle: true,
y2AxisStartAxisAtZero: true,
data: [
{
customer_motivation: 'Recreation',
category_name: 'Accessories',
purchase_count: 33143,
percentage_within_motivation: 67.31,
},
{
customer_motivation: 'Recreation',
category_name: 'Bikes',
purchase_count: 8427,
percentage_within_motivation: 17.11,
},
{
customer_motivation: 'Recreation',
category_name: 'Clothing',
purchase_count: 7321,
percentage_within_motivation: 14.87,
},
{
customer_motivation: 'Recreation',
category_name: 'Components',
purchase_count: 350,
percentage_within_motivation: 0.71,
},
{
customer_motivation: 'Transportation',
category_name: 'Bikes',
purchase_count: 4433,
percentage_within_motivation: 71.88,
},
{
customer_motivation: 'Transportation',
category_name: 'Clothing',
purchase_count: 1615,
percentage_within_motivation: 26.19,
},
{
customer_motivation: 'Transportation',
category_name: 'Components',
purchase_count: 119,
percentage_within_motivation: 1.93,
},
],
columnMetadata: [
{
name: 'customer_motivation',
min_value: 'Recreation',
max_value: 'Transportation',
unique_values: 2,
simple_type: 'text',
type: 'text',
},
{
name: 'category_name',
min_value: 'Accessories',
max_value: 'Components',
unique_values: 4,
simple_type: 'text',
type: 'text',
},
{
name: 'purchase_count',
min_value: 119,
max_value: 33143,
unique_values: 7,
simple_type: 'number',
type: 'int8',
},
{
name: 'percentage_within_motivation',
min_value: 0.71,
max_value: 71.88,
unique_values: 7,
simple_type: 'number',
type: 'numeric',
},
],
},
};

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { versionGetAppVersion } from '@/api/query_keys/version';
import { Text } from '@/components/ui/typography';
import { useWindowFocus } from '@/hooks/useWindowFocus';
@ -53,17 +53,6 @@ export const useAppVersion = () => {
};
const AppVersionMessage = () => {
// const [countdown, setCountdown] = useState(180);
// useEffect(() => {
// const interval = setInterval(() => {
// setCountdown((prev) => Math.max(prev - 1, 0));
// if (countdown === 0) {
// window.location.reload();
// }
// }, 1000);
// return () => clearInterval(interval);
// }, []);
return (
<Text>
A new version of the app is available. Please refresh the page to get the latest features.
@ -78,3 +67,12 @@ export const useIsVersionChanged = () => {
});
return data;
};
export const useAppVersionMeta = () => {
const { data } = useQuery({
...versionGetAppVersion,
select: (data) => data,
notifyOnChangeProps: ['data'],
});
return useMemo(() => ({ ...data, browserBuild }), [data]);
};

View File

@ -2,7 +2,6 @@ import { isServer } from '@tanstack/react-query';
import { ClientOnly } from '@tanstack/react-router';
import type { PostHogConfig } from 'posthog-js';
import React, { type PropsWithChildren, useEffect, useState } from 'react';
import { useGetUserTeams } from '@/api/buster_rest/users';
import {
useGetUserBasicInfo,
useGetUserOrganization,
@ -10,10 +9,8 @@ import {
import { ComponentErrorCard } from '@/components/features/global/ComponentErrorCard';
import { isDev } from '@/config/dev';
import { env } from '@/env';
import { useMount } from '@/hooks/useMount';
import packageJson from '../../../package.json';
import { useAppVersionMeta } from '../AppVersion/useAppVersion';
const version = packageJson.version;
const POSTHOG_KEY = env.VITE_PUBLIC_POSTHOG_KEY;
const DEBUG_POSTHOG = false;
@ -38,9 +35,13 @@ const options: Partial<PostHogConfig> = {
session_recording: {
recordBody: true,
},
api_host: '/phrp/',
ui_host: 'https://us.posthog.com',
defaults: '2025-05-24',
};
const PosthogWrapper: React.FC<PropsWithChildren> = ({ children }) => {
const appVersionMeta = useAppVersionMeta();
const user = useGetUserBasicInfo();
const userOrganizations = useGetUserOrganization();
const userOrganizationId = userOrganizations?.id || '';
@ -60,6 +61,7 @@ const PosthogWrapper: React.FC<PropsWithChildren> = ({ children }) => {
import('posthog-js'),
import('posthog-js/react'),
]);
console.log('posthog', posthog);
setPosthogModules({ posthog, PostHogProvider });
} catch (error) {
@ -89,8 +91,34 @@ const PosthogWrapper: React.FC<PropsWithChildren> = ({ children }) => {
organization: userOrganizations,
});
posthog.group(userOrganizationId, userOrganizationName);
// Register app version metadata to be included with all events
if (appVersionMeta) {
posthog.register({
app_version: appVersionMeta.buildId,
browser_build: appVersionMeta.browserBuild,
server_build: appVersionMeta.buildId,
version_changed: appVersionMeta.buildId !== appVersionMeta.browserBuild,
});
}
}
}, [user?.id, userOrganizationId, userOrganizationName, posthogModules]);
}, [user?.id, userOrganizationId, userOrganizationName, posthogModules, appVersionMeta]);
// Update app version metadata when it changes after PostHog is initialized
useEffect(() => {
if (posthogModules?.posthog && appVersionMeta) {
const { posthog } = posthogModules;
if (posthog.__loaded) {
posthog.register({
app_version: appVersionMeta.buildId,
browser_build: appVersionMeta.browserBuild,
server_build: appVersionMeta.buildId,
version_changed: appVersionMeta.buildId !== appVersionMeta.browserBuild,
});
}
}
}, [appVersionMeta, posthogModules]);
// Show children while loading or if modules failed to load
if (isLoading || !posthogModules) {

View File

@ -36,14 +36,14 @@ export const AppAssetCheckLayout: React.FC<
return null;
} else if (!assetId || !assetType) {
return <AppAssetNotFound assetId={assetId} type={assetType} />;
} else if (!hasAccess && !isPublic) {
content = <AppNoPageAccess assetId={assetId} type={assetType} />;
} else if (isPublic && passwordRequired) {
} else if (isPublic && passwordRequired && !hasAccess) {
content = (
<AppPasswordAccess assetId={assetId} type={assetType}>
{children}
</AppPasswordAccess>
);
} else if (!hasAccess) {
content = <AppNoPageAccess assetId={assetId} type={assetType} />;
} else {
content = children;
}

View File

@ -33,7 +33,7 @@ const getAssetAccess = (
passwordRequired: true,
isPublic: true,
isDeleted: false,
isFetched,
isFetched: true,
};
}
@ -44,7 +44,7 @@ const getAssetAccess = (
passwordRequired: false,
isPublic: false,
isDeleted: true,
isFetched,
isFetched: true,
};
}
@ -55,7 +55,7 @@ const getAssetAccess = (
passwordRequired: false,
isPublic: false,
isDeleted: false,
isFetched,
isFetched: true,
};
}
@ -65,7 +65,7 @@ const getAssetAccess = (
passwordRequired: false,
isPublic: false,
isDeleted: false,
isFetched,
isFetched: true,
};
}

View File

@ -19,4 +19,7 @@ export function defineLinkFromFactory<TFrom extends string>(fromOptions: { from:
}) as ValidateLinkOptions<TRouter, TOptions, TFrom>;
}
export const createFullURL = (location: ParsedLocation) => window.location.origin + location.href;
export const createFullURL = (location: ParsedLocation | string): string =>
window.location.origin + typeof location === 'string'
? (location as string)
: (location as ParsedLocation).href;

View File

@ -1,10 +1,20 @@
import Cookies from 'js-cookie';
import { getQueryClient } from '@/integrations/tanstack-query/query-client';
/**
* Clears all browser storage including localStorage, sessionStorage, and cookies
* Clears all browser storage including localStorage, sessionStorage, cookies, and React Query cache
* @returns void
*/
export const clearAllBrowserStorage = (): void => {
// Clear React Query cache first
try {
const queryClient = getQueryClient();
queryClient.clear(); // Removes all cached queries and mutations
queryClient.getQueryCache().clear(); // Additional cleanup of query cache
queryClient.getMutationCache().clear(); // Additional cleanup of mutation cache
} catch (error) {
console.warn('Failed to clear React Query cache:', error);
}
// Clear localStorage
localStorage.clear();

View File

@ -18,9 +18,11 @@ import { Route as AuthRouteImport } from './routes/auth'
import { Route as AppRouteImport } from './routes/app'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AppIndexRouteImport } from './routes/app/index'
import { Route as InfoGettingStartedRouteImport } from './routes/info/getting-started'
import { Route as AuthResetPasswordRouteImport } from './routes/auth.reset-password'
import { Route as AuthLogoutRouteImport } from './routes/auth.logout'
import { Route as AuthLoginRouteImport } from './routes/auth.login'
import { Route as AppThrowRouteImport } from './routes/app.throw'
import { Route as AppSettingsRouteImport } from './routes/app/_settings'
import { Route as AppAppRouteImport } from './routes/app/_app'
import { Route as EmbedReportReportIdRouteImport } from './routes/embed/report.$reportId'
@ -191,6 +193,11 @@ const AppIndexRoute = AppIndexRouteImport.update({
path: '/',
getParentRoute: () => AppRoute,
} as any)
const InfoGettingStartedRoute = InfoGettingStartedRouteImport.update({
id: '/info/getting-started',
path: '/info/getting-started',
getParentRoute: () => rootRouteImport,
} as any)
const AuthResetPasswordRoute = AuthResetPasswordRouteImport.update({
id: '/reset-password',
path: '/reset-password',
@ -206,6 +213,11 @@ const AuthLoginRoute = AuthLoginRouteImport.update({
path: '/login',
getParentRoute: () => AuthRoute,
} as any)
const AppThrowRoute = AppThrowRouteImport.update({
id: '/throw',
path: '/throw',
getParentRoute: () => AppRoute,
} as any)
const AppSettingsRoute = AppSettingsRouteImport.update({
id: '/_settings',
getParentRoute: () => AppRoute,
@ -933,9 +945,11 @@ export interface FileRoutesByFullPath {
'/auth': typeof AuthRouteWithChildren
'/embed': typeof EmbedRouteWithChildren
'/healthcheck': typeof HealthcheckRoute
'/app/throw': typeof AppThrowRoute
'/auth/login': typeof AuthLoginRoute
'/auth/logout': typeof AuthLogoutRoute
'/auth/reset-password': typeof AuthResetPasswordRoute
'/info/getting-started': typeof InfoGettingStartedRoute
'/app/': typeof AppIndexRoute
'/app/healthcheck': typeof AppAppHealthcheckRoute
'/app/home': typeof AppAppHomeRoute
@ -1041,9 +1055,11 @@ export interface FileRoutesByTo {
'/embed': typeof EmbedRouteWithChildren
'/healthcheck': typeof HealthcheckRoute
'/app': typeof AppSettingsRestricted_layoutAdmin_onlyRouteWithChildren
'/app/throw': typeof AppThrowRoute
'/auth/login': typeof AuthLoginRoute
'/auth/logout': typeof AuthLogoutRoute
'/auth/reset-password': typeof AuthResetPasswordRoute
'/info/getting-started': typeof InfoGettingStartedRoute
'/app/healthcheck': typeof AppAppHealthcheckRoute
'/app/home': typeof AppAppHomeRoute
'/embed/dashboard/$dashboardId': typeof EmbedDashboardDashboardIdRoute
@ -1134,9 +1150,11 @@ export interface FileRoutesById {
'/healthcheck': typeof HealthcheckRoute
'/app/_app': typeof AppAppRouteWithChildren
'/app/_settings': typeof AppSettingsRouteWithChildren
'/app/throw': typeof AppThrowRoute
'/auth/login': typeof AuthLoginRoute
'/auth/logout': typeof AuthLogoutRoute
'/auth/reset-password': typeof AuthResetPasswordRoute
'/info/getting-started': typeof InfoGettingStartedRoute
'/app/': typeof AppIndexRoute
'/app/_app/_asset': typeof AppAppAssetRouteWithChildren
'/app/_app/healthcheck': typeof AppAppHealthcheckRoute
@ -1258,9 +1276,11 @@ export interface FileRouteTypes {
| '/auth'
| '/embed'
| '/healthcheck'
| '/app/throw'
| '/auth/login'
| '/auth/logout'
| '/auth/reset-password'
| '/info/getting-started'
| '/app/'
| '/app/healthcheck'
| '/app/home'
@ -1366,9 +1386,11 @@ export interface FileRouteTypes {
| '/embed'
| '/healthcheck'
| '/app'
| '/app/throw'
| '/auth/login'
| '/auth/logout'
| '/auth/reset-password'
| '/info/getting-started'
| '/app/healthcheck'
| '/app/home'
| '/embed/dashboard/$dashboardId'
@ -1458,9 +1480,11 @@ export interface FileRouteTypes {
| '/healthcheck'
| '/app/_app'
| '/app/_settings'
| '/app/throw'
| '/auth/login'
| '/auth/logout'
| '/auth/reset-password'
| '/info/getting-started'
| '/app/'
| '/app/_app/_asset'
| '/app/_app/healthcheck'
@ -1581,6 +1605,7 @@ export interface RootRouteChildren {
AuthRoute: typeof AuthRouteWithChildren
EmbedRoute: typeof EmbedRouteWithChildren
HealthcheckRoute: typeof HealthcheckRoute
InfoGettingStartedRoute: typeof InfoGettingStartedRoute
}
export interface FileServerRoutesByFullPath {
'/auth/callback': typeof AuthCallbackServerRoute
@ -1648,6 +1673,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppIndexRouteImport
parentRoute: typeof AppRoute
}
'/info/getting-started': {
id: '/info/getting-started'
path: '/info/getting-started'
fullPath: '/info/getting-started'
preLoaderRoute: typeof InfoGettingStartedRouteImport
parentRoute: typeof rootRouteImport
}
'/auth/reset-password': {
id: '/auth/reset-password'
path: '/reset-password'
@ -1669,6 +1701,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthLoginRouteImport
parentRoute: typeof AuthRoute
}
'/app/throw': {
id: '/app/throw'
path: '/throw'
fullPath: '/app/throw'
preLoaderRoute: typeof AppThrowRouteImport
parentRoute: typeof AppRoute
}
'/app/_settings': {
id: '/app/_settings'
path: ''
@ -3195,12 +3234,14 @@ const AppSettingsRouteWithChildren = AppSettingsRoute._addFileChildren(
interface AppRouteChildren {
AppAppRoute: typeof AppAppRouteWithChildren
AppSettingsRoute: typeof AppSettingsRouteWithChildren
AppThrowRoute: typeof AppThrowRoute
AppIndexRoute: typeof AppIndexRoute
}
const AppRouteChildren: AppRouteChildren = {
AppAppRoute: AppAppRouteWithChildren,
AppSettingsRoute: AppSettingsRouteWithChildren,
AppThrowRoute: AppThrowRoute,
AppIndexRoute: AppIndexRoute,
}
@ -3240,6 +3281,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthRoute: AuthRouteWithChildren,
EmbedRoute: EmbedRouteWithChildren,
HealthcheckRoute: HealthcheckRoute,
InfoGettingStartedRoute: InfoGettingStartedRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -0,0 +1,23 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
import { useMount } from '../hooks/useMount';
export const Route = createFileRoute('/app/throw')({
component: RouteComponent,
});
function RouteComponent() {
const [throwError, setThrowError] = useState(false);
useMount(() => {
setTimeout(() => {
setThrowError(true);
}, 1000);
});
if (throwError) {
throw new Error('Nate is testing this error');
}
return <div>Hello "/app/throw"! {throwError ? 'Throwing error' : 'Not throwing error'}</div>;
}

View File

@ -25,11 +25,18 @@ export const Route = createFileRoute('/app')({
loader: async ({ context }) => {
const { queryClient, supabaseSession } = context;
try {
await Promise.all([prefetchGetMyUserInfo(queryClient)]);
const [user] = await Promise.all([prefetchGetMyUserInfo(queryClient)]);
if (!user || !user.organizations || user.organizations.length === 0) {
throw redirect({ href: 'https://buster.so/sign-up', replace: true, statusCode: 307 });
}
return {
supabaseSession,
};
} catch (error) {
// Re-throw redirect Responses so the router can handle them (e.g., getting-started)
if (error instanceof Response) {
throw error;
}
console.error('Error in app route loader:', error);
throw redirect({ to: '/auth/login', replace: true, statusCode: 307 });
}

View File

@ -14,6 +14,6 @@ export const Route = createFileRoute('/auth/logout')({
preload: false,
loader: async () => {
await signOutServerFn();
throw redirect({ to: '/auth/login', statusCode: 307 });
throw redirect({ to: '/auth/login', statusCode: 307, reloadDocument: true, replace: true });
},
});

View File

@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router';
import { useEffect } from 'react';
export const Route = createFileRoute('/info/getting-started')({
component: GettingStartedPage,
});
export default function GettingStartedPage() {
useEffect(() => {
window.location.replace('https://buster.so/sign-up');
}, []);
return null;
}

12
apps/web/vercel.json Normal file
View File

@ -0,0 +1,12 @@
{
"rewrites": [
{
"source": "/phrp/static/(.*)",
"destination": "https://us-assets.i.posthog.com/static/$1"
},
{
"source": "/phrp/(.*)",
"destination": "https://us.i.posthog.com/$1"
}
]
}

View File

@ -13,11 +13,11 @@ export const DEFAULT_ANTHROPIC_OPTIONS = {
export const DEFAULT_OPENAI_OPTIONS = {
gateway: {
order: ['openai'],
openai: {
parallelToolCalls: false,
reasoningEffort: 'minimal',
verbosity: 'low',
},
},
openai: {
parallelToolCalls: false,
reasoningEffort: 'minimal',
verbosity: 'low',
},
};

View File

@ -15,7 +15,6 @@ export {
recoverMessages,
executeStreamAttempt,
handleFailedAttempt,
analyzeError,
createRetryExecutor,
composeMiddleware,
retryMiddleware,

View File

@ -2,7 +2,6 @@ import type { ModelMessage } from 'ai';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type StreamExecutor,
analyzeError,
calculateBackoffDelay,
composeMiddleware,
createMockAgent,
@ -84,22 +83,6 @@ describe('with-agent-retry', () => {
});
});
describe('analyzeError', () => {
it('should correctly identify retryable errors', () => {
const overloadedError = createOverloadedError();
const result = analyzeError(overloadedError);
expect(result.isRetryable).toBe(true);
expect(result.error).toEqual(overloadedError);
});
it('should treat all errors as retryable', () => {
const regularError = new Error('Regular error');
const result = analyzeError(regularError);
expect(result.isRetryable).toBe(true);
expect(result.error).toEqual(regularError);
});
});
describe('sleep', () => {
it('should resolve after specified duration', async () => {
const startTime = Date.now();
@ -238,19 +221,23 @@ describe('with-agent-retry', () => {
expect(mockFetchMessageEntries).not.toHaveBeenCalled();
});
it('should not retry when recovery fails', async () => {
it('should continue with original messages when recovery fails', async () => {
mockFetchMessageEntries.mockRejectedValue(new Error('DB error'));
const originalMessages: ModelMessage[] = [{ role: 'user', content: 'original' }];
const result = await handleFailedAttempt(
createOverloadedError(),
1,
3,
'test-id',
[],
originalMessages,
1000
);
expect(result.shouldRetry).toBe(false);
// We now continue with original messages when recovery fails
expect(result.shouldRetry).toBe(true);
expect(result.nextMessages).toEqual(originalMessages);
expect(result.delayMs).toBe(1000);
});
});
});

View File

@ -38,14 +38,6 @@ interface RetryOptions {
onRetry?: (attempt: number, recoveredMessageCount: number) => void;
}
/**
* Result of checking if an error is retryable
*/
interface RetryableCheck {
isRetryable: boolean;
error: unknown;
}
// ===== Pure Functions =====
/**
@ -96,16 +88,6 @@ export const calculateBackoffDelay = (attempt: number, baseDelayMs: number): num
export const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Check if an error is retryable and return structured result
* Now treats ALL errors as retryable to handle various provider errors
* Pure function for error analysis
*/
export const analyzeError = (error: unknown): RetryableCheck => ({
isRetryable: true, // All errors are now retryable
error,
});
/**
* Recover messages from database
* Returns either recovered messages or original messages
@ -137,6 +119,8 @@ export const recoverMessages = async (
console.error('[Agent Retry] Failed to recover from database', {
messageId,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
errorType: error instanceof Error ? error.name : typeof error,
});
throw error;
}
@ -177,12 +161,12 @@ export const handleFailedAttempt = async (
messageId,
error: error instanceof Error ? error.message : 'Unknown error',
errorType: error instanceof Error ? error.name : typeof error,
stack: error instanceof Error ? error.stack : undefined,
});
const { isRetryable } = analyzeError(error);
if (!isRetryable || attempt === maxAttempts) {
console.error('[Agent Retry] Non-retryable error or max attempts reached', {
// Check if we've reached max attempts
if (attempt === maxAttempts) {
console.error('[Agent Retry] Max attempts reached', {
messageId,
attempt,
maxAttempts,
@ -190,7 +174,7 @@ export const handleFailedAttempt = async (
return { shouldRetry: false, nextMessages: currentMessages, delayMs: 0 };
}
console.warn('[Agent Retry] Error detected, preparing retry', {
console.warn('[Agent Retry] Preparing retry', {
messageId,
attempt,
remainingAttempts: maxAttempts - attempt,
@ -215,9 +199,30 @@ export const handleFailedAttempt = async (
nextMessages: recoveredMessages,
delayMs,
};
} catch (_recoveryError) {
// If recovery fails, don't retry
return { shouldRetry: false, nextMessages: currentMessages, delayMs: 0 };
} catch (recoveryError) {
// Log the recovery failure with full context
console.error('[Agent Retry] Failed to recover messages from database', {
messageId,
attempt,
recoveryError: recoveryError instanceof Error ? recoveryError.message : 'Unknown error',
recoveryErrorType: recoveryError instanceof Error ? recoveryError.name : typeof recoveryError,
recoveryStack: recoveryError instanceof Error ? recoveryError.stack : undefined,
originalError: error instanceof Error ? error.message : 'Unknown error',
});
// Continue with original messages if recovery fails
console.warn('[Agent Retry] Continuing with original messages after recovery failure', {
messageId,
messageCount: currentMessages.length,
});
const delayMs = calculateBackoffDelay(attempt, baseDelayMs);
return {
shouldRetry: true,
nextMessages: currentMessages,
delayMs,
};
}
};
@ -271,19 +276,13 @@ export function withAgentRetry<
TStreamResult = unknown,
TAgent extends Agent<TStreamResult> = Agent<TStreamResult>,
>(agent: TAgent, options: RetryOptions): TAgent {
// Create a new object with the same prototype
const wrappedAgent = Object.create(Object.getPrototypeOf(agent)) as TAgent;
// Copy all properties except stream
for (const key in agent) {
if (key !== 'stream' && Object.prototype.hasOwnProperty.call(agent, key)) {
wrappedAgent[key] = agent[key];
}
}
// Wrap the stream method with retry logic
wrappedAgent.stream = (streamOptions: StreamOptions) =>
retryStream(agent, streamOptions.messages, options);
// Create a new agent with all properties spread from the original
// This ensures type safety and copies all properties correctly
const wrappedAgent = {
...agent,
// Override the stream method with retry logic
stream: (streamOptions: StreamOptions) => retryStream(agent, streamOptions.messages, options),
};
return wrappedAgent;
}
@ -305,7 +304,7 @@ export const createRetryExecutor = <TStreamResult>(
): StreamExecutor<TStreamResult> => {
return async (messages: ModelMessage[]) => {
const agent: Agent<TStreamResult> = {
stream: async ({ messages }) => executor(messages),
stream: async (streamOptions: StreamOptions) => executor(streamOptions.messages),
};
return retryStream(agent, messages, options);
};

View File

@ -46,6 +46,7 @@ export async function withStepRetry<T>(
console.error(`[${stepName}] Error on attempt ${attempt}:`, {
error: error instanceof Error ? error.message : 'Unknown error',
errorType: error instanceof Error ? error.name : typeof error,
stack: error instanceof Error ? error.stack : undefined,
});
// If this was the last attempt, throw the error

View File

@ -40,6 +40,13 @@ async function upsertData(tx: any, tableName: string, table: any, data: any[]) {
if (!data || data.length === 0) return;
try {
// Fix YAML content in datasets table by converting literal \n to actual newlines
if (tableName === 'datasets') {
data = data.map(record => ({
...record,
ymlFile: record.ymlFile ? record.ymlFile.replace(/\\n/g, '\n') : record.ymlFile
}));
}
// For tables that should always use ON CONFLICT DO NOTHING instead of updating
const doNothingTables = [
'assetSearch',