mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-11 19:12:06 -05:00
feat: refresh analysis charts and dashboard feedback gating
Unify chart create and edit flows, update dashboard chart interactions, and add feedback-record availability checks with dedicated empty-state handling across analysis entry points. Made-with: Cursor
This commit is contained in:
@@ -31,6 +31,7 @@ interface AddToDashboardDialogProps {
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
showChartNameField?: boolean;
|
||||
}
|
||||
|
||||
export function AddToDashboardDialog({
|
||||
@@ -43,6 +44,7 @@ export function AddToDashboardDialog({
|
||||
onDashboardSelect,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
showChartNameField = true,
|
||||
}: Readonly<AddToDashboardDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -57,17 +59,19 @@ export function AddToDashboardDialog({
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
{showChartNameField && (
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="dashboard-select">{t("workspace.analysis.charts.dashboard")}</Label>
|
||||
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||
@@ -103,7 +107,10 @@ export function AddToDashboardDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
loading={isSaving}
|
||||
disabled={!selectedDashboardId || (showChartNameField && !chartName.trim())}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface AdvancedChartBuilderProps {
|
||||
hidePreview?: boolean;
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string | null;
|
||||
runQueryCtaLabel?: string;
|
||||
}
|
||||
|
||||
const ACTION = {
|
||||
@@ -84,6 +85,7 @@ export function AdvancedChartBuilder({
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
runQueryCtaLabel,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
|
||||
@@ -151,11 +153,7 @@ export function AdvancedChartBuilder({
|
||||
return (
|
||||
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
|
||||
<div className="mx-1 space-y-2">
|
||||
{!hidePreview && (
|
||||
<>
|
||||
<ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />
|
||||
</>
|
||||
)}
|
||||
{!hidePreview && <ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
|
||||
<MeasuresPanel
|
||||
@@ -249,7 +247,11 @@ export function AdvancedChartBuilder({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
|
||||
{isLoading ? <LoadingSpinner /> : t("workspace.analysis.charts.create_chart")}
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
(runQueryCtaLabel ?? t("workspace.analysis.charts.create_chart"))
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,25 +7,31 @@ import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onAddToDashboardClick: () => void;
|
||||
onAddToDashboardClick?: () => void;
|
||||
isSaving: boolean;
|
||||
saveLabel?: string;
|
||||
showAddToDashboard?: boolean;
|
||||
}
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
saveLabel,
|
||||
showAddToDashboard = true,
|
||||
}: Readonly<ChartDialogFooterProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
{showAddToDashboard && onAddToDashboardClick && (
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.save_chart")}
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { CopyIcon, MoreVertical, PlusIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
@@ -31,6 +33,36 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!isAddToDashboardDialogOpen) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void getDashboardsAction({ workspaceId }).then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAddToDashboardDialogOpen, workspaceId]);
|
||||
|
||||
const handleDeleteChart = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -70,6 +102,37 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChartToDashboard = async () => {
|
||||
if (!selectedDashboardId) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingToDashboard(true);
|
||||
|
||||
try {
|
||||
const result = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: chart.id,
|
||||
dashboardId: selectedDashboardId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
setSelectedDashboardId(undefined);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsAddingToDashboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
@@ -102,6 +165,15 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
{t("common.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<PlusIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
setIsAddToDashboardDialogOpen(true);
|
||||
}}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
@@ -123,6 +195,23 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
text={t("workspace.analysis.charts.delete_chart_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddToDashboardDialogOpen(open);
|
||||
if (!open) {
|
||||
setSelectedDashboardId(undefined);
|
||||
}
|
||||
}}
|
||||
chartName={chart.name}
|
||||
onChartNameChange={() => {}}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddChartToDashboard}
|
||||
isSaving={isAddingToDashboard}
|
||||
showChartNameField={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list"
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import type { TChartWithCreator } 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";
|
||||
@@ -35,22 +37,35 @@ interface ChartsListPageProps {
|
||||
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const chartsPromise = getChartsWithCreator(workspaceId);
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
);
|
||||
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
workspaceId={workspaceId}
|
||||
cta={
|
||||
isReadOnly ? undefined : <CreateChartButton workspaceId={workspaceId} directories={directories} />
|
||||
isReadOnly ? undefined : (
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
buttonProps={{ disabled: !hasFeedbackRecords }}
|
||||
/>
|
||||
)
|
||||
}>
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
{hasFeedbackRecords && chartsPromise ? (
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,28 +4,43 @@ 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 }[];
|
||||
autoAddToDashboardId?: string;
|
||||
label?: string;
|
||||
onSuccess?: () => void;
|
||||
showIcon?: boolean;
|
||||
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
|
||||
}
|
||||
|
||||
export function CreateChartButton({ workspaceId, directories }: Readonly<CreateChartButtonProps>) {
|
||||
export function CreateChartButton({
|
||||
workspaceId,
|
||||
directories,
|
||||
autoAddToDashboardId,
|
||||
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}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
directories={directories}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
|
||||
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CreateChartDialogProps {
|
||||
@@ -9,6 +8,7 @@ export interface CreateChartDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
@@ -19,29 +19,19 @@ export function CreateChartDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartDialogProps>) {
|
||||
if (chartId) {
|
||||
return (
|
||||
<EditChartView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chartId}
|
||||
initialChart={initialChart}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateChartView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chartId}
|
||||
initialChart={initialChart}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
|
||||
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
|
||||
import { FrdPicker } from "@/modules/ee/feedback-record-directory/components/frd-picker";
|
||||
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -19,11 +21,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CreateChartViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
autoAddToDashboardId?: string;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
@@ -32,32 +39,39 @@ export function CreateChartView({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!chartId;
|
||||
|
||||
const {
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
handleChartTypeChange,
|
||||
handleChartGenerated,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
handleAddToDashboard,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
selectedDirectoryId,
|
||||
setSelectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({ open, onOpenChange, workspaceId, onSuccess, directories });
|
||||
} = useChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -67,96 +81,139 @@ export function CreateChartView({
|
||||
}
|
||||
}, [chartData]);
|
||||
|
||||
if (isLoadingChart && isEditing && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (isEditing && !isLoadingChart && !chartData && !initialChart && chartLoadError) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.error")}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<p className="text-sm text-red-600">{chartLoadError}</p>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const chartType = selectedChartType ?? (isEditing ? DEFAULT_CHART_TYPE : undefined);
|
||||
const hasSelectedDirectory = !!selectedDirectoryId;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide" disableCloseOnOutsideClick>
|
||||
<DialogContent
|
||||
className="max-h-[90vh] overflow-y-auto"
|
||||
width="wide"
|
||||
disableCloseOnOutsideClick={!isEditing}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.create_chart")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.create_chart_description")}</DialogDescription>
|
||||
<DialogTitle>
|
||||
{isEditing
|
||||
? t("workspace.analysis.charts.edit_chart_title")
|
||||
: t("workspace.analysis.charts.create_chart")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? t("workspace.analysis.charts.edit_chart_description")
|
||||
: t("workspace.analysis.charts.create_chart_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4">
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
|
||||
{hasSelectedDirectory && (
|
||||
{hasSelectedDirectory ? (
|
||||
<>
|
||||
<AIQuerySection
|
||||
workspaceId={workspaceId}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={handleChartTypeChange}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<AIQuerySection
|
||||
workspaceId={workspaceId}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
|
||||
{selectedChartType && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
|
||||
{chartType && (
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={selectedChartType}
|
||||
initialQuery={chartData?.query}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
runQueryCtaLabel={
|
||||
chartData
|
||||
? t("workspace.analysis.charts.update_chart")
|
||||
: t("workspace.analysis.charts.preview_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartData && (
|
||||
{(isEditing || chartData) && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} />
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
{chartData && (
|
||||
<>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={() => setIsSaveDialogOpen(true)}
|
||||
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SaveChartDialog
|
||||
open={isSaveDialogOpen}
|
||||
onOpenChange={setIsSaveDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
onSave={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
showAddToDashboard={false}
|
||||
saveLabel={
|
||||
autoAddToDashboardId
|
||||
? t("workspace.analysis.charts.save_and_add_to_dashboard")
|
||||
: t("workspace.analysis.charts.save_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
|
||||
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface EditChartViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function EditChartView({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<EditChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
handleChartTypeChange,
|
||||
handleChartGenerated,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
handleAddToDashboard,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
selectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({ open, onOpenChange, workspaceId, chartId, initialChart, onSuccess, directories });
|
||||
|
||||
if (isLoadingChart && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (!isLoadingChart && !chartData && !initialChart && chartLoadError) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.error")}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<p className="text-sm text-red-600">{chartLoadError}</p>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const chartType = selectedChartType ?? DEFAULT_CHART_TYPE;
|
||||
const directoryName = directories.find((d) => d.id === selectedDirectoryId)?.name;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.edit_chart_title")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.edit_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 px-1">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="edit-chart-name" className="text-sm">
|
||||
{t("workspace.analysis.charts.chart_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-chart-name"
|
||||
value={chartName}
|
||||
onChange={(e) => setChartName(e.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{directoryName && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.analysis.charts.data_source")}</Label>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{directoryName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
</div>
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
</DialogBody>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface UseChartDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
@@ -37,6 +38,7 @@ export function useChartDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
@@ -45,7 +47,6 @@ export function useChartDialog({
|
||||
const router = useRouter();
|
||||
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
|
||||
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [chartName, setChartName] = useState("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
@@ -54,9 +55,7 @@ export function useChartDialog({
|
||||
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
|
||||
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
|
||||
directories?.length === 1 ? directories[0].id : null
|
||||
);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -85,7 +84,7 @@ export function useChartDialog({
|
||||
setChartName("");
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,11 +158,6 @@ export function useChartDialog({
|
||||
|
||||
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||
setChartData(data);
|
||||
if (!currentChartId) {
|
||||
setChartName(
|
||||
data.chartType ? `${t("workspace.analysis.charts.chart")} ${new Date().toLocaleString()}` : ""
|
||||
);
|
||||
}
|
||||
setSelectedChartType(data.chartType);
|
||||
};
|
||||
|
||||
@@ -180,6 +174,8 @@ export function useChartDialog({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let savedChartId = currentChartId;
|
||||
|
||||
if (currentChartId) {
|
||||
const result = await updateChartAction({
|
||||
workspaceId,
|
||||
@@ -218,11 +214,32 @@ export function useChartDialog({
|
||||
}
|
||||
|
||||
setCurrentChartId(result.data.id);
|
||||
savedChartId = result.data.id;
|
||||
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
|
||||
}
|
||||
|
||||
setIsSaveDialogOpen(false);
|
||||
if (autoAddToDashboardId && savedChartId) {
|
||||
const addResult = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: savedChartId,
|
||||
dashboardId: autoAddToDashboardId,
|
||||
});
|
||||
|
||||
if (!addResult?.data) {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(addResult) ||
|
||||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
if (autoAddToDashboardId) {
|
||||
router.push(`/workspaces/${workspaceId}/dashboards/${autoAddToDashboardId}`);
|
||||
}
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
} catch (error: unknown) {
|
||||
@@ -328,7 +345,7 @@ export function useChartDialog({
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setChartLoadError(null);
|
||||
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -349,8 +366,6 @@ export function useChartDialog({
|
||||
setSelectedChartType,
|
||||
currentChartId,
|
||||
setCurrentChartId,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MessageSquareDashedIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface NoFeedbackRecordsStateProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const NoFeedbackRecordsState = async ({ workspaceId }: Readonly<NoFeedbackRecordsStateProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
|
||||
<MessageSquareDashedIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-balance text-sm text-slate-600">
|
||||
{t("workspace.analysis.no_feedback_records_message")}
|
||||
</p>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.analysis.setup_feedback_source")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -292,6 +292,9 @@ export const addChartToDashboardAction = authenticatedActionClient
|
||||
layout: parsedInput.layout,
|
||||
});
|
||||
|
||||
revalidatePath(`/workspaces/${workspaceId}/dashboards`);
|
||||
revalidatePath(`/workspaces/${workspaceId}/dashboards/${parsedInput.dashboardId}`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -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,39 +42,40 @@ export function AddExistingChartsDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
dashboardId,
|
||||
directories,
|
||||
existingChartIds,
|
||||
onSuccess,
|
||||
}: Readonly<AddExistingChartsDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
|
||||
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
|
||||
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;
|
||||
@@ -127,15 +131,8 @@ export function AddExistingChartsDialog({
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{chartOptions.length === 0 && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<AlertTitle>{t("workspace.analysis.dashboards.no_charts_to_add_message")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("workspace.analysis.dashboards.no_charts_available_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.add_chart")}</Label>
|
||||
<MultiSelect
|
||||
options={chartOptions}
|
||||
value={selectedChartIds}
|
||||
@@ -143,18 +140,35 @@ export function AddExistingChartsDialog({
|
||||
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}
|
||||
autoAddToDashboardId={dashboardId}
|
||||
label={t("workspace.analysis.dashboards.create_new_chart")}
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
onSuccess();
|
||||
}}
|
||||
buttonProps={{ variant: "secondary", 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>
|
||||
|
||||
@@ -12,9 +12,13 @@ import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateDashboardButtonProps {
|
||||
workspaceId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardButtonProps>) => {
|
||||
export const CreateDashboardButton = ({
|
||||
workspaceId,
|
||||
disabled = false,
|
||||
}: Readonly<CreateDashboardButtonProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
@@ -59,7 +63,7 @@ export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardB
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => handleOpenChange(true)}>
|
||||
<Button size="sm" onClick={() => handleOpenChange(true)} disabled={disabled}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.dashboards.create_dashboard")}
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import "react-resizable/css/styles.css";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
|
||||
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
|
||||
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
|
||||
@@ -114,17 +115,26 @@ const MemoizedWidgetItem = memo(function WidgetItem({
|
||||
widget,
|
||||
isEditing,
|
||||
dataPromise,
|
||||
onEdit,
|
||||
onResize,
|
||||
onRemove,
|
||||
}: Readonly<{
|
||||
widget: TDashboardWidget;
|
||||
isEditing: boolean;
|
||||
dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>;
|
||||
onEdit?: () => void;
|
||||
onResize?: () => void;
|
||||
onRemove?: () => void;
|
||||
}>) {
|
||||
const title = widget.chart.name;
|
||||
|
||||
return (
|
||||
<DashboardWidget title={title} isEditing={isEditing} onRemove={onRemove}>
|
||||
<DashboardWidget
|
||||
title={title}
|
||||
isEditing={isEditing}
|
||||
onEdit={onEdit}
|
||||
onResize={onResize}
|
||||
onRemove={onRemove}>
|
||||
<MemoizedWidgetContent widget={widget} dataPromise={dataPromise} />
|
||||
</DashboardWidget>
|
||||
);
|
||||
@@ -142,6 +152,7 @@ export function DashboardDetailClient({
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [name, setName] = useState(dashboard.name);
|
||||
@@ -171,6 +182,32 @@ export function DashboardDetailClient({
|
||||
[dashboard.widgets]
|
||||
);
|
||||
|
||||
const handleEnterEditMode = useCallback(() => {
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftWidgets((current) => current ?? dashboard.widgets);
|
||||
setIsEditing(true);
|
||||
}, [dashboard.widgets, isEditing]);
|
||||
|
||||
const handleEditChart = useCallback((chartId: string) => {
|
||||
setEditingChartId(chartId);
|
||||
}, []);
|
||||
|
||||
const handleRemoveWidgetFromMenu = useCallback(
|
||||
(widgetId: string) => {
|
||||
if (!isEditing) {
|
||||
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemoveWidget(widgetId);
|
||||
},
|
||||
[dashboard.widgets, handleRemoveWidget, isEditing]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName(dashboard.name);
|
||||
setDraftWidgets(null);
|
||||
@@ -296,7 +333,9 @@ export function DashboardDetailClient({
|
||||
widget={widget}
|
||||
isEditing={isEditing}
|
||||
dataPromise={widgetDataPromises.get(widget.id)}
|
||||
onRemove={isEditing ? () => handleRemoveWidget(widget.id) : undefined}
|
||||
onEdit={isReadOnly ? undefined : () => handleEditChart(widget.chartId)}
|
||||
onResize={isReadOnly ? undefined : handleEnterEditMode}
|
||||
onRemove={isReadOnly ? undefined : () => handleRemoveWidgetFromMenu(widget.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -305,6 +344,23 @@ export function DashboardDetailClient({
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{!isReadOnly && (
|
||||
<CreateChartDialog
|
||||
open={editingChartId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingChartId(null);
|
||||
}
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
chartId={editingChartId ?? undefined}
|
||||
onSuccess={() => {
|
||||
setEditingChartId(null);
|
||||
router.refresh();
|
||||
}}
|
||||
directories={directories}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { Maximize2Icon, MoreVerticalIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -15,12 +15,22 @@ interface DashboardWidgetProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
isEditing?: boolean;
|
||||
onEdit?: () => void;
|
||||
onResize?: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardWidget({ title, children, isEditing, onRemove }: Readonly<DashboardWidgetProps>) {
|
||||
export function DashboardWidget({
|
||||
title,
|
||||
children,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onResize,
|
||||
onRemove,
|
||||
}: Readonly<DashboardWidgetProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const hasMenuActions = Boolean(onEdit || onResize || onRemove);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -34,7 +44,7 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
|
||||
isEditing && "rgl-drag-handle cursor-grab active:cursor-grabbing"
|
||||
)}>
|
||||
<h3 className="flex-1 truncate text-sm font-semibold text-gray-800">{title}</h3>
|
||||
{onRemove && (
|
||||
{hasMenuActions && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -47,15 +57,37 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600">
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.remove")}
|
||||
</DropdownMenuItem>
|
||||
{onEdit && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
<SquarePenIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onResize && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onResize();
|
||||
}}>
|
||||
<Maximize2Icon className="mr-2 h-4 w-4" />
|
||||
{t("common.resize")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRemove && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600">
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.remove")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { CreateDashboardButton } from "../components/create-dashboard-button";
|
||||
@@ -31,18 +33,27 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
const dashboardsPromise = getDashboards(workspaceId);
|
||||
const hasFeedbackRecords = await hasWorkspaceFeedbackRecords(workspaceId);
|
||||
const dashboardsPromise = hasFeedbackRecords ? getDashboards(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
workspaceId={workspaceId}
|
||||
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} />}>
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
cta={
|
||||
isReadOnly ? undefined : (
|
||||
<CreateDashboardButton workspaceId={workspaceId} disabled={!hasFeedbackRecords} />
|
||||
)
|
||||
}>
|
||||
{hasFeedbackRecords && dashboardsPromise ? (
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"server-only";
|
||||
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
|
||||
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
|
||||
if (directoryIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
|
||||
);
|
||||
|
||||
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
|
||||
if (hasRecords) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasErrors = results.some((result) => Boolean(result.error));
|
||||
|
||||
// Do not lock creation flows when record availability is unknown.
|
||||
return hasErrors;
|
||||
};
|
||||
|
||||
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
|
||||
return hasFeedbackRecordsInDirectories(directories.map((directory) => directory.id));
|
||||
};
|
||||
Reference in New Issue
Block a user