Compare commits

...

2 Commits

Author SHA1 Message Date
Johannes 2d6abf484d chore: finalize api keys, translations, and docs updates
Apply remaining admin API key UI tweaks, add matching locale copy updates, and include supporting development documentation for the question-bank workflow.

Made-with: Cursor
2026-04-24 10:41:49 +02:00
Johannes 7c932a8583 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
2026-04-24 10:40:46 +02:00
21 changed files with 781 additions and 417 deletions
+74 -17
View File
@@ -328,6 +328,7 @@
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
"not_connected": "Not Connected",
"not_set": "Not set",
"note": "Note",
"notifications": "Notifications",
"number": "Number",
@@ -366,7 +367,7 @@
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"preview_survey": "Preview survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
@@ -389,6 +390,7 @@
"report_survey": "Report Survey",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"resize": "Resize",
"response": "Response",
"response_id": "Response ID",
"responses": "Responses",
@@ -427,6 +429,7 @@
"some_files_failed_to_upload": "Some files failed to upload",
"something_went_wrong": "Something went wrong",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"soon": "Soon",
"sort_by": "Sort by",
"start_free_trial": "Start free trial",
"status": "Status",
@@ -1762,9 +1765,11 @@
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"preview_chart": "Preview chart",
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_chart": "Save Chart",
"save_and_add_to_dashboard": "Save & add to dashboard",
"save_chart": "Save chart",
"save_chart_dialog_title": "Save Chart",
"select_data_source": "Select a data source",
"select_data_source_first": "Please select a data source first",
@@ -1776,7 +1781,8 @@
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_title": "Add time-based grouping",
"time_dimension_toggle_description": "Monitor trends over time."
"time_dimension_toggle_description": "Monitor trends over time.",
"update_chart": "Update chart"
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
@@ -1807,7 +1813,9 @@
"no_dashboards_found": "No dashboards found.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
"please_enter_name": "Please enter a dashboard name"
}
},
"no_feedback_records_message": "You don't have Feedback Records to report on. Setup Feedback Sources to feed data into the system.",
"setup_feedback_source": "Setup feedback sources"
},
"api_keys": {
"add_api_key": "Add API Key",
@@ -1820,7 +1828,7 @@
"api_key_updated": "API Key updated",
"delete_api_key_confirmation": "Any applications using this key will no longer be able to access your Formbricks data.",
"duplicate_access": "Duplicate workspace access not allowed",
"no_api_keys_yet": "You do not have any API keys yet",
"no_api_keys_yet": "No API keys found. Create an API key to get started.",
"no_env_permissions_found": "No environment permissions found",
"organization_access": "Organization Access",
"organization_access_description": "Select read or write privileges for organization-wide resources.",
@@ -2506,14 +2514,14 @@
"archive_directory": "Archive Directory",
"archive_not_allowed": "You are not allowed to archive this directory.",
"are_you_sure_you_want_to_archive": "Are you sure you want to archive this directory? Workspaces will no longer have access to it.",
"assign_workspaces_description": "Control which workspaces can access this feedback record directory.",
"assign_workspaces_description": "Control which workspaces can access this directory. Each workspace can only access one directory.",
"connectors_description": "Connectors that send feedback records to this directory.",
"create_feedback_directory": "Create feedback directory",
"description": "Manage feedback record directories and their workspace assignments.",
"directory_archived_successfully": "Directory archived successfully",
"directory_created_successfully": "Directory created successfully",
"directory_id": "Directory ID",
"directory_name": "Directory Name",
"directory_name": "Directory name",
"directory_settings_description": "Manage directory name, workspace assignments, and more.",
"directory_settings_title": "{directoryName} Settings",
"directory_unarchived_successfully": "Directory unarchived successfully",
@@ -2523,13 +2531,19 @@
"error_directory_name_duplicate": "A feedback record directory with this name already exists.",
"error_directory_name_required": "Directory name is required.",
"error_directory_workspaces_invalid_org": "Some specified workspaces do not belong to this organization.",
"has_access": "Has access",
"nav_label": "Feedback Directories",
"no_access": "You do not have permission to manage feedback record directories.",
"no_connectors": "No connectors linked to this directory yet.",
"pause_connectors_confirmation_description": "{count, plural, one {1 connector will be paused because its workspace no longer has access to this directory. Continue?} other {{count} connectors will be paused because their workspaces no longer have access to this directory. Continue?}}",
"pause_connectors_confirmation_title": "Pause affected connectors?",
"select_workspaces_placeholder": "Select workspaces...",
"show_archived": "Show archived",
"title": "Feedback Record Directories",
"unarchive": "Unarchive"
"unarchive": "Unarchive",
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more workspaces are already assigned to another Feedback Directory.",
"workspace_access": "Workspace access",
"workspace_already_assigned_to_directory": "Already assigned to \"{directoryName}\""
},
"general": {
"ai_data_analysis_disabled_for_organization": "AI data analysis is disabled for this organization.",
@@ -3551,7 +3565,7 @@
"add_tag": "Add Tag",
"count": "Count",
"delete_tag_confirmation": "Are you sure you want to delete this tag?",
"manage_tags": "Manage Tags",
"manage_tags": "Manage tags",
"manage_tags_description": "Merge and remove response tags.",
"merge": "Merge",
"no_tag_found": "No tag found",
@@ -3570,15 +3584,21 @@
"team_settings_description": "See which teams can access this workspace."
},
"unify": {
"add_feedback_record": "Add feedback record",
"add_feedback_record_description": "Create a feedback record manually.",
"add_feedback_source": "Add Feedback Source",
"add_source": "Add source",
"allowed_values": "Allowed values: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_manage_api_keys": "Manage API keys",
"api_ingestion_settings_description": "Send feedback records directly to Formbricks via HTTP.",
"auto_generated": "Auto-generated",
"change_file": "Change file",
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
"click_to_upload": "Click to upload",
"collected_at": "Collected At",
"configure_import": "Configure import",
"configure_mapping": "Configure Mapping",
"configure_mapping": "Configure mapping",
"connection": "Connection",
"connector_created_successfully": "Connector created successfully",
"connector_deleted_successfully": "Connector deleted successfully",
@@ -3593,14 +3613,18 @@
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
"csv_files_only": "CSV files only",
"csv_import": "CSV Import",
"csv_import": "CSV import",
"csv_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
"csv_max_records": "Maximum {max} records allowed.",
"custom_source_type": "Custom source type",
"custom_source_type_placeholder": "Enter custom source type",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"deselect_all": "Deselect all",
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
"discard_feedback_record_changes_title": "Discard unsaved changes?",
"drop_a_field_here": "Drop a field here",
"drop_field_or": "Drop field or",
"edit_csv_mapping": "Edit CSV mapping",
@@ -3610,24 +3634,46 @@
"enum": "enum",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_record_created_successfully": "Feedback record created successfully",
"feedback_record_details": "Feedback record details",
"feedback_record_details_description": "Review and update feedback record fields.",
"feedback_record_directory": "Feedback Record Directory",
"feedback_record_fields": "Feedback Record Fields",
"feedback_record_mcp": "Feedback Record MCP",
"feedback_record_updated_successfully": "Feedback record updated successfully",
"feedback_record_value_required": "A value is required for the selected field type",
"feedback_records": "Feedback Records",
"feedback_records_refreshed": "Feedback records refreshed",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "This workspace has access to the {directoryNames} feedback directories.",
"feedback_sources_directory_access_single": "This workspace has access to the {directoryNames} feedback directory.",
"feedback_sources_settings_description": "Connect and manage the sources that feed your feedback records.",
"field_group_id": "Field Group ID",
"field_group_label": "Field Group Label",
"field_id": "Field ID",
"field_label": "Field Label",
"field_type": "Field Type",
"formbricks_surveys": "Formbricks Surveys",
"frd_cannot_be_changed": "Feedback directory cannot be changed after creation.",
"formbricks_surveys": "Formbricks survey",
"go_to_feedback_record_directories": "Go to directories settings",
"historical_import_complete": "Import complete: {successes} succeeded, {failures} failed, {skipped} skipped (no data)",
"import_csv_data": "Import feedback",
"import_feedback": "Import feedback",
"import_historical_responses": "Import historical responses",
"import_historical_responses_description": "Creates one feedback record for each answer to each question.",
"import_rows": "Import {count} rows",
"import_via_source_name": "Import via \"{sourceName}\"",
"importing_data": "Importing data...",
"importing_historical_data": "Importing historical data...",
"invalid_enum_values": "Invalid values in column mapped to {field}",
"invalid_values_found": "Found: {values} (rows: {rows}) {extra}",
"load_sample_csv": "Load sample CSV",
"manage_directories": "Manage directories",
"manage_feedback_sources": "Manage feedback sources",
"metadata": "Metadata",
"metadata_key": "Metadata key",
"metadata_read_only_entries": "Read-only metadata values (non-string)",
"metadata_value": "Metadata value",
"missing_feedback_source_title": "Missing a feedback source?",
"n_supported_questions": "{count} supported questions",
"no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
@@ -3639,15 +3685,16 @@
"question_selected": "<strong>{count}</strong> question selected. Each response to these questions will create a new Feedback Record.",
"question_type_not_supported": "This question type is not supported",
"questions_selected": "<strong>{count}</strong> questions selected. Each response to these questions will create a new Feedback Record.",
"records_will_go_to": "Records will go to",
"refresh_feedback_records": "Refresh feedback records",
"refreshing_feedback_records": "Refreshing feedback records...",
"request_feedback_source": "Request it and we will build it!",
"required": "Required",
"save_changes": "Save changes",
"select_a_survey_to_see_questions": "Select a survey to see its questions",
"select_a_value": "Select a value...",
"select_all": "Select all",
"select_feedback_record_directory": "Select a directory",
"select_feedback_record_source_type": "Select source type",
"select_questions": "Select questions",
"select_source_type_description": "Select the type of feedback source you want to connect.",
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
@@ -3656,12 +3703,14 @@
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
"set_value": "set value",
"setup_connection": "Setup connection",
"showing_count_loaded": "Showing {count} records",
"showing_count_loaded": "Showing {count} records from Feedback Directory {directoryName}",
"showing_rows": "Showing 3 of {count} rows",
"source": "source",
"source_connect_csv_description": "Import feedback from CSV files",
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
"source_connect_feedback_record_mcp_description": "Connect feedback records via the Formbricks MCP.",
"source_connect_formbricks_description": "Connect feedback from your Formbricks survey",
"source_fields": "Source Fields",
"source_id": "Source ID",
"source_name": "Source Name",
"source_type": "Source Type",
"source_type_cannot_be_changed": "Source type cannot be changed",
@@ -3670,9 +3719,13 @@
"status_completed": "Completed",
"status_draft": "Draft",
"status_error": "Error",
"status_live_sync": "Live Sync",
"status_paused": "Paused",
"status_ready": "Ready",
"submission_id": "Submission ID",
"survey_has_no_questions": "This survey has no questions",
"survey_import_line": "{surveyName}: {responseCount} responses × {questionCount} questions = {total} Feedback Records",
"topics_and_subtopics": "Topics & Subtopics",
"total_feedback_records": "Total: {checked} of {total} Feedback Records selected across {surveyCount} surveys",
"unify_feedback": "Unify Feedback",
"update_mapping_description": "Update the mapping configuration for this source.",
@@ -3680,7 +3733,11 @@
"upload_csv_data_description": "Upload a CSV file to import feedback data.",
"upload_csv_file": "Upload CSV File",
"user_identifier": "User",
"value": "Value"
"value": "Value",
"value_boolean": "Value (Boolean)",
"value_date": "Value (Date)",
"value_number": "Value (Number)",
"value_text": "Value (Text)"
},
"xm-templates": {
"ces": "CES",
@@ -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.dashboards")}
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>
);
}
@@ -9,6 +9,7 @@ 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;
@@ -18,6 +19,7 @@ interface CreateChartButtonProps {
export function CreateChartButton({
workspaceId,
directories,
autoAddToDashboardId,
label,
onSuccess,
showIcon = true,
@@ -36,6 +38,7 @@ export function CreateChartButton({
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,6 +1,7 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -8,7 +9,6 @@ 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,
@@ -47,6 +47,7 @@ export function AddExistingChartsDialog({
onSuccess,
}: Readonly<AddExistingChartsDialogProps>) {
const { t } = useTranslation();
const router = useRouter();
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -130,35 +131,30 @@ 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}
onChange={setSelectedChartIds}
placeholder={t("common.search_charts")}
disabled={chartOptions.length === 0}
/>
</div>
</>
<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 className="sm:justify-between">
<CreateChartButton
workspaceId={workspaceId}
directories={directories}
autoAddToDashboardId={dashboardId}
label={t("workspace.analysis.dashboards.create_new_chart")}
onSuccess={loadCharts}
buttonProps={{ variant: "outline", size: "default", disabled: isAdding }}
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}>
@@ -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";
@@ -115,17 +116,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>
);
@@ -144,6 +154,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);
@@ -173,6 +184,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);
@@ -299,7 +336,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>
))}
@@ -308,6 +347,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,18 +15,28 @@ 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
className={cn(
"flex h-full flex-col rounded-lg border border-gray-200 bg-white shadow-sm ring-2 ring-transparent",
isEditing && "ring-brand-dark/20 hover:ring-brand-dark/40 transition-shadow"
isEditing && "ring-brand-dark/20 transition-shadow hover:ring-brand-dark/40"
)}>
<div
className={cn(
@@ -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.dashboards")}
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));
};
@@ -160,65 +160,8 @@ export const EditAPIKeys = ({
return (
<div className="space-y-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">{t("workspace.api_keys.api_key")}</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div className="grid-cols-9">
{apiKeysLocal?.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("workspace.api_keys.no_api_keys_yet")}
</div>
) : (
apiKeysLocal?.map((apiKey) => (
<div
role="button"
className="grid h-12 w-full grid-cols-10 content-center items-center rounded-lg px-6 text-left text-sm text-slate-900 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none"
onClick={() => {
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}
}}
tabIndex={0}
data-testid="api-key-row"
key={apiKey.id}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden pr-4 sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.actualKey ?? ""} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button
size="icon"
variant="ghost"
onClick={(e) => {
handleOpenDeleteKeyModal(e, apiKey);
e.stopPropagation();
}}>
<TrashIcon />
</Button>
</div>
)}
</div>
))
)}
</div>
</div>
{!isReadOnly && (
<div>
<div className="absolute right-4 top-4">
<Button
size="sm"
onClick={() => {
@@ -228,6 +171,65 @@ export const EditAPIKeys = ({
</Button>
</div>
)}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-10 content-center border-b border-slate-200 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">{t("workspace.api_keys.api_key")}</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div>
{apiKeysLocal?.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm text-slate-400">
{t("workspace.api_keys.no_api_keys_yet")}
</div>
) : (
<div className="divide-y divide-slate-100">
{apiKeysLocal?.map((apiKey) => (
<div
role="button"
className="grid h-12 w-full grid-cols-10 content-center items-center px-6 text-left text-sm text-slate-900 transition-colors hover:bg-slate-50 focus:bg-slate-50 focus:outline-none"
onClick={() => {
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}
}}
tabIndex={0}
data-testid="api-key-row"
key={apiKey.id}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden pr-4 sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.actualKey ?? ""} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button
size="icon"
variant="ghost"
onClick={(e) => {
handleOpenDeleteKeyModal(e, apiKey);
e.stopPropagation();
}}>
<TrashIcon />
</Button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
<AddApiKeyModal
open={isAddAPIKeyModalOpen}
setOpen={setIsAddAPIKeyModalOpen}
@@ -0,0 +1,119 @@
# Question Bank - Linear Ticket Package (3 Scopes)
## Problem statement
Teams currently recreate the same survey questions across workspaces, which slows authors down, creates inconsistent wording, and makes reporting harder to standardize. We need a shared question bank at the organization level so authenticated creators can reuse high-quality questions quickly without breaking existing surveys.
## Success metrics
- Time to add a reusable question into a survey decreases by at least 50%.
- At least 40% of newly created surveys use one or more question bank entries within 60 days.
- Duplicate-question creation rate declines over time (measured by title/similarity heuristics).
- Permission failures are explicit and non-destructive (0 ambiguous auth errors in tracked flows).
## Locked auth model for all scopes
- Sharing boundary: all workspaces in the same organization.
- Publish eligibility: authenticated users with `readWrite` or `manage` in at least one workspace.
- Edit/delete: creator plus org `Owner/Manager`.
- Unpublish: org `Owner/Manager`.
- API keys: keep existing explicit workspace/org scopes (no owner-role inheritance change).
- Deprovisioned creators: ownership transfers to org admin group.
- Audit baseline: `createdBy`, `updatedBy`, timestamps.
---
## Ticket 1 - Scope 1 Core MVP
### Goal
Launch a usable organization-level question bank that supports the full core lifecycle and detached insertion into surveys.
### In scope
- Browse and insert global questions from survey creation/editing flow.
- Create and publish global questions with locked auth model.
- Edit/delete with creator + org admin override model.
- Unpublish with org admin permissions.
- Insert behavior is copy-detached (future question bank edits do not affect already inserted survey questions).
- Basic metadata in UI: title, category, creator, updated time.
- Empty state, unauthorized state, and no-results state messaging.
### Explicitly out of scope
- Approval workflows.
- Certified/locked question sets.
- Sensitive-category permission differences.
- Version history, diff, restore.
- Live-linked question sync into existing surveys.
### Acceptance criteria
- A user with `readWrite/manage` in at least one workspace can create and publish.
- A user without required workspace permission cannot publish and gets clear guidance.
- Creator can edit/delete own global questions.
- Org `Owner/Manager` can edit/delete/unpublish any global question.
- Inserting a question creates an independent survey copy that does not change on later bank edits.
- Unpublishing hides the question from new insertions but does not alter surveys that already copied it.
- All successful mutations capture attribution metadata (`createdBy`/`updatedBy` and timestamps).
### Scope 1 edge-case handling
- Creator loses workspace write access after publishing: question remains available; creator can no longer mutate unless still authorized by current rules.
- Creator is removed/deactivated: ownership is transferred to org admin group without content loss.
- Admin edits creator-owned content: latest updater attribution is visible.
- Cross-workspace discoverability remains consistent inside the same org.
---
## Ticket 2 - Scope 2 Adoption and Operational Quality
### Goal
Increase discoverability and operational reliability so the bank is easy to use at scale for both UI users and scoped API clients.
### In scope
- Search, category filtering, and sorting.
- API-key read/write support under current explicit workspace/org scope model.
- Ownership transfer flow for deprovisioned creators (admin stewardship).
- Basic moderation operations for org admins focused on unpublish/recoverability.
- Audit visibility in product UX (created by, last updated by, published status timestamps).
### Acceptance criteria
- Users can find bank questions by keyword, category, and sort order with predictable results.
- API key mutations respect explicit scope boundaries; out-of-scope writes are rejected clearly.
- Admins can view and complete ownership transfer for deprovisioned creators.
- Admin can recover previously unpublished items using a clear operational flow.
- Audit fields are visible and understandable in bank listing/detail surfaces.
### Scope 2 edge-case handling
- API key with partial workspace grants attempts org-wide mutation: request is denied with explicit reason.
- Large volume of near-duplicate questions: list remains navigable via filters and sort defaults.
- Simultaneous edits by creator and admin: user-facing result is deterministic and auditable.
---
## Ticket 3 - Scope 3 Governance and Enterprise Depth (Optional/Future)
### Goal
Introduce governance controls for organizations that need stronger quality gates and policy enforcement.
### In scope (optional controls)
- Approval gate before global publication.
- Curator workflow for elevated stewardship.
- Certified/locked question sets (or semi-locked variants).
- Sensitive-category restrictions (if compliance needs require).
- Extended versioning: history, fork, restore.
- Org policy controls for publishing standards and lifecycle rules.
### Acceptance criteria
- Governance controls are configurable per organization and can be rolled out progressively.
- Approval workflow supports clear pending/approved/rejected states.
- Certified or locked items are visibly distinct and enforce expected edit restrictions.
- Version history supports safe recovery without impacting existing detached survey copies.
- Policy changes are auditable and reversible.
### Scope 3 edge-case handling
- Approval backlog delays publication: pending state remains visible and actionable.
- Policy changes after content is already published: no retroactive corruption of survey copies.
- Governance disabled for some orgs: core flows continue without governance regressions.
---
## Prioritization rationale (Qualtrics-informed)
- Start with the minimum reusable-library primitives users expect: browse, search, insert, categorize.
- Keep insertion detached by default to prevent retroactive survey changes.
- Defer certified and strict governance controls until adoption data justifies added complexity.
Reference: [Qualtrics pre-made library questions](https://www.qualtrics.com/support/survey-platform/survey-module/editing-questions/question-types-guide/pre-made-qualtrics-library-questions/)