fix setup + tweak chart & dashboards creation UX

This commit is contained in:
Johannes
2026-04-22 09:04:42 +02:00
parent b099219244
commit ad094b2d4c
16 changed files with 146 additions and 79 deletions
+6 -7
View File
@@ -287,15 +287,14 @@ REDIS_URL=redis://localhost:6379
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
@@ -167,7 +167,7 @@ export const MainNavigation = ({
disabled: isMembershipPending || isBilling,
},
{
name: t("common.analysis"),
name: t("common.dashboards"),
href: `/workspaces/${workspace.id}/dashboards`,
icon: BarChart3Icon,
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
@@ -171,15 +171,6 @@ export const OrganizationBreadcrumb = ({
href: `${workspaceBasePath}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.title"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
+18
View File
@@ -185,6 +185,24 @@ describe("transformResponseToFeedbackRecords", () => {
expect(result[0].collected_at).toBe(NOW.toISOString());
});
test("falls back to updatedAt when createdAt is missing", () => {
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].collected_at).toBe(updatedAt.toISOString());
});
test("parses string createdAt values for collected_at", () => {
const response = {
...mockResponse,
createdAt: "2026-02-26T10:00:00.000Z",
} as unknown as TResponse;
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
});
test("includes tenant_id when provided", () => {
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, "tenant-abc");
+18 -2
View File
@@ -14,6 +14,23 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
return getTextContent(raw) || "Untitled";
};
const toIsoTimestamp = (value: unknown): string | null => {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "string" || typeof value === "number") {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
return null;
};
const getCollectedAt = (response: TResponse): string => {
return toIsoTimestamp(response.createdAt) ?? toIsoTimestamp(response.updatedAt) ?? new Date().toISOString();
};
function extractResponseValue(responseData: TResponseData, elementId: string): TResponseDataValue {
if (!responseData || typeof responseData !== "object") return undefined;
return responseData[elementId];
@@ -99,8 +116,7 @@ export function transformResponseToFeedbackRecords(
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
const feedbackRecord = {
collected_at:
response.createdAt instanceof Date ? response.createdAt.toISOString() : String(response.createdAt),
collected_at: getCollectedAt(response),
source_type: "formbricks",
submission_id: response.id,
tenant_id: tenantId,
+3 -1
View File
@@ -125,6 +125,7 @@
"activity": "Activity",
"add": "Add",
"add_action": "Add action",
"add_chart": "Add chart",
"add_charts": "Add charts",
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
"add_filter": "Add filter",
@@ -1786,6 +1787,7 @@
"create_dashboard": "Create dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
"create_failed": "Failed to create dashboard",
"create_new_chart": "Create new chart",
"create_success": "Dashboard created successfully!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
@@ -1800,7 +1802,7 @@
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"failed_to_load_chart_data": "Failed to load chart data",
"no_charts_available_description": "There are no charts that can be added to this dashboard. Either no charts exist yet, or all existing charts have already been added. Go to the Charts page to create new charts.",
"no_charts_available_description": "No more charts available to add. Create a new one.",
"no_charts_to_add_message": "No charts to add to this dashboard.",
"no_dashboards_found": "No dashboards found.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
@@ -40,7 +40,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
pageTitle={t("common.dashboards")}
workspaceId={workspaceId}
cta={
isReadOnly ? undefined : <CreateChartButton workspaceId={workspaceId} directories={directories} />
@@ -4,28 +4,40 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { Button } from "@/modules/ui/components/button";
import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
workspaceId: string;
directories: { id: string; name: string }[];
label?: string;
onSuccess?: () => void;
showIcon?: boolean;
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
}
export function CreateChartButton({ workspaceId, directories }: Readonly<CreateChartButtonProps>) {
export function CreateChartButton({
workspaceId,
directories,
label,
onSuccess,
showIcon = true,
buttonProps,
}: Readonly<CreateChartButtonProps>) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button size="sm" onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.create_chart")}
<Button size="sm" onClick={() => setIsDialogOpen(true)} {...buttonProps}>
{showIcon && <PlusIcon className="mr-2 h-4 w-4" />}
{label ?? t("workspace.analysis.charts.create_chart")}
</Button>
<CreateChartDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
workspaceId={workspaceId}
directories={directories}
onSuccess={onSuccess}
/>
</>
);
@@ -1,11 +1,12 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -18,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface AddExistingChartsDialogProps {
@@ -25,6 +27,7 @@ interface AddExistingChartsDialogProps {
onOpenChange: (open: boolean) => void;
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
onSuccess: () => void;
}
@@ -39,6 +42,7 @@ export function AddExistingChartsDialog({
onOpenChange,
workspaceId,
dashboardId,
directories,
existingChartIds,
onSuccess,
}: Readonly<AddExistingChartsDialogProps>) {
@@ -48,30 +52,29 @@ export function AddExistingChartsDialog({
const [isLoading, setIsLoading] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const loadCharts = useCallback(async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
}, [workspaceId, existingChartIds, t]);
useEffect(() => {
if (!open) return;
const loadCharts = async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ workspaceId });
if (result?.data) {
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch {
toast.error(t("workspace.analysis.dashboards.charts_load_failed"));
} finally {
setIsLoading(false);
}
};
loadCharts();
}, [open, workspaceId, existingChartIds, t]);
}, [open, loadCharts]);
const handleAdd = async () => {
if (selectedChartIds.length === 0) return;
@@ -136,25 +139,40 @@ export function AddExistingChartsDialog({
</AlertDescription>
</Alert>
)}
<MultiSelect
options={chartOptions}
value={selectedChartIds}
onChange={setSelectedChartIds}
placeholder={t("common.search_charts")}
disabled={chartOptions.length === 0}
/>
<div className="space-y-2">
<Label>{t("common.add_chart")}</Label>
<MultiSelect
options={chartOptions}
value={selectedChartIds}
onChange={setSelectedChartIds}
placeholder={t("common.search_charts")}
disabled={chartOptions.length === 0}
/>
</div>
</>
)}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button onClick={handleAdd} loading={isAdding} disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
<DialogFooter className="sm:justify-between">
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
label={t("workspace.analysis.dashboards.create_new_chart")}
onSuccess={loadCharts}
buttonProps={{ variant: "outline", size: "default", disabled: isAdding }}
/>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button
onClick={handleAdd}
loading={isAdding}
disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? t("workspace.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
: t("common.add")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,6 @@
"use client";
import { CheckIcon, PencilIcon, PlusIcon, RefreshCwIcon, TrashIcon, XIcon } from "lucide-react";
import { CheckIcon, PencilIcon, RefreshCwIcon, TrashIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -8,12 +8,14 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
interface DashboardControlBarProps {
workspaceId: string;
dashboardId: string;
directories: { id: string; name: string }[];
existingChartIds: string[];
isEditing: boolean;
isSaving: boolean;
@@ -28,6 +30,7 @@ interface DashboardControlBarProps {
export const DashboardControlBar = ({
workspaceId,
dashboardId,
directories,
existingChartIds,
isEditing,
isSaving,
@@ -82,12 +85,6 @@ export const DashboardControlBar = ({
];
const viewModeActions = [
{
icon: PlusIcon,
tooltip: t("common.add_charts"),
onClick: () => setIsAddExistingDialogOpen(true),
isVisible: !isReadOnly,
},
{
icon: RefreshCwIcon,
tooltip: t("common.refresh"),
@@ -110,7 +107,12 @@ export const DashboardControlBar = ({
return (
<>
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
<div className="flex items-center gap-2">
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
{!isEditing && !isReadOnly && (
<Button onClick={() => setIsAddExistingDialogOpen(true)}>{t("common.add_chart")}</Button>
)}
</div>
<DeleteDialog
deleteWhat={t("workspace.analysis.dashboards.dashboard")}
open={isDeleteDialogOpen}
@@ -124,6 +126,7 @@ export const DashboardControlBar = ({
onOpenChange={setIsAddExistingDialogOpen}
workspaceId={workspaceId}
dashboardId={dashboardId}
directories={directories}
existingChartIds={existingChartIds}
onSuccess={() => {
setIsAddExistingDialogOpen(false);
@@ -27,6 +27,7 @@ interface DashboardDetailClientProps {
workspaceId: string;
dashboard: TDashboardDetail;
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
@@ -134,6 +135,7 @@ export function DashboardDetailClient({
workspaceId,
dashboard,
widgetDataPromises,
directories,
isReadOnly,
}: Readonly<DashboardDetailClientProps>) {
const router = useRouter();
@@ -248,6 +250,7 @@ export function DashboardDetailClient({
<DashboardControlBar
workspaceId={workspaceId}
dashboardId={dashboard.id}
directories={directories}
existingChartIds={widgets.map((w) => w.chartId)}
isEditing={isEditing}
isSaving={isSaving}
@@ -26,7 +26,7 @@ export function DashboardPageHeader({
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
className="w-full rounded-md border border-dashed border-slate-300 bg-transparent px-2 py-1 text-3xl font-bold text-slate-800 focus:border-brand-dark focus:outline-none focus:ring-0"
className="w-full rounded-md border border-dashed border-slate-300 bg-white px-2 py-1 text-3xl font-bold text-slate-800 focus:border-brand-dark focus:outline-none focus:ring-0"
aria-label={t("workspace.analysis.dashboards.dashboard_name_placeholder")}
placeholder={t("workspace.analysis.dashboards.dashboard_name_placeholder")}
/>
@@ -4,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { injectTenantFilter } from "@/modules/ee/analysis/charts/lib/chart-utils";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { DashboardDetailClient } from "../components/dashboard-detail-client";
import { getDashboard } from "../lib/dashboards";
@@ -59,12 +60,14 @@ export async function DashboardDetailPage({
widgetDataPromises.set(widgetId, Promise.resolve(result));
}
});
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
return (
<DashboardDetailClient
workspaceId={workspaceId}
dashboard={dashboard}
widgetDataPromises={widgetDataPromises}
directories={directories}
isReadOnly={isReadOnly}
/>
);
@@ -35,7 +35,7 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
pageTitle={t("common.dashboards")}
workspaceId={workspaceId}
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} />}>
<DashboardsListContent
+1 -1
View File
@@ -14,7 +14,7 @@ export const AnalysisListLoading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.analysis")}>
<PageHeader pageTitle={t("common.dashboards")}>
{workspaceId ? <AnalysisSecondaryNavigation workspaceId={workspaceId} /> : null}
</PageHeader>
<DashboardsListSkeleton
+6 -4
View File
@@ -89,10 +89,12 @@ services:
- 4001:4001 # Cube Playground UI (dev only)
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-formbricks_hub_postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-hub}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-formbricks}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-formbricks_dev}
# Default to the local postgres service in this compose stack.
# Override CUBEJS_DB_* when using an external Hub/Postgres network.
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-postgres}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}