fix: align schema definitions and seed data with CubeJS cube, add feedback_records table

- Remove phantom dimensions (channel, rating, surveyName) and measure
  (completionRate) from schema-definition that don't exist in the CubeJS
  FeedbackRecords cube
- Fix seed chart queries to use valid cube fields (collectedAt instead of
  createdAt, sentiment instead of rating, sourceType instead of channel,
  sourceName instead of surveyName)
- Create feedback_records table with sample data in the seed so charts
  work in local dev
- Fix ChartPreview infinite spinner when query fails by separating
  loading/error/empty states
- Prevent duplicate query execution when AdvancedChartBuilder has
  hidePreview enabled
This commit is contained in:
TheodorTomas
2026-02-25 15:03:32 +07:00
parent f451658eaf
commit b65feca0a5
7 changed files with 127 additions and 44 deletions
@@ -174,10 +174,14 @@ export function AdvancedChartBuilder({
}
}, [initialChartType, initialQuery, isInitialized]);
// Initialize: execute initialQuery once (deps intentionally minimal to avoid redundant runs)
// Initialize: execute initialQuery once (deps intentionally minimal to avoid redundant runs).
// Skip when hidePreview is true because the parent component handles data loading.
useEffect(() => {
if (!initialQuery || isInitialized) return;
setIsInitialized(true);
if (hidePreview) return;
const chartType = state.chartType;
const requestId = ++requestIdRef.current;
@@ -12,9 +12,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/component
interface ChartPreviewProps {
chartData: AnalyticsResponse | null;
isLoading?: boolean;
error?: string | null;
}
export function ChartPreview({ chartData, isLoading = false }: Readonly<ChartPreviewProps>) {
export function ChartPreview({ chartData, isLoading = false, error }: Readonly<ChartPreviewProps>) {
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
const { t } = useTranslation();
@@ -27,7 +28,7 @@ export function ChartPreview({ chartData, isLoading = false }: Readonly<ChartPre
};
const renderContent = () => {
if (isLoading || !chartData) {
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<LoadingSpinner />
@@ -35,9 +36,19 @@ export function ChartPreview({ chartData, isLoading = false }: Readonly<ChartPre
);
}
if (chartData.error) {
if (error || chartData?.error) {
return (
<div className="flex h-48 items-center justify-center text-sm text-red-600">{chartData.error}</div>
<div className="flex h-48 items-center justify-center text-sm text-red-600">
{error || chartData?.error}
</div>
);
}
if (!chartData) {
return (
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
@@ -50,6 +50,7 @@ export function CreateChartDialog({
setSelectedDashboardId,
isSaving,
isLoadingChart,
chartLoadError,
shouldShowAdvancedBuilder,
handleChartGenerated,
handleSaveChart,
@@ -72,6 +73,7 @@ export function CreateChartDialog({
chartData={chartData ?? null}
initialQuery={initialQuery}
isLoadingChart={isLoadingChart}
chartLoadError={chartLoadError}
chartName={chartName}
onChartNameChange={setChartName}
selectedChartType={selectedChartType}
@@ -26,6 +26,7 @@ interface EditChartViewProps {
/** Query from initialChart when chartData is still loading */
initialQuery?: AnalyticsResponse["query"];
isLoadingChart?: boolean;
chartLoadError?: string | null;
chartName: string;
onChartNameChange: (name: string) => void;
selectedChartType: TChartType | "";
@@ -50,6 +51,7 @@ export function EditChartView({
chartData,
initialQuery,
isLoadingChart = false,
chartLoadError,
chartName,
onChartNameChange,
selectedChartType,
@@ -104,7 +106,7 @@ export function EditChartView({
onSave={onAdvancedBuilderSave}
onAddToDashboard={onAdvancedBuilderAddToDashboard}
/>
<ChartPreview chartData={chartData} isLoading={isLoadingChart} />
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
</DialogBody>
<ChartDialogFooter
@@ -45,6 +45,7 @@ export function useCreateChartDialog({
const [selectedDashboardId, setSelectedDashboardId] = useState<string>(defaultDashboardId ?? "");
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
const router = useRouter();
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
@@ -72,6 +73,7 @@ export function useCreateChartDialog({
}
setIsLoadingChart(true);
setChartLoadError(null);
const loadChartData = async (query: TChartWithCreator["query"], chartType: string) => {
const queryResult = await executeQueryAction({
@@ -80,10 +82,11 @@ export function useCreateChartDialog({
});
if (queryResult?.serverError) {
toast.error(
const errorMsg =
getFormattedErrorMessage(queryResult) ||
t("environments.analysis.charts.failed_to_load_chart_data")
);
t("environments.analysis.charts.failed_to_load_chart_data");
toast.error(errorMsg);
setChartLoadError(errorMsg);
setIsLoadingChart(false);
return;
}
@@ -96,7 +99,9 @@ export function useCreateChartDialog({
data,
});
} else {
toast.error(t("environments.analysis.charts.no_data_returned_for_chart"));
const errorMsg = t("environments.analysis.charts.no_data_returned_for_chart");
toast.error(errorMsg);
setChartLoadError(errorMsg);
}
setIsLoadingChart(false);
};
@@ -121,6 +126,7 @@ export function useCreateChartDialog({
const message =
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_load_chart");
toast.error(message);
setChartLoadError(message);
setIsLoadingChart(false);
});
}
@@ -321,6 +327,7 @@ export function useCreateChartDialog({
setSelectedDashboardId,
isSaving,
isLoadingChart,
chartLoadError,
shouldShowAdvancedBuilder,
handleChartGenerated,
handleSaveChart,
@@ -61,30 +61,12 @@ export const FEEDBACK_FIELDS = {
type: "string",
description: "Unique identifier linking related feedback records",
},
{
id: "FeedbackRecords.rating",
label: "Rating",
type: "number",
description: "Rating value from feedback",
},
{
id: "FeedbackRecords.npsValue",
label: "NPS Value",
type: "number",
description: "Raw NPS score value (0-10)",
},
{
id: "FeedbackRecords.surveyName",
label: "Survey Name",
type: "string",
description: "Name of the survey",
},
{
id: "FeedbackRecords.channel",
label: "Channel",
type: "string",
description: "Channel through which feedback was collected",
},
{
id: "FeedbackRecords.collectedAt",
label: "Collected At",
@@ -135,12 +117,6 @@ export const FEEDBACK_FIELDS = {
type: "number",
description: "Average NPS score",
},
{
id: "FeedbackRecords.completionRate",
label: "Completion Rate",
type: "number",
description: "Survey completion rate percentage",
},
] as MeasureDefinition[],
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
};
+91 -10
View File
@@ -573,12 +573,77 @@ async function main(): Promise<void> {
await generateResponses(SEED_IDS.SURVEY_CSAT, 50);
await generateResponses(SEED_IDS.SURVEY_COMPLETED, 50);
// feedback_records table (used by CubeJS for analytics charts)
logger.info("Seeding feedback_records table for CubeJS...");
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS feedback_records (
id TEXT PRIMARY KEY,
sentiment TEXT,
source_type TEXT,
source_name TEXT,
field_type TEXT,
collected_at TIMESTAMPTZ,
value_number DOUBLE PRECISION,
response_id TEXT,
user_identifier TEXT,
emotion TEXT,
metadata JSONB DEFAULT '{}'::jsonb
)
`);
await prisma.$executeRawUnsafe(`DELETE FROM feedback_records`);
const sentiments = ["positive", "negative", "neutral"];
const sourceTypes = ["survey", "nps_campaign", "in_app"];
const sourceNames = ["Onboarding Survey", "Q4 NPS", "Feature Feedback", "Support Follow-up"];
const fieldTypes = ["nps", "text", "rating"];
const emotions = ["happy", "frustrated", "neutral", "satisfied", "confused"];
const topics = [
["usability", "onboarding"],
["pricing"],
["performance", "bugs"],
["feature-request"],
["documentation"],
];
const userIds = ["user-alice", "user-bob", "user-carol", "user-dave", "user-eve"];
const feedbackValues: string[] = [];
for (let i = 0; i < 100; i++) {
const id = `fb_seed_${String(i).padStart(3, "0")}`;
const sentiment = sentiments[i % sentiments.length];
const sourceType = sourceTypes[i % sourceTypes.length];
const sourceName = sourceNames[i % sourceNames.length];
const fieldType = fieldTypes[i % fieldTypes.length];
const daysAgo = Math.floor(Math.random() * 180);
const collectedAt = new Date(Date.now() - daysAgo * 86400000).toISOString();
const valueNumber = Math.floor(Math.random() * 11);
const responseId = `resp_seed_${String(i).padStart(3, "0")}`;
const userIdentifier = userIds[i % userIds.length];
const emotion = emotions[i % emotions.length];
const topicList = topics[i % topics.length];
const metadata = JSON.stringify({ topics: topicList }).replace(/'/g, "''");
feedbackValues.push(
`('${id}','${sentiment}','${sourceType}','${sourceName}','${fieldType}','${collectedAt}',${valueNumber},'${responseId}','${userIdentifier}','${emotion}','${metadata}'::jsonb)`
);
}
await prisma.$executeRawUnsafe(
`INSERT INTO feedback_records (id, sentiment, source_type, source_name, field_type, collected_at, value_number, response_id, user_identifier, emotion, metadata) VALUES ${feedbackValues.join(",\n")} ON CONFLICT (id) DO NOTHING`
);
// Charts & Dashboards
logger.info("Seeding charts and dashboards...");
const chartResponsesOverTime = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_RESPONSES_OVER_TIME },
update: {},
update: {
query: {
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", granularity: "week" }],
},
},
create: {
id: SEED_IDS.CHART_RESPONSES_OVER_TIME,
name: "Responses Over Time",
@@ -587,7 +652,7 @@ async function main(): Promise<void> {
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.createdAt", granularity: "week" }],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", granularity: "week" }],
},
config: {
xAxisLabel: "Week",
@@ -600,7 +665,12 @@ async function main(): Promise<void> {
const chartSatisfactionDist = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_SATISFACTION_DIST },
update: {},
update: {
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
},
},
create: {
id: SEED_IDS.CHART_SATISFACTION_DIST,
name: "Satisfaction Distribution",
@@ -609,7 +679,7 @@ async function main(): Promise<void> {
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.rating"],
dimensions: ["FeedbackRecords.sentiment"],
},
config: {
showLegend: true,
@@ -641,7 +711,12 @@ async function main(): Promise<void> {
const chartCompletionRate = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_COMPLETION_RATE },
update: {},
update: {
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sourceName"],
},
},
create: {
id: SEED_IDS.CHART_COMPLETION_RATE,
name: "Survey Completion Rate",
@@ -649,8 +724,8 @@ async function main(): Promise<void> {
projectId: project.id,
createdBy: SEED_IDS.USER_MANAGER,
query: {
measures: ["FeedbackRecords.completionRate"],
dimensions: ["FeedbackRecords.surveyName"],
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sourceName"],
},
config: {
xAxisLabel: "Survey",
@@ -664,7 +739,13 @@ async function main(): Promise<void> {
const chartTopChannels = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_TOP_CHANNELS },
update: {},
update: {
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", granularity: "month" }],
},
},
create: {
id: SEED_IDS.CHART_TOP_CHANNELS,
name: "Responses by Channel",
@@ -673,8 +754,8 @@ async function main(): Promise<void> {
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.channel"],
timeDimensions: [{ dimension: "FeedbackRecords.createdAt", granularity: "month" }],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", granularity: "month" }],
},
config: {
stacked: true,