mirror of https://github.com/buster-so/buster.git
commit
5a32524426
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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%');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
});
|
||||
});
|
|
@ -1,3 +1,2 @@
|
|||
export * from './useChartWrapperProvider';
|
||||
export * from './useColors';
|
||||
export * from './useDatasetOptions';
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -15,7 +15,6 @@ export {
|
|||
recoverMessages,
|
||||
executeStreamAttempt,
|
||||
handleFailedAttempt,
|
||||
analyzeError,
|
||||
createRetryExecutor,
|
||||
composeMiddleware,
|
||||
retryMiddleware,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue