mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 19:39:00 -05:00
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:
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user