mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 11:28:58 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6abf484d | |||
| 7c932a8583 |
+74
-17
@@ -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/)
|
||||
Reference in New Issue
Block a user