mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-09 02:28:38 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddeef4096f | |||
| 9fe4678c47 | |||
| 49acc1cbb8 | |||
| 6942502baf | |||
| a4bd217761 | |||
| fee770358c | |||
| 44f8f80cac | |||
| 858a7f7aa9 | |||
| ac40b90e81 | |||
| aa21b4e442 | |||
| fa72296de5 | |||
| 3776b31794 | |||
| 5c7ea33fb0 | |||
| 33f60ce2be | |||
| c0386cea5a | |||
| 7cea53130c | |||
| 0636989d67 | |||
| e29300df2c | |||
| 219883266c | |||
| 55fc2b2bc8 | |||
| 6e4ef9a099 | |||
| ebf7d1e3a1 | |||
| 998162bc48 | |||
| 4fadc54b4e | |||
| f4ac9a8292 | |||
| 7c8a7606b7 | |||
| 225217330b | |||
| 589c04a530 | |||
| aa538a3a51 | |||
| 817e108ff5 |
@@ -229,24 +229,5 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
|
||||
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
|
||||
# CUBEJS_DB_HOST=formbricks_hub_postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=hub
|
||||
# CUBEJS_DB_USER=formbricks
|
||||
# CUBEJS_DB_PASS=formbricks_dev
|
||||
#
|
||||
# Alternative (when not on same Docker network): host.docker.internal and port 5433
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <ChartsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
const DashboardDetailPage = async (props: Readonly<{ params: Promise<{ dashboardId: string }> }>) => {
|
||||
const { dashboardId } = await props.params;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||
Dashboard detail for {dashboardId} will appear here.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardDetailPage;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <DashboardsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||
};
|
||||
|
||||
export default AnalysisPage;
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChartBar,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
@@ -115,13 +114,6 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/environments/${environment.id}/analysis`,
|
||||
icon: ChartBar,
|
||||
isActive: pathname?.includes("/analysis"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
@@ -196,7 +188,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
+1
-1
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
@@ -56,6 +56,7 @@ export const GET = withV1ApiWrapper({
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
redirect_uri: SLACK_REDIRECT_URI,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
|
||||
@@ -106,7 +106,6 @@ checksums:
|
||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||
@@ -123,8 +122,6 @@ checksums:
|
||||
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
|
||||
common/cancel: 2e2a849c2223911717de8caa2c71bade
|
||||
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
|
||||
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
|
||||
common/charts: 1da4564d89264c89de4ed28d7451b43e
|
||||
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
|
||||
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
|
||||
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
|
||||
@@ -154,7 +151,6 @@ checksums:
|
||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
||||
common/create: 757ccd28dd533ff3a933355273c1e32a
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -164,8 +160,6 @@ checksums:
|
||||
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
|
||||
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
@@ -204,7 +198,6 @@ checksums:
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
@@ -287,7 +280,6 @@ checksums:
|
||||
common/on: 1929bcf2fba8003c043b446a851bcb4f
|
||||
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
|
||||
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
|
||||
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
|
||||
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
|
||||
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
|
||||
common/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
@@ -587,35 +579,6 @@ checksums:
|
||||
environments/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
|
||||
environments/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
|
||||
environments/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
|
||||
environments/analysis/charts/action_coming_soon: ee2b0671e00972773210c5be5a9ccb89
|
||||
environments/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
|
||||
environments/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
|
||||
environments/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
|
||||
environments/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
|
||||
environments/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
|
||||
environments/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
|
||||
environments/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
|
||||
environments/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
|
||||
environments/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
|
||||
environments/analysis/charts/create_chart: ca7fdcc964e01f42ea9709924221edba
|
||||
environments/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
|
||||
environments/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
|
||||
environments/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
|
||||
environments/analysis/dashboards/create_dashboard: 9396aec1ea4a9b05ada94483655d1373
|
||||
environments/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
|
||||
environments/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
|
||||
environments/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
|
||||
environments/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
|
||||
environments/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
|
||||
environments/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
|
||||
environments/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
|
||||
environments/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
|
||||
environments/analysis/dashboards/description_optional: d5519551a79f18fc414dc127b773485f
|
||||
environments/analysis/dashboards/description_placeholder: 90a599e6b1695e2b026fb1300d1d5903
|
||||
environments/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
|
||||
environments/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
|
||||
environments/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
|
||||
environments/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
|
||||
environments/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
|
||||
environments/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
|
||||
environments/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
|
||||
|
||||
@@ -63,7 +63,8 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
|
||||
|
||||
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
|
||||
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
|
||||
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
|
||||
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
|
||||
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
|
||||
|
||||
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
|
||||
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
|
||||
@@ -22,9 +22,6 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
};
|
||||
|
||||
export type ActionClientCtx = {
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
|
||||
"analysis": "Analyse",
|
||||
"and": "und",
|
||||
"and_response_limit_of": "und Antwortlimit von",
|
||||
"anonymous": "Anonym",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Unten rechts",
|
||||
"cancel": "Abbrechen",
|
||||
"centered_modal": "Zentriertes Modalfenster",
|
||||
"chart": "Diagramm",
|
||||
"charts": "Diagramme",
|
||||
"choices": "Entscheidungen",
|
||||
"choose_environment": "Umgebung auswählen",
|
||||
"choose_organization": "Organisation auswählen",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
|
||||
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
|
||||
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
|
||||
"create": "Erstellen",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
"create_survey": "Umfrage erstellen",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Erstellt von",
|
||||
"customer_success": "Kundenerfolg",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Datum",
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "An",
|
||||
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
"open_options": "Optionen öffnen",
|
||||
"option_id": "Option-ID",
|
||||
"option_ids": "Option-IDs",
|
||||
"optional": "Optional",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
|
||||
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Kommt bald",
|
||||
"chart_deleted_successfully": "Diagramm erfolgreich gelöscht",
|
||||
"chart_deletion_error": "Diagramm konnte nicht gelöscht werden",
|
||||
"chart_duplicated_successfully": "Diagramm erfolgreich dupliziert",
|
||||
"chart_duplication_error": "Diagramm konnte nicht dupliziert werden",
|
||||
"chart_type_area": "Flächendiagramm",
|
||||
"chart_type_bar": "Balkendiagramm",
|
||||
"chart_type_big_number": "Große Zahl",
|
||||
"chart_type_line": "Liniendiagramm",
|
||||
"chart_type_pie": "Kreisdiagramm",
|
||||
"create_chart": "Diagramm erstellen",
|
||||
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
|
||||
"no_charts_found": "Keine Diagramme gefunden.",
|
||||
"open_options": "Diagrammoptionen öffnen"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Dashboard erstellen",
|
||||
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
|
||||
"create_failed": "Dashboard konnte nicht erstellt werden",
|
||||
"create_success": "Dashboard erfolgreich erstellt!",
|
||||
"dashboard_name": "Dashboard-Name",
|
||||
"dashboard_name_placeholder": "Mein Dashboard",
|
||||
"delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_failed": "Dashboard konnte nicht gelöscht werden",
|
||||
"delete_success": "Dashboard erfolgreich gelöscht",
|
||||
"description_optional": "Beschreibung (optional)",
|
||||
"description_placeholder": "Dashboard-Beschreibung",
|
||||
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
|
||||
"duplicate_success": "Dashboard erfolgreich dupliziert!",
|
||||
"no_dashboards_found": "Keine Dashboards gefunden.",
|
||||
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Glückwunsch!",
|
||||
"connection_successful_message": "Gut gemacht! Wir sind verbunden.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||
"analysis": "Analysis",
|
||||
"and": "And",
|
||||
"and_response_limit_of": "and response limit of",
|
||||
"anonymous": "Anonymous",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Bottom Right",
|
||||
"cancel": "Cancel",
|
||||
"centered_modal": "Centered Modal",
|
||||
"chart": "Chart",
|
||||
"charts": "Charts",
|
||||
"choices": "Choices",
|
||||
"choose_environment": "Choose environment",
|
||||
"choose_organization": "Choose organization",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||
"create": "Create",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_segment": "Create segment",
|
||||
"create_survey": "Create survey",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Created by",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
@@ -245,7 +239,6 @@
|
||||
"hidden": "Hidden",
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide": "Hide",
|
||||
"hide_column": "Hide column",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
@@ -315,7 +308,6 @@
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
"open_options": "Open options",
|
||||
"option_id": "Option ID",
|
||||
"option_ids": "Option IDs",
|
||||
"optional": "Optional",
|
||||
@@ -458,7 +450,6 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Verified Email",
|
||||
"video": "Video",
|
||||
"view": "View",
|
||||
"warning": "Warning",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
|
||||
"webhook": "Webhook",
|
||||
@@ -622,159 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
|
||||
"your_survey_would_not_be_shown": "Your survey would not be shown."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"AND": "AND",
|
||||
"OR": "OR",
|
||||
"add_chart_to_dashboard": "Add Chart to Dashboard",
|
||||
"add_chart_to_dashboard_description": "Select a dashboard to add this chart to. The chart will be saved automatically.",
|
||||
"add_custom_measure": "Add Custom Measure",
|
||||
"add_filter": "Add filter",
|
||||
"add_to_dashboard": "Add to Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
|
||||
"ai_query_placeholder": "e.g. How many users signed up last week?",
|
||||
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
|
||||
"ai_query_section_title": "Ask your data",
|
||||
"alias_optional": "Alias (optional)",
|
||||
"apply_changes": "Apply Changes",
|
||||
"chart": "Chart",
|
||||
"chart_added_to_dashboard": "Chart added to dashboard!",
|
||||
"chart_builder_choose_chart_type": "Choose chart type",
|
||||
"chart_data": "Chart Data",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Chart deleted successfully",
|
||||
"chart_duplicated_successfully": "Chart duplicated successfully",
|
||||
"chart_duplication_error": "Failed to duplicate chart",
|
||||
"chart_name": "Chart Name",
|
||||
"chart_name_placeholder": "Chart name",
|
||||
"chart_preview": "Chart Preview",
|
||||
"chart_saved_successfully": "Chart saved successfully!",
|
||||
"chart_type_area": "Area Chart",
|
||||
"chart_type_bar": "Bar Chart",
|
||||
"chart_type_big_number": "Big Number",
|
||||
"chart_type_donut": "Donut Chart",
|
||||
"chart_type_line": "Line Chart",
|
||||
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
|
||||
"chart_type_pie": "Pie Chart",
|
||||
"chart_updated_successfully": "Chart updated successfully!",
|
||||
"configure_description": "Modify the chart type and other settings for this visualization.",
|
||||
"configure_title": "Configure Chart",
|
||||
"configure_type_label": "Chart Type",
|
||||
"contains": "contains",
|
||||
"create_chart": "Create Chart",
|
||||
"create_chart_description": "Use AI to generate a chart or build one manually.",
|
||||
"cube_js_query": "Cube.js Query",
|
||||
"custom_aggregations": "Custom Aggregations",
|
||||
"custom_aggregations_toggle_description": "Define custom metrics using aggregations (avg, sum, min, max, etc.) on numeric dimension fields.",
|
||||
"custom_range": "Custom Range",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "Select a dashboard",
|
||||
"data_label": "Data",
|
||||
"date_range": "Date Range",
|
||||
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
|
||||
"dimensions": "Dimensions",
|
||||
"dimensions_toggle_description": "Group data by categories. Order matters for multi-dimensional charts.",
|
||||
"edit_chart_description": "View and edit your chart configuration.",
|
||||
"edit_chart_title": "Edit Chart",
|
||||
"enable_time_dimension": "Enable Time Dimension",
|
||||
"end_date": "End date",
|
||||
"enter_a_name_for_your_chart": "Enter a name for your chart to save it.",
|
||||
"enter_value": "Enter value",
|
||||
"equals": "equals",
|
||||
"failed_to_add_chart_to_dashboard": "Failed to add chart to dashboard",
|
||||
"failed_to_execute_query": "Failed to execute query",
|
||||
"failed_to_load_chart": "Failed to load chart",
|
||||
"failed_to_load_chart_data": "Failed to load chart data",
|
||||
"failed_to_save_chart": "Failed to save chart",
|
||||
"field": "Field",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Only include data that meets the following conditions.",
|
||||
"generating_chart": "Generating chart...",
|
||||
"granularity": "Granularity",
|
||||
"greater_than": "greater than",
|
||||
"greater_than_or_equal": "greater than or equal",
|
||||
"group_by": "Group By",
|
||||
"group_by_description": "Select dimensions to break down your data. The order matters for multi-dimensional charts.",
|
||||
"guide_button": "View field guide",
|
||||
"guide_chart_type": "Chart type",
|
||||
"guide_chart_type_desc": "How the data is visualized: Area, Bar, Line, Pie, or Big Number. Choose based on what you want to show (trends, comparisons, parts of a whole, etc.).",
|
||||
"guide_dimensions": "Dimensions (Group By)",
|
||||
"guide_dimensions_desc": "How you split or group the data. Each dimension becomes a category on the chart (e.g. Sentiment, Source Type, Survey Name, Channel, Topic). Order matters for multi-dimensional charts.",
|
||||
"guide_filters": "Filters",
|
||||
"guide_filters_desc": "Conditions that limit which data is included. Each filter has a field, operator (equals, contains, greater than, etc.), and values. And = all must match; Or = any can match.",
|
||||
"guide_measures": "Measures (what you count or aggregate)",
|
||||
"guide_measures_custom": "Custom aggregations let you define your own metrics: pick a numeric field (e.g. Rating, NPS Value) and an aggregation (count, countDistinct, sum, avg, min, max). Alias is an optional label for the chart.",
|
||||
"guide_measures_predefined": "Predefined measures are pre-built metrics from your feedback data: Count (total responses), Promoter/Detractor/Passive Count (NPS segments), NPS Score, Average Score, Completion Rate.",
|
||||
"guide_quick_ref": "Quick reference",
|
||||
"guide_term_custom": "Measure you define: field + aggregation (avg, sum, etc.)",
|
||||
"guide_term_dimension": "Categorical field used to group or split data",
|
||||
"guide_term_filter": "Condition that limits which rows are included",
|
||||
"guide_term_measure": "Numeric value you aggregate (count, sum, avg, etc.)",
|
||||
"guide_term_time": "Time-based grouping with granularity and date range",
|
||||
"guide_time_dimension": "Time dimension",
|
||||
"guide_time_dimension_desc": "Time-based grouping: pick a time field (usually Collected At), granularity (Hour, Day, Week, Month, etc.), and date range (preset or custom). Use for trends over time.",
|
||||
"guide_title": "Chart Builder Field Guide",
|
||||
"is_not_set": "is not set",
|
||||
"is_set": "is set",
|
||||
"less_than": "less than",
|
||||
"less_than_or_equal": "less than or equal",
|
||||
"measures": "Measures",
|
||||
"measures_toggle_description": "Select predefined or custom metrics to display in the chart.",
|
||||
"no_charts_found": "No charts found.",
|
||||
"no_dashboards_available": "No dashboards available",
|
||||
"no_dashboards_create_first": "Create a dashboard first to add charts to it.",
|
||||
"no_data_available": "No data available",
|
||||
"no_data_returned": "No data returned from query",
|
||||
"no_data_returned_for_chart": "No data returned for chart",
|
||||
"no_grouping": "None (filter only)",
|
||||
"no_valid_data_to_display": "No valid data to display",
|
||||
"not_contains": "not contains",
|
||||
"not_equals": "not equals",
|
||||
"open_chart": "Open chart {{name}}",
|
||||
"open_options": "Open chart options",
|
||||
"or_filter_logic": "OR",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Please enter a chart name",
|
||||
"please_run_query_first": "Please run a query first",
|
||||
"please_select_at_least_one_measure": "Please select at least one measure",
|
||||
"please_select_chart_type": "Please select a chart type",
|
||||
"please_select_dashboard": "Please select a dashboard",
|
||||
"predefined_measures": "Predefined Measures",
|
||||
"preset": "Preset",
|
||||
"query_executed_successfully": "Query executed successfully",
|
||||
"query_label": "Query",
|
||||
"reset_to_ai_suggestion": "Reset to AI suggestion",
|
||||
"run_query": "Run Query",
|
||||
"save_chart": "Save Chart",
|
||||
"save_chart_dialog_title": "Save Chart",
|
||||
"select_field": "Select field",
|
||||
"select_measures": "Select measures...",
|
||||
"select_preset": "Select preset",
|
||||
"showing_first_10_of": "Showing first 10 of {count} rows",
|
||||
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
|
||||
"start_date": "Start date",
|
||||
"time_dimension": "Time Dimension",
|
||||
"time_dimension_toggle_description": "Add time-based grouping for trends over time.",
|
||||
"unable_to_determine_chart_data_structure": "Unable to determine chart data structure"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Create Dashboard",
|
||||
"create_dashboard_description": "Enter a name for your new dashboard.",
|
||||
"create_failed": "Failed to create dashboard",
|
||||
"create_success": "Dashboard created successfully!",
|
||||
"dashboard_name": "Dashboard Name",
|
||||
"dashboard_name_placeholder": "My dashboard",
|
||||
"delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
|
||||
"delete_failed": "Failed to delete dashboard",
|
||||
"delete_success": "Dashboard deleted successfully",
|
||||
"description_optional": "Description (Optional)",
|
||||
"description_placeholder": "Dashboard description",
|
||||
"duplicate_failed": "Failed to duplicate dashboard",
|
||||
"duplicate_success": "Dashboard duplicated successfully!",
|
||||
"no_dashboards_found": "No dashboards found.",
|
||||
"please_enter_name": "Please enter a dashboard name"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Congrats!",
|
||||
"connection_successful_message": "Well done! We are connected.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||
"analysis": "Análisis",
|
||||
"and": "Y",
|
||||
"and_response_limit_of": "y límite de respuesta de",
|
||||
"anonymous": "Anónimo",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Inferior derecha",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal centrado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Opciones",
|
||||
"choose_environment": "Elegir entorno",
|
||||
"choose_organization": "Elegir organización",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
|
||||
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
|
||||
"create": "Crear",
|
||||
"create_new_organization": "Crear organización nueva",
|
||||
"create_segment": "Crear segmento",
|
||||
"create_survey": "Crear encuesta",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Creado por",
|
||||
"customer_success": "Éxito del cliente",
|
||||
"dark_overlay": "Superposición oscura",
|
||||
"dashboard": "Panel de control",
|
||||
"dashboards": "Paneles",
|
||||
"date": "Fecha",
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "Activado",
|
||||
"only_one_file_allowed": "Solo se permite un archivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
|
||||
"open_options": "Abrir opciones",
|
||||
"option_id": "ID de opción",
|
||||
"option_ids": "IDs de opciones",
|
||||
"optional": "Opcional",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
|
||||
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Próximamente",
|
||||
"chart_deleted_successfully": "Gráfico eliminado correctamente",
|
||||
"chart_deletion_error": "Error al eliminar el gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
|
||||
"chart_duplication_error": "Error al duplicar el gráfico",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de líneas",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"create_chart": "Crear gráfico",
|
||||
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
|
||||
"no_charts_found": "No se encontraron gráficos.",
|
||||
"open_options": "Abrir opciones del gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Crear panel de control",
|
||||
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
|
||||
"create_failed": "Error al crear el panel de control",
|
||||
"create_success": "Panel de control creado correctamente",
|
||||
"dashboard_name": "Nombre del panel de control",
|
||||
"dashboard_name_placeholder": "Mi panel de control",
|
||||
"delete_confirmation": "¿Estás seguro de que quieres eliminar este panel de control? Esta acción no se puede deshacer.",
|
||||
"delete_failed": "Error al eliminar el panel de control",
|
||||
"delete_success": "Panel de control eliminado correctamente",
|
||||
"description_optional": "Descripción (opcional)",
|
||||
"description_placeholder": "Descripción del panel de control",
|
||||
"duplicate_failed": "Error al duplicar el panel de control",
|
||||
"duplicate_success": "Panel de control duplicado correctamente",
|
||||
"no_dashboards_found": "No se han encontrado paneles de control.",
|
||||
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "¡Enhorabuena!",
|
||||
"connection_successful_message": "¡Bien hecho! Estamos conectados.",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
|
||||
},
|
||||
"common": {
|
||||
"Filter": "Filtrer",
|
||||
"accepted": "Accepté",
|
||||
"account": "Compte",
|
||||
"account_settings": "Paramètres du compte",
|
||||
@@ -133,7 +134,6 @@
|
||||
"allow": "Autoriser",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "Et",
|
||||
"and_response_limit_of": "et limite de réponse de",
|
||||
"anonymous": "Anonyme",
|
||||
@@ -150,8 +150,6 @@
|
||||
"bottom_right": "En bas à droite",
|
||||
"cancel": "Annuler",
|
||||
"centered_modal": "Au centre",
|
||||
"chart": "Graphique",
|
||||
"charts": "Graphiques",
|
||||
"choices": "Choix",
|
||||
"choose_environment": "Choisir l'environnement",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
@@ -181,7 +179,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
|
||||
"count_responses": "{value, plural, other {# réponses}}",
|
||||
"create": "Créer",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_segment": "Créer un segment",
|
||||
"create_survey": "Créer un sondage",
|
||||
@@ -191,8 +188,6 @@
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboards": "Tableaux de bord",
|
||||
"date": "Date",
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
@@ -314,7 +309,6 @@
|
||||
"on": "Sur",
|
||||
"only_one_file_allowed": "Un seul fichier est autorisé",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"open_options": "Ouvrir les options",
|
||||
"option_id": "Identifiant de l'option",
|
||||
"option_ids": "Identifiants des options",
|
||||
"optional": "Facultatif",
|
||||
@@ -620,41 +614,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
|
||||
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "À venir bientôt",
|
||||
"chart_deleted_successfully": "Graphique supprimé avec succès",
|
||||
"chart_deletion_error": "Échec de la suppression du graphique",
|
||||
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
|
||||
"chart_duplication_error": "Échec de la duplication du graphique",
|
||||
"chart_type_area": "Graphique en aires",
|
||||
"chart_type_bar": "Graphique à barres",
|
||||
"chart_type_big_number": "Grand nombre",
|
||||
"chart_type_line": "Graphique linéaire",
|
||||
"chart_type_pie": "Graphique circulaire",
|
||||
"create_chart": "Créer un graphique",
|
||||
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique ?",
|
||||
"no_charts_found": "Aucun graphique trouvé.",
|
||||
"open_options": "Ouvrir les options du graphique"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Créer un tableau de bord",
|
||||
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
|
||||
"create_failed": "Échec de la création du tableau de bord",
|
||||
"create_success": "Tableau de bord créé avec succès !",
|
||||
"dashboard_name": "Nom du tableau de bord",
|
||||
"dashboard_name_placeholder": "Mon tableau de bord",
|
||||
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
|
||||
"delete_failed": "Échec de la suppression du tableau de bord",
|
||||
"delete_success": "Tableau de bord supprimé avec succès",
|
||||
"description_optional": "Description (facultatif)",
|
||||
"description_placeholder": "Description du tableau de bord",
|
||||
"duplicate_failed": "Échec de la duplication du tableau de bord",
|
||||
"duplicate_success": "Tableau de bord dupliqué avec succès !",
|
||||
"no_dashboards_found": "Aucun tableau de bord trouvé.",
|
||||
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Félicitations !",
|
||||
"connection_successful_message": "Bien joué ! Nous sommes connectés.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Engedélyezés",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
|
||||
"analysis": "Elemzés",
|
||||
"and": "És",
|
||||
"and_response_limit_of": "és kérdéskorlátja ennek:",
|
||||
"anonymous": "Névtelen",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Jobbra lent",
|
||||
"cancel": "Mégse",
|
||||
"centered_modal": "Középre helyezett kizárólagos",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagramok",
|
||||
"choices": "Választási lehetőségek",
|
||||
"choose_environment": "Környezet kiválasztása",
|
||||
"choose_organization": "Szervezet kiválasztása",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
|
||||
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
|
||||
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
|
||||
"create": "Létrehozás",
|
||||
"create_new_organization": "Új szervezet létrehozása",
|
||||
"create_segment": "Szakasz létrehozása",
|
||||
"create_survey": "Kérdőív létrehozása",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Létrehozta",
|
||||
"customer_success": "Ügyfélsiker",
|
||||
"dark_overlay": "Sötét rávetítés",
|
||||
"dashboard": "Vezérlőpult",
|
||||
"dashboards": "Irányítópultok",
|
||||
"date": "Dátum",
|
||||
"days": "napok",
|
||||
"default": "Alapértelmezett",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "Be",
|
||||
"only_one_file_allowed": "Csak egy fájl engedélyezett",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
|
||||
"open_options": "Beállítások megnyitása",
|
||||
"option_id": "Választásazonosító",
|
||||
"option_ids": "Választásazonosítók",
|
||||
"optional": "Elhagyható",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
|
||||
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Hamarosan",
|
||||
"chart_deleted_successfully": "A diagram sikeresen törölve",
|
||||
"chart_deletion_error": "A diagram törlése sikertelen",
|
||||
"chart_duplicated_successfully": "A diagram sikeresen duplikálva",
|
||||
"chart_duplication_error": "A diagram duplikálása sikertelen",
|
||||
"chart_type_area": "Területdiagram",
|
||||
"chart_type_bar": "Oszlopdiagram",
|
||||
"chart_type_big_number": "Nagy szám",
|
||||
"chart_type_line": "Vonaldiagram",
|
||||
"chart_type_pie": "Kördiagram",
|
||||
"create_chart": "Diagram létrehozása",
|
||||
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
|
||||
"no_charts_found": "Nem található diagram.",
|
||||
"open_options": "Diagram beállításainak megnyitása"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Vezérlőpult létrehozása",
|
||||
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
|
||||
"create_failed": "A vezérlőpult létrehozása sikertelen",
|
||||
"create_success": "A vezérlőpult sikeresen létrehozva!",
|
||||
"dashboard_name": "Vezérlőpult neve",
|
||||
"dashboard_name_placeholder": "Saját vezérlőpult",
|
||||
"delete_confirmation": "Biztosan törölni szeretné ezt a vezérlőpultot? Ez a művelet nem vonható vissza.",
|
||||
"delete_failed": "A vezérlőpult törlése sikertelen",
|
||||
"delete_success": "A vezérlőpult sikeresen törölve",
|
||||
"description_optional": "Leírás (opcionális)",
|
||||
"description_placeholder": "Vezérlőpult leírása",
|
||||
"duplicate_failed": "A vezérlőpult másolása sikertelen",
|
||||
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
|
||||
"no_dashboards_found": "Nem található vezérlőpult.",
|
||||
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Gratulálunk!",
|
||||
"connection_successful_message": "Szép munka! Kapcsolódtunk.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "許可",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
|
||||
"analysis": "分析",
|
||||
"and": "および",
|
||||
"and_response_limit_of": "と回答数の上限",
|
||||
"anonymous": "匿名",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "キャンセル",
|
||||
"centered_modal": "中央モーダル",
|
||||
"chart": "チャート",
|
||||
"charts": "チャート",
|
||||
"choices": "選択肢",
|
||||
"choose_environment": "環境を選択",
|
||||
"choose_organization": "組織を選択",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, other {{value}個の属性}}",
|
||||
"count_contacts": "{count, plural, other {# 件の連絡先}}",
|
||||
"count_responses": "{count, plural, other {# 件の回答}}",
|
||||
"create": "作成",
|
||||
"create_new_organization": "新しい組織を作成",
|
||||
"create_segment": "セグメントを作成",
|
||||
"create_survey": "フォームを作成",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "作成者",
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboards": "ダッシュボード",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "オン",
|
||||
"only_one_file_allowed": "ファイルは1つのみ許可されています",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
|
||||
"open_options": "オプションを開く",
|
||||
"option_id": "オプションID",
|
||||
"option_ids": "オプションID",
|
||||
"optional": "任意",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
|
||||
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "近日公開",
|
||||
"chart_deleted_successfully": "チャートを削除しました",
|
||||
"chart_deletion_error": "チャートの削除に失敗しました",
|
||||
"chart_duplicated_successfully": "チャートを複製しました",
|
||||
"chart_duplication_error": "チャートの複製に失敗しました",
|
||||
"chart_type_area": "エリアチャート",
|
||||
"chart_type_bar": "棒グラフ",
|
||||
"chart_type_big_number": "大きな数値",
|
||||
"chart_type_line": "折れ線グラフ",
|
||||
"chart_type_pie": "円グラフ",
|
||||
"create_chart": "チャートを作成",
|
||||
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
|
||||
"no_charts_found": "チャートが見つかりません。",
|
||||
"open_options": "チャートオプションを開く"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "ダッシュボードを作成",
|
||||
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
|
||||
"create_failed": "ダッシュボードの作成に失敗しました",
|
||||
"create_success": "ダッシュボードを正常に作成しました!",
|
||||
"dashboard_name": "ダッシュボード名",
|
||||
"dashboard_name_placeholder": "マイダッシュボード",
|
||||
"delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"delete_failed": "ダッシュボードの削除に失敗しました",
|
||||
"delete_success": "ダッシュボードを正常に削除しました",
|
||||
"description_optional": "説明(任意)",
|
||||
"description_placeholder": "ダッシュボードの説明",
|
||||
"duplicate_failed": "ダッシュボードの複製に失敗しました",
|
||||
"duplicate_success": "ダッシュボードを正常に複製しました!",
|
||||
"no_dashboards_found": "ダッシュボードが見つかりません。",
|
||||
"please_enter_name": "ダッシュボード名を入力してください"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "おめでとうございます!",
|
||||
"connection_successful_message": "うまくいきました!接続されました。",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Toestaan",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "En",
|
||||
"and_response_limit_of": "en responslimiet van",
|
||||
"anonymous": "Anoniem",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Rechtsonder",
|
||||
"cancel": "Annuleren",
|
||||
"centered_modal": "Gecentreerd modaal",
|
||||
"chart": "Grafiek",
|
||||
"charts": "Grafieken",
|
||||
"choices": "Keuzes",
|
||||
"choose_environment": "Kies omgeving",
|
||||
"choose_organization": "Kies organisatie",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
|
||||
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
|
||||
"create": "Creëren",
|
||||
"create_new_organization": "Creëer een nieuwe organisatie",
|
||||
"create_segment": "Segment maken",
|
||||
"create_survey": "Enquête maken",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Gemaakt door",
|
||||
"customer_success": "Klant succes",
|
||||
"dark_overlay": "Donkere overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Datum",
|
||||
"days": "dagen",
|
||||
"default": "Standaard",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "Op",
|
||||
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
|
||||
"open_options": "Opties openen",
|
||||
"option_id": "Optie-ID",
|
||||
"option_ids": "Optie-ID's",
|
||||
"optional": "Optioneel",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
|
||||
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Binnenkort beschikbaar",
|
||||
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
|
||||
"chart_deletion_error": "Verwijderen van grafiek mislukt",
|
||||
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
|
||||
"chart_duplication_error": "Dupliceren van grafiek mislukt",
|
||||
"chart_type_area": "Vlakdiagram",
|
||||
"chart_type_bar": "Staafdiagram",
|
||||
"chart_type_big_number": "Groot getal",
|
||||
"chart_type_line": "Lijndiagram",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"create_chart": "Diagram maken",
|
||||
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
|
||||
"no_charts_found": "Geen diagrammen gevonden.",
|
||||
"open_options": "Open diagramopties"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Dashboard creëren",
|
||||
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
|
||||
"create_failed": "Dashboard creëren mislukt",
|
||||
"create_success": "Dashboard succesvol aangemaakt!",
|
||||
"dashboard_name": "Dashboardnaam",
|
||||
"dashboard_name_placeholder": "Mijn dashboard",
|
||||
"delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_failed": "Dashboard verwijderen mislukt",
|
||||
"delete_success": "Dashboard succesvol verwijderd",
|
||||
"description_optional": "Beschrijving (optioneel)",
|
||||
"description_placeholder": "Dashboardbeschrijving",
|
||||
"duplicate_failed": "Dashboard dupliceren mislukt",
|
||||
"duplicate_success": "Dashboard succesvol gedupliceerd!",
|
||||
"no_dashboards_found": "Geen dashboards gevonden.",
|
||||
"please_enter_name": "Voer een dashboardnaam in"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Gefeliciteerd!",
|
||||
"connection_successful_message": "Goed gedaan! We zijn verbonden.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anônimo",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Canto Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolher ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar pesquisa",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Painéis",
|
||||
"date": "Encontro",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "ligado",
|
||||
"only_one_file_allowed": "É permitido apenas um arquivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID da opção",
|
||||
"option_ids": "IDs da Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
|
||||
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Em breve",
|
||||
"chart_deleted_successfully": "Gráfico excluído com sucesso",
|
||||
"chart_deletion_error": "Falha ao excluir gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_pie": "Gráfico de pizza",
|
||||
"create_chart": "Criar gráfico",
|
||||
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"open_options": "Abrir opções do gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Digite um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "Meu painel",
|
||||
"delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
|
||||
"delete_failed": "Falha ao excluir painel",
|
||||
"delete_success": "Painel excluído com sucesso",
|
||||
"description_optional": "Descrição (opcional)",
|
||||
"description_placeholder": "Descrição do painel",
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"please_enter_name": "Por favor, digite um nome para o painel"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Parabéns!",
|
||||
"connection_successful_message": "Mandou bem! Estamos conectados.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anónimo",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolha o ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar inquérito",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Data",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "Ligado",
|
||||
"only_one_file_allowed": "Apenas um ficheiro é permitido",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID de Opção",
|
||||
"option_ids": "IDs de Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
|
||||
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Em breve",
|
||||
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
|
||||
"chart_deletion_error": "Falha ao eliminar gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"create_chart": "Criar gráfico",
|
||||
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"open_options": "Abrir opções do gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "O meu painel",
|
||||
"delete_confirmation": "Tem a certeza de que pretende eliminar este painel? Esta ação não pode ser revertida.",
|
||||
"delete_failed": "Falha ao eliminar painel",
|
||||
"delete_success": "Painel eliminado com sucesso",
|
||||
"description_optional": "Descrição (opcional)",
|
||||
"description_placeholder": "Descrição do painel",
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"please_enter_name": "Por favor, introduza um nome para o painel"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Parabéns!",
|
||||
"connection_successful_message": "Muito bem! Estamos ligados.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Permite",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
|
||||
"analysis": "Analiză",
|
||||
"and": "Și",
|
||||
"and_response_limit_of": "și limită răspuns",
|
||||
"anonymous": "Anonim",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Dreapta Jos",
|
||||
"cancel": "Anulare",
|
||||
"centered_modal": "Modală centralizată",
|
||||
"chart": "Grafic",
|
||||
"charts": "Grafice",
|
||||
"choices": "Alegeri",
|
||||
"choose_environment": "Alege mediul",
|
||||
"choose_organization": "Alege organizația",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
|
||||
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
|
||||
"create": "Creează",
|
||||
"create_new_organization": "Creează organizație nouă",
|
||||
"create_segment": "Creați segment",
|
||||
"create_survey": "Creează sondaj",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Creat de",
|
||||
"customer_success": "Succesul Clientului",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"dashboard": "Tablou de bord",
|
||||
"dashboards": "Tablouri de bord",
|
||||
"date": "Dată",
|
||||
"days": "zile",
|
||||
"default": "Implicit",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "Pe",
|
||||
"only_one_file_allowed": "Este permis doar un fișier",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
|
||||
"open_options": "Deschide opțiunile",
|
||||
"option_id": "ID opțiune",
|
||||
"option_ids": "ID-uri opțiuni",
|
||||
"optional": "Opțional",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
|
||||
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "În curând",
|
||||
"chart_deleted_successfully": "Graficul a fost șters cu succes",
|
||||
"chart_deletion_error": "Nu s-a putut șterge graficul",
|
||||
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
|
||||
"chart_duplication_error": "Nu s-a putut duplica graficul",
|
||||
"chart_type_area": "Grafic de tip arie",
|
||||
"chart_type_bar": "Grafic de tip bară",
|
||||
"chart_type_big_number": "Număr mare",
|
||||
"chart_type_line": "Grafic de tip linie",
|
||||
"chart_type_pie": "Grafic de tip plăcintă",
|
||||
"create_chart": "Creează grafic",
|
||||
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
|
||||
"no_charts_found": "Nu s-au găsit grafice.",
|
||||
"open_options": "Deschide opțiunile graficului"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Creează tablou de bord",
|
||||
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
|
||||
"create_failed": "Crearea tabloului de bord a eșuat",
|
||||
"create_success": "Tablou de bord creat cu succes!",
|
||||
"dashboard_name": "Nume tablou de bord",
|
||||
"dashboard_name_placeholder": "Tabloul meu de bord",
|
||||
"delete_confirmation": "Ești sigur că vrei să ștergi acest tablou de bord? Această acțiune nu poate fi anulată.",
|
||||
"delete_failed": "Ștergerea tabloului de bord a eșuat",
|
||||
"delete_success": "Tablou de bord șters cu succes",
|
||||
"description_optional": "Descriere (opțional)",
|
||||
"description_placeholder": "Descriere tablou de bord",
|
||||
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
|
||||
"duplicate_success": "Tablou de bord duplicat cu succes!",
|
||||
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
|
||||
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Felicitări!",
|
||||
"connection_successful_message": "Bravo! Suntem conectați.",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
|
||||
},
|
||||
"common": {
|
||||
"Filter": "Фильтр",
|
||||
"accepted": "Принято",
|
||||
"account": "Аккаунт",
|
||||
"account_settings": "Настройки аккаунта",
|
||||
@@ -133,7 +134,6 @@
|
||||
"allow": "Разрешить",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
|
||||
"analysis": "Аналитика",
|
||||
"and": "и",
|
||||
"and_response_limit_of": "и лимит ответов",
|
||||
"anonymous": "Аноним",
|
||||
@@ -150,8 +150,6 @@
|
||||
"bottom_right": "Внизу справа",
|
||||
"cancel": "Отмена",
|
||||
"centered_modal": "Центрированное модальное окно",
|
||||
"chart": "График",
|
||||
"charts": "Графики",
|
||||
"choices": "Варианты",
|
||||
"choose_environment": "Выберите среду",
|
||||
"choose_organization": "Выберите организацию",
|
||||
@@ -181,7 +179,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
|
||||
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
|
||||
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
|
||||
"create": "Создать",
|
||||
"create_new_organization": "Создать новую организацию",
|
||||
"create_segment": "Создать сегмент",
|
||||
"create_survey": "Создать опрос",
|
||||
@@ -191,8 +188,6 @@
|
||||
"created_by": "Создано пользователем",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboards": "Дашборды",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
@@ -314,7 +309,6 @@
|
||||
"on": "Вкл.",
|
||||
"only_one_file_allowed": "Разрешён только один файл",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
|
||||
"open_options": "Открыть параметры",
|
||||
"option_id": "ID опции",
|
||||
"option_ids": "ID опций",
|
||||
"optional": "Необязательно",
|
||||
@@ -620,41 +614,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
|
||||
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Скоро будет",
|
||||
"chart_deleted_successfully": "График успешно удалён",
|
||||
"chart_deletion_error": "Не удалось удалить график",
|
||||
"chart_duplicated_successfully": "График успешно дублирован",
|
||||
"chart_duplication_error": "Не удалось дублировать график",
|
||||
"chart_type_area": "График областью",
|
||||
"chart_type_bar": "Столбчатая диаграмма",
|
||||
"chart_type_big_number": "Большое число",
|
||||
"chart_type_line": "Линейный график",
|
||||
"chart_type_pie": "Круговая диаграмма",
|
||||
"create_chart": "Создать график",
|
||||
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
|
||||
"no_charts_found": "Графики не найдены.",
|
||||
"open_options": "Открыть настройки графика"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Создать панель управления",
|
||||
"create_dashboard_description": "Введите название для новой панели управления.",
|
||||
"create_failed": "Не удалось создать панель управления",
|
||||
"create_success": "Панель управления успешно создана!",
|
||||
"dashboard_name": "Название панели управления",
|
||||
"dashboard_name_placeholder": "Моя панель управления",
|
||||
"delete_confirmation": "Ты уверен, что хочешь удалить эту панель управления? Это действие нельзя отменить.",
|
||||
"delete_failed": "Не удалось удалить панель управления",
|
||||
"delete_success": "Панель управления успешно удалена",
|
||||
"description_optional": "Описание (необязательно)",
|
||||
"description_placeholder": "Описание панели управления",
|
||||
"duplicate_failed": "Не удалось дублировать панель управления",
|
||||
"duplicate_success": "Панель управления успешно продублирована!",
|
||||
"no_dashboards_found": "Панели управления не найдены.",
|
||||
"please_enter_name": "Пожалуйста, введите название панели управления"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Поздравляем!",
|
||||
"connection_successful_message": "Отлично! Мы подключены.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Tillåt",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
|
||||
"analysis": "Analys",
|
||||
"and": "Och",
|
||||
"and_response_limit_of": "och svarsgräns på",
|
||||
"anonymous": "Anonym",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Nedre höger",
|
||||
"cancel": "Avbryt",
|
||||
"centered_modal": "Centrerad modal",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagram",
|
||||
"choices": "Val",
|
||||
"choose_environment": "Välj miljö",
|
||||
"choose_organization": "Välj organisation",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
|
||||
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
|
||||
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
|
||||
"create": "Skapa",
|
||||
"create_new_organization": "Skapa ny organisation",
|
||||
"create_segment": "Skapa segment",
|
||||
"create_survey": "Skapa enkät",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Skapad av",
|
||||
"customer_success": "Kundframgång",
|
||||
"dark_overlay": "Mörkt överlägg",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboards": "Instrumentpaneler",
|
||||
"date": "Datum",
|
||||
"days": "dagar",
|
||||
"default": "Standard",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "På",
|
||||
"only_one_file_allowed": "Endast en fil är tillåten",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
|
||||
"open_options": "Öppna alternativ",
|
||||
"option_id": "Alternativ-ID",
|
||||
"option_ids": "Alternativ-ID:n",
|
||||
"optional": "Valfritt",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
|
||||
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Kommer snart",
|
||||
"chart_deleted_successfully": "Diagrammet har tagits bort",
|
||||
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
|
||||
"chart_duplicated_successfully": "Diagrammet har duplicerats",
|
||||
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
|
||||
"chart_type_area": "Ytdiagram",
|
||||
"chart_type_bar": "Stapeldiagram",
|
||||
"chart_type_big_number": "Stort tal",
|
||||
"chart_type_line": "Linjediagram",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"create_chart": "Skapa diagram",
|
||||
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
|
||||
"no_charts_found": "Inga diagram hittades.",
|
||||
"open_options": "Öppna diagramalternativ"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Skapa instrumentpanel",
|
||||
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
|
||||
"create_failed": "Det gick inte att skapa instrumentpanelen",
|
||||
"create_success": "Instrumentpanelen har skapats!",
|
||||
"dashboard_name": "Instrumentpanelens namn",
|
||||
"dashboard_name_placeholder": "Min instrumentpanel",
|
||||
"delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
|
||||
"delete_failed": "Det gick inte att ta bort instrumentpanelen",
|
||||
"delete_success": "Instrumentpanelen har tagits bort",
|
||||
"description_optional": "Beskrivning (valfritt)",
|
||||
"description_placeholder": "Beskrivning av instrumentpanelen",
|
||||
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
|
||||
"duplicate_success": "Instrumentpanelen har duplicerats!",
|
||||
"no_dashboards_found": "Inga instrumentpaneler hittades.",
|
||||
"please_enter_name": "Ange ett namn på instrumentpanelen"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Grattis!",
|
||||
"connection_successful_message": "Bra gjort! Vi är anslutna.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "允许",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
|
||||
"analysis": "分析",
|
||||
"and": "和",
|
||||
"and_response_limit_of": "和 响应限制",
|
||||
"anonymous": "匿名",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "居中 模态",
|
||||
"chart": "图表",
|
||||
"charts": "图表",
|
||||
"choices": "选项",
|
||||
"choose_environment": "选择 环境",
|
||||
"choose_organization": "选择 组织",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 联系人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回复} }",
|
||||
"create": "创建",
|
||||
"create_new_organization": "创建 新的 组织",
|
||||
"create_segment": "创建 细分",
|
||||
"create_survey": "创建 调查",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "由 创建",
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "仪表盘",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "开启",
|
||||
"only_one_file_allowed": "只 允许 一个 文件",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
|
||||
"open_options": "打开选项",
|
||||
"option_id": "选项 ID",
|
||||
"option_ids": "选项 ID",
|
||||
"optional": "可选",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
|
||||
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "即将推出",
|
||||
"chart_deleted_successfully": "图表删除成功",
|
||||
"chart_deletion_error": "图表删除失败",
|
||||
"chart_duplicated_successfully": "图表复制成功",
|
||||
"chart_duplication_error": "图表复制失败",
|
||||
"chart_type_area": "面积图",
|
||||
"chart_type_bar": "柱状图",
|
||||
"chart_type_big_number": "大数字",
|
||||
"chart_type_line": "折线图",
|
||||
"chart_type_pie": "饼图",
|
||||
"create_chart": "创建图表",
|
||||
"delete_chart_confirmation": "你确定要删除这个图表吗?",
|
||||
"no_charts_found": "未找到图表。",
|
||||
"open_options": "打开图表选项"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "创建 Dashboard",
|
||||
"create_dashboard_description": "请输入新 Dashboard 的名称。",
|
||||
"create_failed": "创建 Dashboard 失败",
|
||||
"create_success": "Dashboard 创建成功!",
|
||||
"dashboard_name": "Dashboard 名称",
|
||||
"dashboard_name_placeholder": "我的 Dashboard",
|
||||
"delete_confirmation": "确定要删除此 Dashboard 吗?此操作无法撤销。",
|
||||
"delete_failed": "删除 Dashboard 失败",
|
||||
"delete_success": "Dashboard 删除成功",
|
||||
"description_optional": "描述(可选)",
|
||||
"description_placeholder": "Dashboard 描述",
|
||||
"duplicate_failed": "复制 Dashboard 失败",
|
||||
"duplicate_success": "Dashboard 复制成功!",
|
||||
"no_dashboards_found": "未找到 Dashboard。",
|
||||
"please_enter_name": "请输入 Dashboard 名称"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "恭喜!",
|
||||
"connection_successful_message": "做得好 !我们 已经 连接。",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "允許",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
|
||||
"analysis": "分析",
|
||||
"and": "且",
|
||||
"and_response_limit_of": "且回應上限為",
|
||||
"anonymous": "匿名",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "置中彈窗",
|
||||
"chart": "圖表",
|
||||
"charts": "圖表",
|
||||
"choices": "選項",
|
||||
"choose_environment": "選擇環境",
|
||||
"choose_organization": "選擇 組織",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回應} }",
|
||||
"create": "建立",
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_segment": "建立區隔",
|
||||
"create_survey": "建立問卷",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "建立者",
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"dashboard": "儀表板",
|
||||
"dashboards": "儀表板",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
@@ -314,7 +308,6 @@
|
||||
"on": "開啟",
|
||||
"only_one_file_allowed": "僅允許一個檔案",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"open_options": "開啟選項",
|
||||
"option_id": "選項 ID",
|
||||
"option_ids": "選項 IDs",
|
||||
"optional": "選填",
|
||||
@@ -620,41 +613,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
|
||||
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "即將推出",
|
||||
"chart_deleted_successfully": "圖表已成功刪除",
|
||||
"chart_deletion_error": "刪除圖表失敗",
|
||||
"chart_duplicated_successfully": "圖表已成功複製",
|
||||
"chart_duplication_error": "圖表複製失敗",
|
||||
"chart_type_area": "區域圖",
|
||||
"chart_type_bar": "長條圖",
|
||||
"chart_type_big_number": "大數字",
|
||||
"chart_type_line": "折線圖",
|
||||
"chart_type_pie": "圓餅圖",
|
||||
"create_chart": "建立圖表",
|
||||
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
|
||||
"no_charts_found": "找不到圖表。",
|
||||
"open_options": "開啟圖表選項"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "建立儀表板",
|
||||
"create_dashboard_description": "請輸入新儀表板的名稱。",
|
||||
"create_failed": "建立儀表板失敗",
|
||||
"create_success": "儀表板建立成功!",
|
||||
"dashboard_name": "儀表板名稱",
|
||||
"dashboard_name_placeholder": "我的儀表板",
|
||||
"delete_confirmation": "你確定要刪除此儀表板嗎?此操作無法復原。",
|
||||
"delete_failed": "刪除儀表板失敗",
|
||||
"delete_success": "儀表板刪除成功",
|
||||
"description_optional": "描述(選填)",
|
||||
"description_placeholder": "儀表板描述",
|
||||
"duplicate_failed": "複製儀表板失敗",
|
||||
"duplicate_success": "儀表板複製成功!",
|
||||
"no_dashboards_found": "找不到儀表板。",
|
||||
"please_enter_name": "請輸入儀表板名稱"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "恭喜!",
|
||||
"connection_successful_message": "做得好!我們已連線。",
|
||||
|
||||
@@ -12,9 +12,7 @@ type HasFindMany =
|
||||
| Prisma.TeamFindManyArgs
|
||||
| Prisma.ProjectTeamFindManyArgs
|
||||
| Prisma.UserFindManyArgs
|
||||
| Prisma.ContactAttributeKeyFindManyArgs
|
||||
| Prisma.ChartFindManyArgs
|
||||
| Prisma.DashboardFindManyArgs;
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockLoad = vi.fn();
|
||||
const mockTablePivot = vi.fn();
|
||||
|
||||
vi.mock("@cubejs-client/core", () => ({
|
||||
default: vi.fn(() => ({
|
||||
load: mockLoad,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
const resultSet = { tablePivot: mockTablePivot };
|
||||
mockLoad.mockResolvedValue(resultSet);
|
||||
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("loads query and returns tablePivot result", async () => {
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
const query = { measures: ["FeedbackRecords.count"] };
|
||||
const result = await executeQuery(query);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledWith(query);
|
||||
expect(mockTablePivot).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
|
||||
const fullUrl = "https://cube.example.com/cubejs-api/v1";
|
||||
vi.stubEnv("CUBEJS_API_URL", fullUrl);
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
|
||||
await executeQuery({ measures: ["FeedbackRecords.count"] });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
|
||||
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
|
||||
|
||||
const getApiUrl = (): string => {
|
||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
let cubeClient: CubeApi | null = null;
|
||||
|
||||
function getCubeClient(): CubeApi {
|
||||
if (!cubeClient) {
|
||||
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
|
||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
||||
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
|
||||
}
|
||||
return cubeClient;
|
||||
}
|
||||
|
||||
export async function executeQuery(query: Query) {
|
||||
const client = getCubeClient();
|
||||
const resultSet = await client.load(query);
|
||||
return resultSet.tablePivot();
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
import { validateQueryMembers } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import {
|
||||
createChart,
|
||||
deleteChart,
|
||||
duplicateChart,
|
||||
getChart,
|
||||
getCharts,
|
||||
updateChart,
|
||||
} from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
|
||||
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
|
||||
/** Client-facing chart input (projectId and createdBy are resolved server-side) */
|
||||
const ZChartCreateInputClient = ZChartCreateInput.omit({ projectId: true, createdBy: true });
|
||||
|
||||
const ZCreateChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartInput: ZChartCreateInputClient,
|
||||
});
|
||||
|
||||
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await createChart({
|
||||
...parsedInput.chartInput,
|
||||
projectId,
|
||||
createdBy: ctx.user.id,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = chart.id;
|
||||
ctx.auditLoggingCtx.newObject = chart;
|
||||
return chart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
chartUpdateInput: ZChartUpdateInput,
|
||||
});
|
||||
|
||||
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const { chart, updatedChart } = await updateChart(
|
||||
parsedInput.chartId,
|
||||
projectId,
|
||||
parsedInput.chartUpdateInput
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
ctx.auditLoggingCtx.newObject = updatedChart;
|
||||
return updatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDuplicateChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const duplicatedChart = await duplicateChart(parsedInput.chartId, projectId, ctx.user.id);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
|
||||
ctx.auditLoggingCtx.newObject = duplicatedChart;
|
||||
return duplicatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await deleteChart(parsedInput.chartId, projectId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const getChartAction = authenticatedActionClient
|
||||
.schema(ZGetChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getChart(parsedInput.chartId, projectId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetChartsAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getChartsAction = authenticatedActionClient
|
||||
.schema(ZGetChartsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartsAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getCharts(projectId);
|
||||
}
|
||||
);
|
||||
|
||||
// ── Charts UI specific actions (query execution & AI generation) ─────────────
|
||||
|
||||
const ZExecuteQueryAction = z.object({
|
||||
environmentId: ZId,
|
||||
query: ZChartQuery,
|
||||
});
|
||||
|
||||
export const executeQueryAction = authenticatedActionClient
|
||||
.schema(ZExecuteQueryAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZExecuteQueryAction>;
|
||||
}) => {
|
||||
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
validateQueryMembers(parsedInput.query);
|
||||
|
||||
try {
|
||||
return await executeQuery(parsedInput.query as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error("Failed to execute query");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const ZGenerateAIQueryResponse = z.object({
|
||||
measures: z.array(z.string()),
|
||||
dimensions: z.array(z.string()).nullable(),
|
||||
timeDimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
dimension: z.string(),
|
||||
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
|
||||
dateRange: z.string().nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
chartType: ZChartType,
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
member: z.string(),
|
||||
operator: z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
]),
|
||||
values: z.array(z.string()).nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const AI_QUERY_JSON_SCHEMA = {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
measures: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
description: "List of measures to query",
|
||||
},
|
||||
dimensions: {
|
||||
anyOf: [{ type: "array" as const, items: { type: "string" as const } }, { type: "null" as const }],
|
||||
description: "List of dimensions to query",
|
||||
},
|
||||
timeDimensions: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
dimension: { type: "string" as const },
|
||||
granularity: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string" as const,
|
||||
enum: ["hour", "day", "week", "month", "quarter", "year"],
|
||||
},
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
dateRange: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||
},
|
||||
required: ["dimension", "granularity", "dateRange"],
|
||||
},
|
||||
},
|
||||
{ type: "null" as const },
|
||||
],
|
||||
description: "Time dimensions with granularity and date range",
|
||||
},
|
||||
chartType: {
|
||||
type: "string" as const,
|
||||
enum: [...ZChartType.options],
|
||||
description: "Suggested chart type for visualization",
|
||||
},
|
||||
filters: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
member: { type: "string" as const },
|
||||
operator: {
|
||||
type: "string" as const,
|
||||
enum: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
],
|
||||
},
|
||||
values: {
|
||||
anyOf: [
|
||||
{ type: "array" as const, items: { type: "string" as const } },
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ["member", "operator", "values"],
|
||||
},
|
||||
},
|
||||
{ type: "null" as const },
|
||||
],
|
||||
description: "Filters to apply to the query",
|
||||
},
|
||||
},
|
||||
required: ["measures", "dimensions", "timeDimensions", "chartType", "filters"],
|
||||
};
|
||||
|
||||
const ZGenerateAIChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
prompt: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export const generateAIChartAction = authenticatedActionClient
|
||||
.schema(ZGenerateAIChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGenerateAIChartAction>;
|
||||
}) => {
|
||||
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const schemaContext = generateSchemaContext();
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: schemaContext },
|
||||
{ role: "user", content: `User request: "${parsedInput.prompt}"` },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_cube_query",
|
||||
description: "Generate a Cube.js query based on the user request",
|
||||
parameters: AI_QUERY_JSON_SCHEMA,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function", function: { name: "generate_cube_query" } },
|
||||
});
|
||||
|
||||
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
|
||||
if (toolCall?.function?.name !== "generate_cube_query") {
|
||||
throw new Error("Failed to generate structured output from OpenAI");
|
||||
}
|
||||
|
||||
const rawQuery = JSON.parse(toolCall.function.arguments);
|
||||
const validated = ZGenerateAIQueryResponse.parse(rawQuery);
|
||||
|
||||
if (!validated.measures || validated.measures.length === 0) {
|
||||
validated.measures = [`${CUBE_NAME}.count`];
|
||||
}
|
||||
|
||||
const { chartType, ...cubeQuery } = validated;
|
||||
|
||||
const cleanQuery: Record<string, unknown> = {
|
||||
measures: cubeQuery.measures,
|
||||
};
|
||||
|
||||
if (Array.isArray(cubeQuery.dimensions) && cubeQuery.dimensions.length > 0) {
|
||||
cleanQuery.dimensions = cubeQuery.dimensions;
|
||||
}
|
||||
|
||||
if (Array.isArray(cubeQuery.filters) && cubeQuery.filters.length > 0) {
|
||||
cleanQuery.filters = cubeQuery.filters.map(
|
||||
(f: { member: string; operator: string; values?: string[] | null }) => {
|
||||
const cleaned: Record<string, unknown> = { member: f.member, operator: f.operator };
|
||||
if (f.values !== null && f.values !== undefined) cleaned.values = f.values;
|
||||
return cleaned;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(cubeQuery.timeDimensions) && cubeQuery.timeDimensions.length > 0) {
|
||||
cleanQuery.timeDimensions = cubeQuery.timeDimensions.map(
|
||||
(td: { dimension: string; granularity?: string | null; dateRange?: string | null }) => {
|
||||
const cleaned: Record<string, unknown> = { dimension: td.dimension };
|
||||
if (td.granularity !== null && td.granularity !== undefined) cleaned.granularity = td.granularity;
|
||||
if (td.dateRange !== null && td.dateRange !== undefined) cleaned.dateRange = td.dateRange;
|
||||
return cleaned;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const data = await executeQuery(cleanQuery);
|
||||
|
||||
return {
|
||||
query: cleanQuery,
|
||||
chartType,
|
||||
data: Array.isArray(data) ? data : [],
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AddToDashboardDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function AddToDashboardDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
}: Readonly<AddToDashboardDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && onOpenChange(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.add_chart_to_dashboard")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.analysis.charts.add_chart_to_dashboard_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("environments.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dashboard-select">{t("environments.analysis.charts.dashboard")}</Label>
|
||||
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||
<SelectTrigger
|
||||
id="dashboard-select"
|
||||
className="mt-2 w-full bg-white"
|
||||
disabled={dashboards.length === 0}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
dashboards.length === 0
|
||||
? t("environments.analysis.charts.no_dashboards_available")
|
||||
: t("environments.analysis.charts.dashboard_select_placeholder")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="max-h-[200px]">
|
||||
{dashboards.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.no_dashboards_available")}
|
||||
</div>
|
||||
) : (
|
||||
dashboards.map((dashboard) => (
|
||||
<SelectItem key={dashboard.id} value={dashboard.id}>
|
||||
{dashboard.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dashboards.length === 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t("environments.analysis.charts.no_dashboards_create_first")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
|
||||
{t("environments.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { executeQueryAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartPreview } from "@/modules/ee/analysis/charts/components/advanced-chart-preview";
|
||||
import { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
|
||||
import { ChartTypeSelector } from "@/modules/ee/analysis/charts/components/chart-type-selector";
|
||||
import { DimensionsPanel } from "@/modules/ee/analysis/charts/components/dimensions-panel";
|
||||
import { FiltersPanel } from "@/modules/ee/analysis/charts/components/filters-panel";
|
||||
import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures-panel";
|
||||
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
|
||||
import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel";
|
||||
import { useSaveDashboardDialogs } from "@/modules/ee/analysis/charts/hooks/use-save-dashboard-dialogs";
|
||||
import {
|
||||
ChartBuilderState,
|
||||
type CustomMeasure,
|
||||
type FilterRow,
|
||||
type TimeDimensionConfig,
|
||||
buildCubeQuery,
|
||||
parseQueryToState,
|
||||
} from "@/modules/ee/analysis/lib/query-builder";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { AnalyticsResponse, TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
interface AdvancedChartBuilderProps {
|
||||
environmentId: string;
|
||||
initialChartType?: TChartType;
|
||||
initialQuery?: TChartQuery;
|
||||
hidePreview?: boolean;
|
||||
/** Must be stable (memoized) to avoid effect re-runs on every parent render */
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
onSave?: (chartId: string) => void;
|
||||
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_CHART_TYPE"; payload: TChartType }
|
||||
| { type: "SET_MEASURES"; payload: string[] }
|
||||
| { type: "SET_CUSTOM_MEASURES"; payload: CustomMeasure[] }
|
||||
| { type: "SET_DIMENSIONS"; payload: string[] }
|
||||
| { type: "SET_FILTERS"; payload: FilterRow[] }
|
||||
| { type: "SET_FILTER_LOGIC"; payload: "and" | "or" }
|
||||
| { type: "SET_TIME_DIMENSION"; payload: TimeDimensionConfig | null }
|
||||
| { type: "QUERY_START" }
|
||||
| { type: "QUERY_SUCCESS"; payload: { data: TChartDataRow[]; query: TChartQuery } }
|
||||
| { type: "QUERY_ERROR"; payload: string };
|
||||
|
||||
interface QueryState {
|
||||
chartData: TChartDataRow[] | null;
|
||||
query: TChartQuery | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialQueryState: QueryState = {
|
||||
chartData: null,
|
||||
query: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const initialState: ChartBuilderState = {
|
||||
chartType: "",
|
||||
selectedMeasures: [],
|
||||
customMeasures: [],
|
||||
selectedDimensions: [],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
|
||||
const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBuilderState => {
|
||||
switch (action.type) {
|
||||
case "SET_CHART_TYPE":
|
||||
return { ...state, chartType: action.payload };
|
||||
case "SET_MEASURES":
|
||||
return { ...state, selectedMeasures: action.payload };
|
||||
case "SET_CUSTOM_MEASURES":
|
||||
return { ...state, customMeasures: action.payload };
|
||||
case "SET_DIMENSIONS":
|
||||
return { ...state, selectedDimensions: action.payload };
|
||||
case "SET_FILTERS":
|
||||
return { ...state, filters: action.payload };
|
||||
case "SET_FILTER_LOGIC":
|
||||
return { ...state, filterLogic: action.payload };
|
||||
case "SET_TIME_DIMENSION":
|
||||
return { ...state, timeDimension: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const queryReducer = (state: QueryState, action: Action): QueryState => {
|
||||
switch (action.type) {
|
||||
case "QUERY_START":
|
||||
return { ...state, isLoading: true, error: null };
|
||||
case "QUERY_SUCCESS":
|
||||
return {
|
||||
chartData: action.payload.data,
|
||||
query: action.payload.query,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
case "QUERY_ERROR":
|
||||
return { ...state, isLoading: false, error: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export function AdvancedChartBuilder({
|
||||
environmentId,
|
||||
initialChartType,
|
||||
initialQuery,
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
onSave,
|
||||
onAddToDashboard,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const onChartGeneratedRef = useRef(onChartGenerated);
|
||||
onChartGeneratedRef.current = onChartGenerated;
|
||||
const prevInitialChartTypeRef = useRef(initialChartType);
|
||||
|
||||
const getInitialState = useCallback((): ChartBuilderState => {
|
||||
if (initialQuery) {
|
||||
const parsedState = parseQueryToState(initialQuery, initialChartType);
|
||||
return {
|
||||
...initialState,
|
||||
...parsedState,
|
||||
chartType: parsedState.chartType || initialChartType || "",
|
||||
};
|
||||
}
|
||||
return { ...initialState, chartType: initialChartType || "" };
|
||||
}, [initialQuery, initialChartType]);
|
||||
|
||||
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
|
||||
const [queryState, dispatchQuery] = useReducer(queryReducer, {
|
||||
...initialQueryState,
|
||||
query: initialQuery || null,
|
||||
});
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showQuery, setShowQuery] = useState(false);
|
||||
const [showData, setShowData] = useState(false);
|
||||
const [dimensionsOpen, setDimensionsOpen] = useState(false);
|
||||
const [timeDimensionOpen, setTimeDimensionOpen] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [customAggregationsOpen, setCustomAggregationsOpen] = useState(false);
|
||||
const lastStateRef = useRef<string>("");
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const saveDashboard = useSaveDashboardDialogs({
|
||||
environmentId,
|
||||
getChartInput: () => {
|
||||
if (!queryState.chartData || !queryState.query || !state.chartType) return null;
|
||||
return { query: queryState.query, chartType: state.chartType };
|
||||
},
|
||||
onSave,
|
||||
onAddToDashboard,
|
||||
});
|
||||
|
||||
// Sync initialChartType only when the prop changes (not when state diverges)
|
||||
useEffect(() => {
|
||||
if (initialChartType && initialChartType !== prevInitialChartTypeRef.current) {
|
||||
prevInitialChartTypeRef.current = initialChartType;
|
||||
dispatch({ type: "SET_CHART_TYPE", payload: initialChartType });
|
||||
if (!initialQuery && !isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
}, [initialChartType, initialQuery, isInitialized]);
|
||||
|
||||
// Sync section open states when loading from initialQuery
|
||||
useEffect(() => {
|
||||
if (!initialQuery) return;
|
||||
const parsed = parseQueryToState(initialQuery, initialChartType);
|
||||
setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0);
|
||||
setTimeDimensionOpen(parsed.timeDimension != null);
|
||||
setFiltersOpen((parsed.filters?.length ?? 0) > 0);
|
||||
// Only set customAggregationsOpen to true when parsed has custom measures.
|
||||
// Never set to false here: parseQueryToState always returns customMeasures: [] because
|
||||
// Cube.js query format doesn't store custom measure definitions, so we'd incorrectly
|
||||
// turn off the toggle after running a query that uses custom measures.
|
||||
if ((parsed.customMeasures?.length ?? 0) > 0) {
|
||||
setCustomAggregationsOpen(true);
|
||||
}
|
||||
}, [initialQuery, initialChartType]);
|
||||
|
||||
// Keep time dimension toggle in sync when panel's disable clears the config
|
||||
useEffect(() => {
|
||||
if (state.timeDimension == null) setTimeDimensionOpen(false);
|
||||
}, [state.timeDimension]);
|
||||
|
||||
// Turn off filter toggle when the last filter is deleted
|
||||
useEffect(() => {
|
||||
if (state.filters.length === 0 && filtersOpen) setFiltersOpen(false);
|
||||
}, [state.filters.length, filtersOpen]);
|
||||
|
||||
// Turn off custom aggregations toggle when the last custom measure is removed
|
||||
useEffect(() => {
|
||||
if (state.customMeasures.length === 0 && customAggregationsOpen) setCustomAggregationsOpen(false);
|
||||
}, [state.customMeasures.length, customAggregationsOpen]);
|
||||
|
||||
// Initialize: execute initialQuery once (deps intentionally minimal to avoid redundant runs).
|
||||
// Skip when hidePreview is true because the parent component handles data loading.
|
||||
useEffect(() => {
|
||||
if (!initialQuery || isInitialized) return;
|
||||
setIsInitialized(true);
|
||||
|
||||
if (hidePreview) {
|
||||
// Sync lastStateRef so the reactive effect does not re-run the same query on mount.
|
||||
lastStateRef.current = JSON.stringify({
|
||||
chartType: state.chartType,
|
||||
measures: state.selectedMeasures,
|
||||
dimensions: state.selectedDimensions,
|
||||
filters: state.filters,
|
||||
timeDimension: state.timeDimension,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chartType = state.chartType;
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
executeQueryAction({ environmentId, query: initialQuery })
|
||||
.then((result) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
if (result?.serverError) {
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: getFormattedErrorMessage(result) });
|
||||
return;
|
||||
}
|
||||
const data = Array.isArray(result?.data) ? result.data : [];
|
||||
if (data.length > 0) {
|
||||
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: initialQuery } });
|
||||
lastStateRef.current = JSON.stringify({
|
||||
chartType,
|
||||
measures: state.selectedMeasures,
|
||||
dimensions: state.selectedDimensions,
|
||||
filters: state.filters,
|
||||
timeDimension: state.timeDimension,
|
||||
});
|
||||
if (onChartGeneratedRef.current && chartType) {
|
||||
onChartGeneratedRef.current({ query: initialQuery, chartType, data });
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: message });
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- init runs once; state/onChartGenerated via ref
|
||||
}, [initialQuery, environmentId, isInitialized]);
|
||||
|
||||
// Reactive query with debounce and cancellation
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !state.chartType) return;
|
||||
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) return;
|
||||
|
||||
const stateHash = JSON.stringify({
|
||||
chartType: state.chartType,
|
||||
measures: state.selectedMeasures,
|
||||
dimensions: state.selectedDimensions,
|
||||
filters: state.filters,
|
||||
timeDimension: state.timeDimension,
|
||||
});
|
||||
if (stateHash === lastStateRef.current) return;
|
||||
lastStateRef.current = stateHash;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
const chartType = state.chartType;
|
||||
const updatedQuery = buildCubeQuery(state);
|
||||
const requestId = ++requestIdRef.current;
|
||||
dispatchQuery({ type: "QUERY_START" });
|
||||
|
||||
executeQueryAction({ environmentId, query: updatedQuery })
|
||||
.then((result) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
if (result?.serverError) {
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: getFormattedErrorMessage(result) });
|
||||
return;
|
||||
}
|
||||
const data = Array.isArray(result?.data) ? result.data : [];
|
||||
if (data.length > 0) {
|
||||
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: updatedQuery } });
|
||||
if (onChartGeneratedRef.current && chartType) {
|
||||
onChartGeneratedRef.current({ query: updatedQuery, chartType, data });
|
||||
}
|
||||
} else {
|
||||
dispatchQuery({
|
||||
type: "QUERY_ERROR",
|
||||
payload: t("environments.analysis.charts.no_data_returned"),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: message });
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced; onChartGenerated via ref
|
||||
}, [
|
||||
state.chartType,
|
||||
state.selectedMeasures,
|
||||
state.selectedDimensions,
|
||||
state.filters,
|
||||
state.filterLogic,
|
||||
state.customMeasures,
|
||||
state.timeDimension,
|
||||
isInitialized,
|
||||
environmentId,
|
||||
]);
|
||||
|
||||
const processQueryResult = useCallback(
|
||||
(
|
||||
result: Awaited<ReturnType<typeof executeQueryAction>>,
|
||||
cubeQuery: TChartQuery,
|
||||
chartType: TChartType,
|
||||
requestId: number
|
||||
) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
if (result?.serverError) {
|
||||
const errorMsg = getFormattedErrorMessage(result);
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: errorMsg });
|
||||
toast.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
const data = Array.isArray(result?.data) ? result.data : [];
|
||||
if (data.length === 0) {
|
||||
dispatchQuery({
|
||||
type: "QUERY_ERROR",
|
||||
payload: t("environments.analysis.charts.no_data_returned"),
|
||||
});
|
||||
toast.error(t("environments.analysis.charts.no_data_returned"));
|
||||
return;
|
||||
}
|
||||
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: cubeQuery } });
|
||||
toast.success(t("environments.analysis.charts.query_executed_successfully"));
|
||||
if (onChartGeneratedRef.current && chartType) {
|
||||
onChartGeneratedRef.current({ query: cubeQuery, chartType, data });
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleRunQuery = async () => {
|
||||
if (!state.chartType) {
|
||||
toast.error(t("environments.analysis.charts.please_select_chart_type"));
|
||||
return;
|
||||
}
|
||||
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
|
||||
toast.error(t("environments.analysis.charts.please_select_at_least_one_measure"));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchQuery({ type: "QUERY_START" });
|
||||
const cubeQuery = buildCubeQuery(state);
|
||||
const chartType = state.chartType;
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
try {
|
||||
const result = await executeQueryAction({ environmentId, query: cubeQuery });
|
||||
processQueryResult(result, cubeQuery, chartType, requestId);
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: message });
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const { chartData, query, isLoading, error } = queryState;
|
||||
const showSaveDashboard = !onSave || !onAddToDashboard;
|
||||
|
||||
return (
|
||||
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
|
||||
<div className="mx-1 space-y-2">
|
||||
{!hidePreview && (
|
||||
<>
|
||||
<ChartBuilderGuide />
|
||||
<ChartTypeSelector
|
||||
selectedChartType={state.chartType}
|
||||
onChartTypeSelect={(chartType) => dispatch({ type: "SET_CHART_TYPE", payload: chartType })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
|
||||
<MeasuresPanel
|
||||
selectedMeasures={state.selectedMeasures}
|
||||
customMeasures={state.customMeasures}
|
||||
customAggregationsOpen={customAggregationsOpen}
|
||||
onCustomAggregationsOpenChange={(open) => {
|
||||
setCustomAggregationsOpen(open);
|
||||
if (!open) {
|
||||
dispatch({ type: "SET_CUSTOM_MEASURES", payload: [] });
|
||||
} else if (state.customMeasures.length === 0) {
|
||||
const dimensionOptions = FEEDBACK_FIELDS.dimensions
|
||||
.filter((d) => d.type === "number")
|
||||
.map((d) => d.id);
|
||||
dispatch({
|
||||
type: "SET_CUSTOM_MEASURES",
|
||||
payload: [
|
||||
{
|
||||
id: `measure-${crypto.randomUUID()}`,
|
||||
field: dimensionOptions[0] ?? "",
|
||||
aggregation: "avg",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMeasuresChange={(measures) => dispatch({ type: "SET_MEASURES", payload: measures })}
|
||||
onCustomMeasuresChange={(measures) =>
|
||||
dispatch({ type: "SET_CUSTOM_MEASURES", payload: measures })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={dimensionsOpen}
|
||||
onToggle={(checked) => {
|
||||
setDimensionsOpen(checked);
|
||||
if (!checked) dispatch({ type: "SET_DIMENSIONS", payload: [] });
|
||||
}}
|
||||
htmlId="chart-dimensions-toggle"
|
||||
title={t("environments.analysis.charts.dimensions")}
|
||||
description={t("environments.analysis.charts.dimensions_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<DimensionsPanel
|
||||
hideTitle
|
||||
selectedDimensions={state.selectedDimensions}
|
||||
onDimensionsChange={(dimensions) => dispatch({ type: "SET_DIMENSIONS", payload: dimensions })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={timeDimensionOpen}
|
||||
onToggle={(checked) => {
|
||||
setTimeDimensionOpen(checked);
|
||||
if (!checked) dispatch({ type: "SET_TIME_DIMENSION", payload: null });
|
||||
else if (!state.timeDimension) {
|
||||
dispatch({
|
||||
type: "SET_TIME_DIMENSION",
|
||||
payload: {
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: "last 30 days",
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-time-dimension-toggle"
|
||||
title={t("environments.analysis.charts.time_dimension")}
|
||||
description={t("environments.analysis.charts.time_dimension_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<TimeDimensionPanel
|
||||
hideTitle
|
||||
timeDimension={state.timeDimension}
|
||||
onTimeDimensionChange={(config) => dispatch({ type: "SET_TIME_DIMENSION", payload: config })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={filtersOpen}
|
||||
onToggle={(checked) => {
|
||||
setFiltersOpen(checked);
|
||||
if (!checked) {
|
||||
dispatch({ type: "SET_FILTERS", payload: [] });
|
||||
} else if (state.filters.length === 0) {
|
||||
const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0];
|
||||
dispatch({
|
||||
type: "SET_FILTERS",
|
||||
payload: [
|
||||
{
|
||||
field: firstField?.id ?? "",
|
||||
operator: "equals" as const,
|
||||
values: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-filters-toggle"
|
||||
title={t("environments.analysis.charts.filters")}
|
||||
description={t("environments.analysis.charts.filters_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<FiltersPanel
|
||||
hideTitle
|
||||
filters={state.filters}
|
||||
filterLogic={state.filterLogic}
|
||||
onFiltersChange={(filters) => dispatch({ type: "SET_FILTERS", payload: filters })}
|
||||
onFilterLogicChange={(logic) => dispatch({ type: "SET_FILTER_LOGIC", payload: logic })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !state.chartType}>
|
||||
{isLoading ? <LoadingSpinner /> : t("environments.analysis.charts.run_query")}
|
||||
</Button>
|
||||
{chartData && showSaveDashboard && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => saveDashboard.setIsSaveDialogOpen(true)}>
|
||||
{t("environments.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => saveDashboard.setIsAddToDashboardDialogOpen(true)}>
|
||||
{t("environments.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hidePreview && (
|
||||
<AdvancedChartPreview
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
chartData={chartData}
|
||||
chartType={state.chartType}
|
||||
query={query}
|
||||
showQuery={showQuery}
|
||||
onShowQueryChange={setShowQuery}
|
||||
showData={showData}
|
||||
onShowDataChange={setShowData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!onSave && (
|
||||
<SaveChartDialog
|
||||
open={saveDashboard.isSaveDialogOpen}
|
||||
onOpenChange={saveDashboard.setIsSaveDialogOpen}
|
||||
chartName={saveDashboard.chartName}
|
||||
onChartNameChange={saveDashboard.setChartName}
|
||||
onSave={saveDashboard.handleSaveChart}
|
||||
isSaving={saveDashboard.isSaving}
|
||||
/>
|
||||
)}
|
||||
{!onAddToDashboard && (
|
||||
<AddToDashboardDialog
|
||||
isOpen={saveDashboard.isAddToDashboardDialogOpen}
|
||||
onOpenChange={saveDashboard.setIsAddToDashboardDialogOpen}
|
||||
chartName={saveDashboard.chartName}
|
||||
onChartNameChange={saveDashboard.setChartName}
|
||||
dashboards={saveDashboard.dashboards}
|
||||
selectedDashboardId={saveDashboard.selectedDashboardId}
|
||||
onDashboardSelect={saveDashboard.setSelectedDashboardId}
|
||||
onConfirm={saveDashboard.handleAddToDashboard}
|
||||
isSaving={saveDashboard.isSaving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CodeIcon, DatabaseIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import { QueryViewer } from "@/modules/ee/analysis/charts/components/query-viewer";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface AdvancedChartPreviewProps {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
chartData: TChartDataRow[] | null;
|
||||
chartType: TChartType | "";
|
||||
query: TChartQuery | null;
|
||||
showQuery: boolean;
|
||||
onShowQueryChange: (open: boolean) => void;
|
||||
showData: boolean;
|
||||
onShowDataChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AdvancedChartPreview({
|
||||
error,
|
||||
isLoading,
|
||||
chartData,
|
||||
chartType,
|
||||
query,
|
||||
showQuery,
|
||||
onShowQueryChange,
|
||||
showData,
|
||||
onShowDataChange,
|
||||
}: Readonly<AdvancedChartPreviewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const hasData = chartData && chartData.length > 0 && !isLoading && chartType && query;
|
||||
const isEmpty = !chartData && !isLoading && !error;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.chart_preview")}
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<ChartRenderer chartType={chartType} data={chartData} query={query} />
|
||||
</div>
|
||||
|
||||
<QueryViewer
|
||||
query={query}
|
||||
isOpen={showQuery}
|
||||
onOpenChange={onShowQueryChange}
|
||||
trigger={
|
||||
<Collapsible.CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<CodeIcon className="mr-2 h-4 w-4" />
|
||||
{showQuery ? t("common.hide") : t("common.view")}{" "}
|
||||
{t("environments.analysis.charts.query_label")}
|
||||
</Button>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
}
|
||||
/>
|
||||
|
||||
<Collapsible.Root open={showData} onOpenChange={onShowDataChange}>
|
||||
<Collapsible.CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<DatabaseIcon className="mr-2 h-4 w-4" />
|
||||
{showData ? t("common.hide") : t("common.view")}{" "}
|
||||
{t("environments.analysis.charts.data_label")}
|
||||
</Button>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<DataViewer data={chartData} />
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.advanced_chart_builder_config_prompt")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ActivityIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface AIQuerySectionProps {
|
||||
environmentId: string;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
}
|
||||
|
||||
export function AIQuerySection({ environmentId, onChartGenerated }: Readonly<AIQuerySectionProps>) {
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!userQuery.trim()) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const result = await generateAIChartAction({
|
||||
environmentId,
|
||||
prompt: userQuery.trim(),
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
onChartGenerated(result.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<ActivityIcon className="text-brand-dark h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.ai_query_section_title")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.ai_query_section_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex gap-4" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.ai_query_placeholder")}
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
className="flex-1"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}
|
||||
className="bg-brand-dark hover:bg-brand-dark/90">
|
||||
{t("common.generate")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartBuilderGuideProps {
|
||||
/** Optional trigger; when not provided, caller renders their own */
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChartBuilderGuide({ trigger }: Readonly<ChartBuilderGuideProps>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger ?? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setIsOpen(true)}>
|
||||
<HelpCircle className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.guide_button")}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={isOpen} onOpenChange={(isOpen) => !isOpen && setIsOpen(false)}>
|
||||
<DialogContent width="wide" className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.guide_title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_chart_type")}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t("environments.analysis.charts.guide_chart_type_desc")}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_measures")}
|
||||
</h3>
|
||||
<p className="mb-2 text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_measures_predefined")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_measures_custom")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_dimensions")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_dimensions_desc")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_time_dimension")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_time_dimension_desc")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_filters")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_filters_desc")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-2 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_quick_ref")}
|
||||
</h3>
|
||||
<dl className="space-y-1.5 text-sm text-gray-600">
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Measure: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_measure")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Dimension: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_dimension")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Custom aggregation: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_custom")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Time dimension: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_time")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Filter: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_filter")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, SaveIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onAddToDashboardClick: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
}: Readonly<ChartDialogFooterProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ChartDialogLoadingViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChartDialogLoadingView({ open, onClose }: Readonly<ChartDialogLoadingViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogTitle className="sr-only">{t("common.loading")}</DialogTitle>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { 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 type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ChartDropdownMenuProps {
|
||||
environmentId: string;
|
||||
chart: TChartWithCreator;
|
||||
onEdit?: (chartId: string) => void;
|
||||
onInteractionStart?: () => void;
|
||||
}
|
||||
|
||||
export function ChartDropdownMenu({
|
||||
environmentId,
|
||||
chart,
|
||||
onEdit,
|
||||
onInteractionStart,
|
||||
}: Readonly<ChartDropdownMenuProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
|
||||
const handleDeleteChart = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteChartAction({ environmentId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
|
||||
setDeleteDialogOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateChart = async () => {
|
||||
onInteractionStart?.();
|
||||
setIsDuplicating(true);
|
||||
try {
|
||||
const result = await duplicateChartAction({ environmentId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.analysis.charts.chart_duplicated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_duplication_error")
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.charts.chart_duplication_error"));
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
onInteractionStart?.();
|
||||
onEdit?.(chart.id);
|
||||
};
|
||||
|
||||
const handleOpenDeleteDialog = () => {
|
||||
onInteractionStart?.();
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<Button variant="outline" className="px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
|
||||
<MoreVertical className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem icon={<SquarePenIcon className="size-4" />} onClick={handleEdit}>
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<CopyIcon className="size-4" />}
|
||||
onClick={handleDuplicateChart}
|
||||
disabled={isDuplicating}>
|
||||
{t("common.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
onClick={handleOpenDeleteDialog}
|
||||
disabled={isDeleting}>
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("common.chart")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={handleDeleteChart}
|
||||
text={t("environments.analysis.charts.delete_chart_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, DatabaseIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
|
||||
interface ChartPreviewProps {
|
||||
chartData: AnalyticsResponse | null;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function ChartPreview({ chartData, isLoading = false, error }: Readonly<ChartPreviewProps>) {
|
||||
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = chartData?.data ?? [];
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "chart" || value === "data") {
|
||||
setActiveTab(value);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || chartData?.error) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-red-600">
|
||||
{error || chartData?.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartData) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.analysis.charts.chart")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
|
||||
{t("environments.analysis.charts.chart_data_tab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<ChartRenderer chartType={chartData.chartType} data={data} query={chartData.query} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="data" className="mt-0">
|
||||
<DataViewer data={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 font-semibold text-gray-900">{t("environments.analysis.charts.chart_preview")}</h3>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { TChartQuery } from "@formbricks/types/analysis";
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_BRAND_LIGHT,
|
||||
formatCellValue,
|
||||
preparePieData,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import type { ChartConfig } from "@/modules/ui/components/chart";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
|
||||
|
||||
function formatXAxisTick(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
let str: string;
|
||||
if (typeof value === "string") str = value;
|
||||
else if (typeof value === "number") str = String(value);
|
||||
else return "";
|
||||
const date = parseISO(str);
|
||||
if (isValid(date)) return format(date, "MMM d, yyyy");
|
||||
return str;
|
||||
}
|
||||
|
||||
function ChartTooltipRow({ value, dataKey }: Readonly<{ value: unknown; dataKey: string }>) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border border-current"
|
||||
style={{
|
||||
backgroundColor: CHART_BRAND_DARK,
|
||||
borderColor: CHART_BRAND_DARK,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between leading-none">
|
||||
<span className="text-muted-foreground">{formatCubeColumnHeader(dataKey)}</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">{formatCellValue(value)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates a tooltip formatter bound to dataKey for Cartesian charts. Defined at module level to avoid Sonar "component in parent" warnings. */
|
||||
function createTooltipFormatter(dataKey: string) {
|
||||
const Formatter = (value: unknown) => <ChartTooltipRow value={value} dataKey={dataKey} />;
|
||||
Formatter.displayName = "ChartTooltipFormatter";
|
||||
return Formatter;
|
||||
}
|
||||
|
||||
/** Tooltip content for bar/line/area charts with formatted label and value. Extracted to avoid inline component definitions. */
|
||||
function CartesianChartTooltip({ dataKey }: Readonly<{ dataKey: string }>) {
|
||||
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={createTooltipFormatter(dataKey)} />;
|
||||
}
|
||||
|
||||
/** Shared layout for bar, line, and area charts to avoid duplicating grid/axis/tooltip boilerplate. */
|
||||
function CartesianChart({
|
||||
data,
|
||||
xAxisKey,
|
||||
dataKey,
|
||||
chartConfig,
|
||||
chart: Chart,
|
||||
children,
|
||||
}: Readonly<{
|
||||
data: TChartDataRow[];
|
||||
xAxisKey: string;
|
||||
dataKey: string;
|
||||
chartConfig: ChartConfig;
|
||||
chart: ElementType;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="h-64 w-full">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||
<Chart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxisKey}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<CartesianChartTooltip dataKey={dataKey} />} />
|
||||
{children}
|
||||
</Chart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartRendererProps {
|
||||
chartType: TChartType;
|
||||
data: TChartDataRow[];
|
||||
query: TChartQuery;
|
||||
}
|
||||
|
||||
export function ChartRenderer({ chartType, data, query }: Readonly<ChartRendererProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
{t("environments.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dataKey = query.measures?.[0] ?? Object.keys(data[0])[0] ?? "value";
|
||||
const xAxisKey =
|
||||
query.dimensions?.[0] ??
|
||||
query.timeDimensions?.[0]?.dimension ??
|
||||
Object.keys(data[0]).find((k) => k !== dataKey) ??
|
||||
"key";
|
||||
const chartConfig: ChartConfig = {
|
||||
[dataKey]: {
|
||||
label: formatCubeColumnHeader(dataKey),
|
||||
color: CHART_BRAND_DARK,
|
||||
},
|
||||
};
|
||||
|
||||
switch (chartType) {
|
||||
case "bar":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={BarChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKey={dataKey}
|
||||
chartConfig={chartConfig}>
|
||||
<Bar dataKey={dataKey} fill={CHART_BRAND_DARK} radius={4} />
|
||||
</CartesianChart>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={LineChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKey={dataKey}
|
||||
chartConfig={chartConfig}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={CHART_BRAND_DARK}
|
||||
strokeWidth={3}
|
||||
dot={{ fill: CHART_BRAND_DARK, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</CartesianChart>
|
||||
);
|
||||
case "area":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={AreaChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKey={dataKey}
|
||||
chartConfig={chartConfig}>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={CHART_BRAND_DARK}
|
||||
fill={CHART_BRAND_LIGHT}
|
||||
fillOpacity={0.4}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</CartesianChart>
|
||||
);
|
||||
case "pie": {
|
||||
const pieResult = preparePieData(data, dataKey);
|
||||
if (!pieResult) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
{t("environments.analysis.charts.no_valid_data_to_display")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { processedData, colors } = pieResult;
|
||||
|
||||
return (
|
||||
<div className="h-64 w-full min-w-0">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full min-w-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={processedData}
|
||||
dataKey={dataKey}
|
||||
nameKey={xAxisKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={({ name, percent }) => {
|
||||
if (!percent) return "";
|
||||
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
|
||||
}}>
|
||||
{processedData.map((row, index) => {
|
||||
const rowKey = row[xAxisKey] ?? `row-${index}`;
|
||||
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
|
||||
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
|
||||
})}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent />}
|
||||
formatter={(value: number | string, name: string) => [
|
||||
formatCellValue(value),
|
||||
formatCubeColumnHeader(name),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "big_number": {
|
||||
const total = data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-gray-900">{total.toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-gray-500">{formatCubeColumnHeader(dataKey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
{t("environments.analysis.charts.chart_type_not_supported", { chartType })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { convertDateString, timeSinceDate } from "@/lib/time";
|
||||
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
|
||||
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartRowProps {
|
||||
chart: TChartWithCreator;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ChartRow({ chart, environmentId, isReadOnly }: Readonly<ChartRowProps>) {
|
||||
const IconComponent = CHART_TYPE_ICONS[chart.type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
|
||||
|
||||
return (
|
||||
<div className="grid h-12 w-full grid-cols-7 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-6 grid grid-cols-6 content-center p-2">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
{convertDateString(chart.createdAt.toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{timeSinceDate(new Date(chart.updatedAt))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
|
||||
{!isReadOnly && <ChartDropdownMenu environmentId={environmentId} chart={chart} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartTypeSelectorProps {
|
||||
selectedChartType: TChartType | "";
|
||||
onChartTypeSelect: (chartType: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ChartTypeSelector({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ChartTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.chart_builder_choose_chart_type")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Delay } from "@suspensive/react";
|
||||
import { Suspense, use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
|
||||
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
|
||||
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 type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
interface ChartsListContentProps {
|
||||
chartsPromise: Promise<TChartWithCreator[]>;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const ChartsListContent = ({
|
||||
chartsPromise,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<ChartsListContentProps>) => {
|
||||
const charts = use(chartsPromise);
|
||||
|
||||
return <ChartsList charts={charts} environmentId={environmentId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
interface ChartsListPageProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export async function ChartsListPage({ environmentId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
const chartsPromise = getChartsWithCreator(project.id);
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
environmentId={environmentId}
|
||||
cta={isReadOnly ? undefined : <CreateChartButton environmentId={environmentId} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Delay ms={200}>
|
||||
<ChartsListSkeleton
|
||||
columnHeaders={[
|
||||
t("common.title"),
|
||||
t("common.created_by"),
|
||||
t("common.created_at"),
|
||||
t("common.updated_at"),
|
||||
]}
|
||||
/>
|
||||
</Delay>
|
||||
}>
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
const SKELETON_ROWS = 5;
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="grid h-12 w-full animate-pulse grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center gap-4 pl-6">
|
||||
<div className="h-5 w-5 rounded bg-gray-200" />
|
||||
<div className="h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-16 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartsListSkeletonProps {
|
||||
columnHeaders: string[];
|
||||
}
|
||||
|
||||
export function ChartsListSkeleton({ columnHeaders }: Readonly<ChartsListSkeletonProps>) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||
<SkeletonRow key={`skeleton-row-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartsListProps {
|
||||
charts: TChartWithCreator[];
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ChartsList({ charts, environmentId, isReadOnly }: Readonly<ChartsListProps>) {
|
||||
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const filteredCharts = charts;
|
||||
|
||||
const getChartIcon = (type: string) => {
|
||||
const IconComponent = CHART_TYPE_ICONS[type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
|
||||
return <IconComponent className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const handleChartClick = (chartId: string) => {
|
||||
setEditingChartId(chartId);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setIsEditDialogOpen(false);
|
||||
setEditingChartId(undefined);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: React.KeyboardEvent, chartId: string) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleChartClick(chartId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{filteredCharts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.analysis.charts.no_charts_found")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{filteredCharts.map((chart) => (
|
||||
// Cannot use native <button>; row contains dropdown trigger (nested interactive invalid)
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
key={chart.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleChartClick(chart.id)}
|
||||
onKeyDown={(e) => handleRowKeyDown(e, chart.id)}
|
||||
aria-label={t("environments.analysis.charts.open_chart", { name: chart.name })}
|
||||
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||
{getChartIcon(chart.type)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
{format(new Date(chart.createdAt), "do 'of' MMMM, yyyy")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
{formatDistanceToNow(new Date(chart.updatedAt), {
|
||||
addSuffix: true,
|
||||
}).replace("about", "")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="col-span-1 my-auto flex items-center justify-end pr-6"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{!isReadOnly && (
|
||||
<ChartDropdownMenu
|
||||
environmentId={environmentId}
|
||||
chart={chart}
|
||||
onEdit={(chartId) => {
|
||||
setEditingChartId(chartId);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<CreateChartDialog
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
environmentId={environmentId}
|
||||
chartId={editingChartId}
|
||||
initialChart={editingChartId ? filteredCharts.find((c) => c.id === editingChartId) : undefined}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ConfigureChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentChartType: string;
|
||||
configuredChartType: string | null;
|
||||
onChartTypeSelect: (type: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ConfigureChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentChartType,
|
||||
configuredChartType,
|
||||
onChartTypeSelect,
|
||||
onReset,
|
||||
}: Readonly<ConfigureChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.configure_title")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.analysis.charts.configure_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-md mb-3 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.configure_type_label")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = (configuredChartType || currentChartType) === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
|
||||
isSelected
|
||||
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
|
||||
: "border-gray-200"
|
||||
)}
|
||||
aria-label={chart.label}>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onReset} className="text-sm">
|
||||
{t("environments.analysis.charts.reset_to_ai_suggestion")}
|
||||
</Button>
|
||||
{configuredChartType && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.original")}:{" "}
|
||||
{chartTypes.find((c) => c.id === currentChartType)?.label ?? currentChartType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t("environments.analysis.charts.apply_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateChartButtonProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function CreateChartButton({ environmentId }: Readonly<CreateChartButtonProps>) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.create_chart")}
|
||||
</Button>
|
||||
<CreateChartDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} environmentId={environmentId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
|
||||
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
|
||||
import { useCreateChartDialog } from "@/modules/ee/analysis/charts/hooks/use-create-chart-dialog";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CreateChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
environmentId: string;
|
||||
chartId?: string;
|
||||
/** Pre-loaded chart metadata from list; skips getChartAction when provided */
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function CreateChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
chartId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
}: Readonly<CreateChartDialogProps>) {
|
||||
const hook = useCreateChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
chartId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const {
|
||||
chartData,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
initialQuery,
|
||||
setSelectedChartType,
|
||||
handleChartTypeChange,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
isSaving,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
shouldShowAdvancedBuilder,
|
||||
handleChartGenerated,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
handleClose,
|
||||
handleAdvancedBuilderSave,
|
||||
handleAdvancedBuilderAddToDashboard,
|
||||
} = hook;
|
||||
|
||||
if (chartId && isLoadingChart && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (chartId && (chartData || initialChart)) {
|
||||
return (
|
||||
<EditChartView
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
environmentId={environmentId}
|
||||
chartData={chartData ?? null}
|
||||
initialQuery={initialQuery}
|
||||
isLoadingChart={isLoadingChart}
|
||||
chartLoadError={chartLoadError}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeChange={handleChartTypeChange}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
onAdvancedBuilderSave={handleAdvancedBuilderSave}
|
||||
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onAddToDashboard={handleAddToDashboard}
|
||||
onSave={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
|
||||
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateChartView
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
environmentId={environmentId}
|
||||
chartId={chartId}
|
||||
chartData={chartData}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
selectedChartType={selectedChartType}
|
||||
onSelectedChartTypeChange={setSelectedChartType}
|
||||
shouldShowAdvancedBuilder={shouldShowAdvancedBuilder}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
onAdvancedBuilderSave={handleAdvancedBuilderSave}
|
||||
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onAddToDashboard={handleAddToDashboard}
|
||||
onSave={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
isSaveDialogOpen={isSaveDialogOpen}
|
||||
onSaveDialogOpenChange={setIsSaveDialogOpen}
|
||||
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
|
||||
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
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 type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
|
||||
interface CreateChartViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
environmentId: string;
|
||||
chartId?: string;
|
||||
chartData: AnalyticsResponse | null;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
selectedChartType: TChartType | "";
|
||||
onSelectedChartTypeChange: (type: TChartType) => void;
|
||||
shouldShowAdvancedBuilder: boolean;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
onAdvancedBuilderSave: (savedChartId: string) => void;
|
||||
onAdvancedBuilderAddToDashboard: (savedChartId: string, _dashboardId?: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onAddToDashboard: () => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
isSaveDialogOpen: boolean;
|
||||
onSaveDialogOpenChange: (open: boolean) => void;
|
||||
isAddToDashboardDialogOpen: boolean;
|
||||
onAddToDashboardDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateChartView({
|
||||
open,
|
||||
onClose,
|
||||
environmentId,
|
||||
chartId,
|
||||
chartData,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
selectedChartType,
|
||||
onSelectedChartTypeChange,
|
||||
shouldShowAdvancedBuilder,
|
||||
onChartGenerated,
|
||||
onAdvancedBuilderSave,
|
||||
onAdvancedBuilderAddToDashboard,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onAddToDashboard,
|
||||
onSave,
|
||||
isSaving,
|
||||
isSaveDialogOpen,
|
||||
onSaveDialogOpenChange,
|
||||
isAddToDashboardDialogOpen,
|
||||
onAddToDashboardDialogOpenChange,
|
||||
}: Readonly<CreateChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartData) {
|
||||
chartPreviewRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [chartData]);
|
||||
|
||||
const handleAdvancedChartGenerated = (data: AnalyticsResponse) => {
|
||||
onChartGenerated(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{chartId
|
||||
? t("environments.analysis.charts.edit_chart_title")
|
||||
: t("environments.analysis.charts.create_chart")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{chartId
|
||||
? t("environments.analysis.charts.edit_chart_description")
|
||||
: t("environments.analysis.charts.create_chart_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4">
|
||||
<AIQuerySection environmentId={environmentId} onChartGenerated={onChartGenerated} />
|
||||
|
||||
<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-gray-50 px-2 text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ChartBuilderGuide />
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={onSelectedChartTypeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shouldShowAdvancedBuilder && (
|
||||
<AdvancedChartBuilder
|
||||
environmentId={environmentId}
|
||||
initialChartType={selectedChartType || chartData?.chartType}
|
||||
initialQuery={chartData?.query}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleAdvancedChartGenerated}
|
||||
onSave={onAdvancedBuilderSave}
|
||||
onAddToDashboard={onAdvancedBuilderAddToDashboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartData && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
{chartData && (
|
||||
<>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={() => onSaveDialogOpenChange(true)}
|
||||
onAddToDashboardClick={() => onAddToDashboardDialogOpenChange(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SaveChartDialog
|
||||
open={isSaveDialogOpen}
|
||||
onOpenChange={onSaveDialogOpenChange}
|
||||
chartName={chartName}
|
||||
onChartNameChange={onChartNameChange}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={onAddToDashboardDialogOpenChange}
|
||||
chartName={chartName}
|
||||
onChartNameChange={onChartNameChange}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={onDashboardSelect}
|
||||
onConfirm={onAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DatabaseIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatCellValue } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface DataViewerProps {
|
||||
data: TChartDataRow[];
|
||||
}
|
||||
|
||||
export function DataViewer({ data }: Readonly<DataViewerProps>) {
|
||||
const { t } = useTranslation();
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-500">{t("environments.analysis.charts.no_data_available")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = Object.keys(data[0]);
|
||||
const displayData = data.slice(0, 50);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.chart_data")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto rounded bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
{columns.map((key) => (
|
||||
<th key={key} className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
|
||||
{formatCubeColumnHeader(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayData.map((row, index) => {
|
||||
const rowKey = Object.values(row)[0] ? String(Object.values(row)[0]) : `row-${index}`;
|
||||
return (
|
||||
<tr key={`data-row-${rowKey}-${index}`} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
{Object.entries(row).map(([key, value]) => (
|
||||
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
|
||||
{formatCellValue(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.length > 50 && (
|
||||
<div className="px-3 py-2 text-xs text-gray-500">
|
||||
{t("environments.analysis.charts.showing_first_n_of", { n: 50, count: data.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
|
||||
interface DimensionsPanelProps {
|
||||
selectedDimensions: string[];
|
||||
onDimensionsChange: (dimensions: string[]) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function DimensionsPanel({
|
||||
selectedDimensions,
|
||||
onDimensionsChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<DimensionsPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||
value: d.id,
|
||||
label: [d.label, d.description].filter(Boolean).join(" - "),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.dimensions")}
|
||||
</h3>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.group_by")}</label>
|
||||
<MultiSelect
|
||||
options={dimensionOptions}
|
||||
value={selectedDimensions}
|
||||
onChange={onDimensionsChange}
|
||||
placeholder={t("environments.analysis.charts.select_measures")}
|
||||
/>
|
||||
<p className="text-sm text-gray-500">{t("environments.analysis.charts.group_by_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +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 { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface EditChartViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
environmentId: string;
|
||||
chartData: AnalyticsResponse | null;
|
||||
/** Query from initialChart when chartData is still loading */
|
||||
initialQuery?: AnalyticsResponse["query"];
|
||||
isLoadingChart?: boolean;
|
||||
chartLoadError?: string | null;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
selectedChartType: TChartType | "";
|
||||
onChartTypeChange: (type: TChartType) => void;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
onAdvancedBuilderSave: (savedChartId: string) => void;
|
||||
onAdvancedBuilderAddToDashboard: (savedChartId: string, dashboardId?: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onAddToDashboard: () => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
isAddToDashboardDialogOpen: boolean;
|
||||
onAddToDashboardDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditChartView({
|
||||
open,
|
||||
onClose,
|
||||
environmentId,
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart = false,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
selectedChartType,
|
||||
onChartTypeChange,
|
||||
onChartGenerated,
|
||||
onAdvancedBuilderSave,
|
||||
onAdvancedBuilderAddToDashboard,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onAddToDashboard,
|
||||
onSave,
|
||||
isSaving,
|
||||
isAddToDashboardDialogOpen,
|
||||
onAddToDashboardDialogOpenChange,
|
||||
}: Readonly<EditChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.edit_chart_title")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.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("environments.analysis.charts.chart_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-chart-name"
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ChartBuilderGuide />
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={onChartTypeChange}
|
||||
/>
|
||||
</div>
|
||||
<AdvancedChartBuilder
|
||||
environmentId={environmentId}
|
||||
initialChartType={selectedChartType || chartData?.chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={onChartGenerated}
|
||||
onSave={onAdvancedBuilderSave}
|
||||
onAddToDashboard={onAdvancedBuilderAddToDashboard}
|
||||
/>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
</DialogBody>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={onSave}
|
||||
onAddToDashboardClick={() => onAddToDashboardDialogOpenChange(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={onAddToDashboardDialogOpenChange}
|
||||
chartName={chartName}
|
||||
onChartNameChange={onChartNameChange}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={onDashboardSelect}
|
||||
onConfirm={onAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
getFieldById,
|
||||
getFilterOperatorsForType,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface FiltersPanelProps {
|
||||
filters: FilterRow[];
|
||||
filterLogic: "and" | "or";
|
||||
onFiltersChange: (filters: FilterRow[]) => void;
|
||||
onFilterLogicChange: (logic: "and" | "or") => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function FiltersPanel({
|
||||
filters,
|
||||
filterLogic,
|
||||
onFiltersChange,
|
||||
onFilterLogicChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<FiltersPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const fieldOptions = [
|
||||
...FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.label,
|
||||
type: d.type,
|
||||
})),
|
||||
...FEEDBACK_FIELDS.measures.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.label,
|
||||
type: "number" as TFilterFieldType,
|
||||
})),
|
||||
];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const firstField = fieldOptions[0];
|
||||
onFiltersChange([
|
||||
...filters,
|
||||
{
|
||||
field: firstField?.value || "",
|
||||
operator: "equals",
|
||||
values: null,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (index: number) => {
|
||||
onFiltersChange(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
|
||||
const updated = [...filters];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
// Reset values if operator changed to set/notSet
|
||||
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
|
||||
updated[index].values = null;
|
||||
}
|
||||
onFiltersChange(updated);
|
||||
};
|
||||
|
||||
const getValueInput = (filter: FilterRow, index: number) => {
|
||||
const field = getFieldById(filter.field);
|
||||
const fieldType = (field?.type || "string") as TFilterFieldType;
|
||||
|
||||
// For set/notSet operators, no value input needed
|
||||
if (filter.operator === "set" || filter.operator === "notSet") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For number fields with comparison operators, use number input
|
||||
if (
|
||||
fieldType === "number" &&
|
||||
(filter.operator === "gt" ||
|
||||
filter.operator === "gte" ||
|
||||
filter.operator === "lt" ||
|
||||
filter.operator === "lte")
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [Number(e.target.value)] : null,
|
||||
})
|
||||
}
|
||||
className="w-[150px] bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For equals/notEquals with string fields, allow single value
|
||||
if ((filter.operator === "equals" || filter.operator === "notEquals") && fieldType === "string") {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [e.target.value] : null,
|
||||
})
|
||||
}
|
||||
className="w-[200px] bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For contains/notContains, allow multiple values (multi-select)
|
||||
if (filter.operator === "contains" || filter.operator === "notContains") {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [e.target.value] : null,
|
||||
})
|
||||
}
|
||||
className="w-[200px] bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: single value input
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [e.target.value] : null,
|
||||
})
|
||||
}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const hasFilters = filters.length > 0;
|
||||
const hasMultipleFilters = filters.length > 1;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{hasMultipleFilters && (
|
||||
<div className={`flex items-center ${hideTitle ? "justify-end" : "justify-between"}`}>
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.filters")}
|
||||
</h3>
|
||||
)}
|
||||
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
|
||||
<SelectTrigger className="w-[100px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="and">{t("common.and")}</SelectItem>
|
||||
<SelectItem value="or">{t("environments.analysis.charts.or_filter_logic")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filters.map((filter, index) => {
|
||||
const field = getFieldById(filter.field);
|
||||
const fieldType = (field?.type || "string") as "string" | "number" | "time";
|
||||
const operators = getFilterOperatorsForType(fieldType);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filter.operator + index}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||
<Select
|
||||
value={filter.field}
|
||||
onValueChange={(value) => {
|
||||
const newField = getFieldById(value);
|
||||
const newType = (newField?.type || "string") as TFilterFieldType;
|
||||
const newOperators = getFilterOperatorsForType(newType);
|
||||
handleUpdateFilter(index, {
|
||||
field: value,
|
||||
operator: newOperators[0] || "equals",
|
||||
values: null,
|
||||
});
|
||||
}}>
|
||||
<SelectTrigger className="w-[200px] bg-white">
|
||||
<SelectValue placeholder={t("environments.analysis.charts.select_field")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateFilter(index, {
|
||||
operator: value,
|
||||
})
|
||||
}>
|
||||
<SelectTrigger className="w-[150px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op} value={op}>
|
||||
{op === "equals" && t("environments.analysis.charts.equals")}
|
||||
{op === "notEquals" && t("environments.analysis.charts.not_equals")}
|
||||
{op === "contains" && t("environments.analysis.charts.contains")}
|
||||
{op === "notContains" && t("environments.analysis.charts.not_contains")}
|
||||
{op === "set" && t("environments.analysis.charts.is_set")}
|
||||
{op === "notSet" && t("environments.analysis.charts.is_not_set")}
|
||||
{op === "gt" && t("environments.analysis.charts.greater_than")}
|
||||
{op === "gte" && t("environments.analysis.charts.greater_than_or_equal")}
|
||||
{op === "lt" && t("environments.analysis.charts.less_than")}
|
||||
{op === "lte" && t("environments.analysis.charts.less_than_or_equal")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{getValueInput(filter, index)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveFilter(index)}
|
||||
className="h-8 w-8">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasFilters && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("environments.analysis.charts.add_filter")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ManualChartBuilderProps {
|
||||
selectedChartType: TChartType | "";
|
||||
onChartTypeSelect: (type: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ManualChartBuilder({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ManualChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={cn(
|
||||
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CustomMeasure } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface MeasuresPanelProps {
|
||||
selectedMeasures: string[];
|
||||
customMeasures: CustomMeasure[];
|
||||
customAggregationsOpen: boolean;
|
||||
onCustomAggregationsOpenChange: (open: boolean) => void;
|
||||
onMeasuresChange: (measures: string[]) => void;
|
||||
onCustomMeasuresChange: (measures: CustomMeasure[]) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function MeasuresPanel({
|
||||
selectedMeasures,
|
||||
customMeasures,
|
||||
customAggregationsOpen,
|
||||
onCustomAggregationsOpenChange,
|
||||
onMeasuresChange,
|
||||
onCustomMeasuresChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<MeasuresPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
|
||||
value: m.id,
|
||||
label: [m.label, m.description].filter(Boolean).join(" - "),
|
||||
}));
|
||||
|
||||
const dimensionOptions = FEEDBACK_FIELDS.dimensions
|
||||
.filter((d) => d.type === "number")
|
||||
.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.label,
|
||||
}));
|
||||
|
||||
const aggregationOptions = FEEDBACK_FIELDS.customAggregations.map((agg) => ({
|
||||
value: agg,
|
||||
label: agg.charAt(0).toUpperCase() + agg.slice(1),
|
||||
}));
|
||||
|
||||
const handleAddCustomMeasure = () => {
|
||||
onCustomMeasuresChange([
|
||||
...customMeasures,
|
||||
{
|
||||
id: `measure-${crypto.randomUUID()}`,
|
||||
field: dimensionOptions[0]?.value || "",
|
||||
aggregation: "avg",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCustomMeasure = (index: number) => {
|
||||
onCustomMeasuresChange(customMeasures.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdateCustomMeasure = (index: number, updates: Partial<CustomMeasure>) => {
|
||||
const updated = [...customMeasures];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
onCustomMeasuresChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">{t("environments.analysis.charts.measures")}</h3>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{/* Predefined Measures */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.predefined_measures")}</label>
|
||||
<MultiSelect
|
||||
options={measureOptions}
|
||||
value={selectedMeasures}
|
||||
onChange={(selected) => onMeasuresChange(selected)}
|
||||
placeholder={t("environments.analysis.charts.select_measures")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Aggregations */}
|
||||
<AdvancedOptionToggle
|
||||
isChecked={customAggregationsOpen}
|
||||
onToggle={onCustomAggregationsOpenChange}
|
||||
htmlId="chart-custom-aggregations-toggle"
|
||||
title={t("environments.analysis.charts.custom_aggregations")}
|
||||
description={t("environments.analysis.charts.custom_aggregations_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<div className="w-full space-y-2">
|
||||
{customMeasures.map((measure, index) => (
|
||||
<div
|
||||
key={measure.id ?? `custom-measure-${index}`}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||
<Select
|
||||
value={measure.field}
|
||||
onValueChange={(value) => handleUpdateCustomMeasure(index, { field: value })}>
|
||||
<SelectTrigger className="w-[200px] bg-white">
|
||||
<SelectValue placeholder={t("environments.analysis.charts.select_field")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dimensionOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={measure.aggregation}
|
||||
onValueChange={(value) => handleUpdateCustomMeasure(index, { aggregation: value })}>
|
||||
<SelectTrigger className="w-[150px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aggregationOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.alias_optional")}
|
||||
value={measure.alias || ""}
|
||||
onChange={(e) => handleUpdateCustomMeasure(index, { alias: e.target.value })}
|
||||
className="flex-1 bg-white"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveCustomMeasure(index)}
|
||||
className="h-8 w-8">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{customMeasures.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddCustomMeasure}
|
||||
className="h-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("environments.analysis.charts.add_custom_measure")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CodeIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface QueryViewerProps {
|
||||
query: Record<string, unknown>;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Optional trigger; when provided, renders as CollapsibleTrigger for collapsible UX */
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function QueryViewer({ query, isOpen, onOpenChange, trigger }: Readonly<QueryViewerProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||
{trigger}
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<CodeIcon className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.cube_js_query")}
|
||||
</h4>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto rounded bg-white p-3 text-xs">
|
||||
{JSON.stringify(query, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface SaveChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function SaveChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: Readonly<SaveChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.save_chart_dialog_title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.analysis.charts.enter_a_name_for_your_chart")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && chartName.trim() && !isSaving) {
|
||||
onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Calendar from "react-calendar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TimeDimensionConfig } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
FEEDBACK_FIELDS,
|
||||
TIME_GRANULARITIES,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import "@/modules/ui/components/date-picker/styles.css";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface TimeDimensionPanelProps {
|
||||
timeDimension: TimeDimensionConfig | null;
|
||||
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function TimeDimensionPanel({
|
||||
timeDimension,
|
||||
onTimeDimensionChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<TimeDimensionPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
|
||||
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
|
||||
);
|
||||
const [customStartDate, setCustomStartDate] = useState<Date | null>(
|
||||
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
|
||||
);
|
||||
const [customEndDate, setCustomEndDate] = useState<Date | null>(
|
||||
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
|
||||
);
|
||||
const [presetValue, setPresetValue] = useState<string>(
|
||||
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
|
||||
);
|
||||
|
||||
const timeFieldOptions = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
|
||||
|
||||
const handleEnableTimeDimension = () => {
|
||||
if (!timeDimension) {
|
||||
onTimeDimensionChange({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: "last 30 days",
|
||||
});
|
||||
setPresetValue("last 30 days");
|
||||
setDateRangeType("preset");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionChange = (dimension: string) => {
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({ ...timeDimension, dimension });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGranularityChange = (value: string) => {
|
||||
if (timeDimension) {
|
||||
const granularity = value === "none" ? undefined : (value as TimeDimensionConfig["granularity"]);
|
||||
onTimeDimensionChange({ ...timeDimension, granularity });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetChange = (preset: string) => {
|
||||
setPresetValue(preset);
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
|
||||
}
|
||||
};
|
||||
|
||||
if (!timeDimension) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.time_dimension")}
|
||||
</h3>
|
||||
)}
|
||||
<div>
|
||||
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
|
||||
{t("environments.analysis.charts.enable_time_dimension")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.time_dimension")}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Field Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.field")}</label>
|
||||
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeFieldOptions.map((field) => (
|
||||
<SelectItem key={field.id} value={field.id}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Granularity Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.granularity")}</label>
|
||||
<Select value={timeDimension.granularity ?? "none"} onValueChange={handleGranularityChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("environments.analysis.charts.no_grouping")}</SelectItem>
|
||||
{TIME_GRANULARITIES.map((gran) => (
|
||||
<SelectItem key={gran} value={gran}>
|
||||
{gran.charAt(0).toUpperCase() + gran.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.date_range")}</label>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={dateRangeType}
|
||||
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preset">{t("environments.analysis.charts.preset")}</SelectItem>
|
||||
<SelectItem value="custom">{t("environments.analysis.charts.custom_range")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateRangeType === "preset" ? (
|
||||
<Select value={presetValue} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue placeholder={t("environments.analysis.charts.select_preset")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{presetValue && !DATE_PRESETS.some((p) => p.value === presetValue) && (
|
||||
<SelectItem key={presetValue} value={presetValue}>
|
||||
{presetValue}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{customStartDate
|
||||
? format(customStartDate, "MMM dd, yyyy")
|
||||
: t("environments.analysis.charts.start_date")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
onChange={(date: Date) => {
|
||||
setCustomStartDate(date);
|
||||
if (timeDimension && date && customEndDate) {
|
||||
onTimeDimensionChange({
|
||||
...timeDimension,
|
||||
dateRange: [date, customEndDate],
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={customStartDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{customEndDate
|
||||
? format(customEndDate, "MMM dd, yyyy")
|
||||
: t("environments.analysis.charts.end_date")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
onChange={(date: Date) => {
|
||||
setCustomEndDate(date);
|
||||
if (timeDimension && customStartDate && date) {
|
||||
onTimeDimensionChange({
|
||||
...timeDimension,
|
||||
dateRange: [customStartDate, date],
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={customEndDate || undefined}
|
||||
minDate={customStartDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
createChartAction,
|
||||
executeQueryAction,
|
||||
getChartAction,
|
||||
updateChartAction,
|
||||
} from "@/modules/ee/analysis/charts/actions";
|
||||
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { AnalyticsResponse, TChartType, TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface UseCreateChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
environmentId: string;
|
||||
chartId?: string;
|
||||
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
|
||||
initialChart?: TChartWithCreator;
|
||||
defaultDashboardId?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function useCreateChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
chartId,
|
||||
initialChart,
|
||||
defaultDashboardId,
|
||||
onSuccess,
|
||||
}: Readonly<UseCreateChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedChartType, setSelectedChartType] = useState<TChartType | "">("");
|
||||
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 }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>(defaultDashboardId ?? "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
|
||||
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||
const router = useRouter();
|
||||
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddToDashboardDialogOpen) {
|
||||
getDashboardsAction({ environmentId }).then((result) => {
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
|
||||
} else if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAddToDashboardDialogOpen, environmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && chartId) {
|
||||
const chartMetadata = initialChart?.id === chartId ? initialChart : undefined;
|
||||
|
||||
if (chartMetadata) {
|
||||
setChartName(chartMetadata.name);
|
||||
setSelectedChartType(resolveChartType(chartMetadata.type));
|
||||
setCurrentChartId(chartMetadata.id);
|
||||
}
|
||||
|
||||
setIsLoadingChart(true);
|
||||
setChartLoadError(null);
|
||||
|
||||
const loadChartData = async (query: TChartWithCreator["query"], chartType: string) => {
|
||||
const queryResult = await executeQueryAction({
|
||||
environmentId,
|
||||
query,
|
||||
});
|
||||
|
||||
if (queryResult?.serverError) {
|
||||
const errorMsg =
|
||||
getFormattedErrorMessage(queryResult) ||
|
||||
t("environments.analysis.charts.failed_to_load_chart_data");
|
||||
toast.error(errorMsg);
|
||||
setChartLoadError(errorMsg);
|
||||
setIsLoadingChart(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = Array.isArray(queryResult?.data) ? queryResult.data : undefined;
|
||||
if (data) {
|
||||
setChartData({
|
||||
query,
|
||||
chartType: resolveChartType(chartType),
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
const errorMsg = t("environments.analysis.charts.no_data_returned_for_chart");
|
||||
toast.error(errorMsg);
|
||||
setChartLoadError(errorMsg);
|
||||
}
|
||||
setIsLoadingChart(false);
|
||||
};
|
||||
|
||||
if (chartMetadata) {
|
||||
loadChartData(chartMetadata.query, chartMetadata.type);
|
||||
} else {
|
||||
getChartAction({ environmentId, chartId })
|
||||
.then(async (result) => {
|
||||
if (result?.data) {
|
||||
const chart = result.data;
|
||||
setChartName(chart.name);
|
||||
setSelectedChartType(resolveChartType(chart.type));
|
||||
setCurrentChartId(chart.id);
|
||||
await loadChartData(chart.query, chart.type);
|
||||
} else if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsLoadingChart(false);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_load_chart");
|
||||
toast.error(message);
|
||||
setChartLoadError(message);
|
||||
setIsLoadingChart(false);
|
||||
});
|
||||
}
|
||||
} else if (open && !chartId) {
|
||||
setChartData(null);
|
||||
setChartName("");
|
||||
setSelectedChartType("");
|
||||
setCurrentChartId(undefined);
|
||||
}
|
||||
}, [open, chartId, environmentId, initialChart]);
|
||||
|
||||
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||
setChartData(data);
|
||||
if (!currentChartId) {
|
||||
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
|
||||
}
|
||||
setSelectedChartType(data.chartType);
|
||||
};
|
||||
|
||||
const handleSaveChart = async () => {
|
||||
if (!chartData || !chartName.trim()) {
|
||||
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (currentChartId) {
|
||||
const result = await updateChartAction({
|
||||
environmentId,
|
||||
chartId: currentChartId,
|
||||
chartUpdateInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(chartData.chartType),
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_updated_successfully"));
|
||||
setIsSaveDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const result = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(chartData.chartType),
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
setCurrentChartId(result.data.id);
|
||||
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
|
||||
setIsSaveDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_save_chart");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToDashboard = async () => {
|
||||
if (!chartData || !selectedDashboardId) {
|
||||
toast.error(t("environments.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let chartIdToUse = currentChartId;
|
||||
|
||||
if (!chartIdToUse) {
|
||||
if (!chartName.trim()) {
|
||||
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const chartResult = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(chartData.chartType),
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chartResult?.data) {
|
||||
toast.error(
|
||||
(chartResult && getFormattedErrorMessage(chartResult)) ||
|
||||
t("environments.analysis.charts.failed_to_save_chart")
|
||||
);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
chartIdToUse = chartResult.data.id;
|
||||
setCurrentChartId(chartResult.data.id);
|
||||
}
|
||||
|
||||
const widgetResult = await addChartToDashboardAction({
|
||||
environmentId,
|
||||
chartId: chartIdToUse,
|
||||
dashboardId: selectedDashboardId,
|
||||
title: chartName.trim(),
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
});
|
||||
|
||||
if (!widgetResult?.data) {
|
||||
toast.error(
|
||||
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
|
||||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setChartData(null);
|
||||
setChartName("");
|
||||
setSelectedChartType("");
|
||||
setCurrentChartId(undefined);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvancedBuilderSave = (savedChartId: string) => {
|
||||
setCurrentChartId(savedChartId);
|
||||
setIsSaveDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const handleAdvancedBuilderAddToDashboard = (savedChartId: string) => {
|
||||
setCurrentChartId(savedChartId);
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const handleChartTypeChange = (type: TChartType) => {
|
||||
setSelectedChartType(type);
|
||||
setChartData((prev) => (prev ? { ...prev, chartType: type } : null));
|
||||
};
|
||||
|
||||
const initialQuery = initialChart && initialChart.id === chartId ? initialChart.query : undefined;
|
||||
|
||||
return {
|
||||
chartData,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
initialQuery,
|
||||
setSelectedChartType,
|
||||
currentChartId,
|
||||
setCurrentChartId,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
isSaving,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
shouldShowAdvancedBuilder,
|
||||
handleChartGenerated,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
handleClose,
|
||||
handleAdvancedBuilderSave,
|
||||
handleAdvancedBuilderAddToDashboard,
|
||||
handleChartTypeChange,
|
||||
};
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createChartAction, deleteChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface ChartInput {
|
||||
query: TChartQuery;
|
||||
chartType: TChartType;
|
||||
}
|
||||
|
||||
export interface UseSaveDashboardDialogsProps {
|
||||
environmentId: string;
|
||||
/** Returns current query and chart type when save/add is triggered; null if not ready */
|
||||
getChartInput: () => ChartInput | null;
|
||||
onSave?: (chartId: string) => void;
|
||||
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
|
||||
}
|
||||
|
||||
export function useSaveDashboardDialogs({
|
||||
environmentId,
|
||||
getChartInput,
|
||||
onSave,
|
||||
onAddToDashboard,
|
||||
}: Readonly<UseSaveDashboardDialogsProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [chartName, setChartName] = useState("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddToDashboardDialogOpen) {
|
||||
getDashboardsAction({ environmentId }).then((result) => {
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
|
||||
} else if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAddToDashboardDialogOpen, environmentId]);
|
||||
|
||||
const handleSaveChart = async () => {
|
||||
const input = getChartInput();
|
||||
if (!input) return;
|
||||
if (!chartName.trim()) {
|
||||
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(input.chartType),
|
||||
query: input.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(
|
||||
(result && getFormattedErrorMessage(result)) ||
|
||||
t("environments.analysis.charts.failed_to_save_chart")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
|
||||
setIsSaveDialogOpen(false);
|
||||
if (onSave) {
|
||||
onSave(result.data.id);
|
||||
} else {
|
||||
router.push(`/environments/${environmentId}/analysis/charts`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_save_chart");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToDashboard = async () => {
|
||||
const input = getChartInput();
|
||||
if (!input || !selectedDashboardId) {
|
||||
toast.error(t("environments.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
const name = chartName.trim() || `Chart ${new Date().toISOString().slice(0, 19)}`;
|
||||
|
||||
setIsSaving(true);
|
||||
let chartId: string | null = null;
|
||||
try {
|
||||
const chartResult = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name,
|
||||
type: resolveChartType(input.chartType),
|
||||
query: input.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chartResult?.data) {
|
||||
toast.error(
|
||||
(chartResult && getFormattedErrorMessage(chartResult)) ||
|
||||
t("environments.analysis.charts.failed_to_save_chart")
|
||||
);
|
||||
return;
|
||||
}
|
||||
chartId = chartResult.data.id;
|
||||
|
||||
const widgetResult = await addChartToDashboardAction({
|
||||
environmentId,
|
||||
chartId,
|
||||
dashboardId: selectedDashboardId,
|
||||
title: name,
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
});
|
||||
|
||||
if (!widgetResult?.data) {
|
||||
toast.error(
|
||||
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
|
||||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
await deleteChartAction({ environmentId, chartId }).catch(() => {
|
||||
/* best-effort cleanup of orphan chart */
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
if (onAddToDashboard) {
|
||||
onAddToDashboard(chartId, selectedDashboardId);
|
||||
} else {
|
||||
router.push(`/environments/${environmentId}/analysis/dashboards/${selectedDashboardId}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
|
||||
toast.error(message);
|
||||
if (chartId) {
|
||||
await deleteChartAction({ environmentId, chartId }).catch(() => {
|
||||
/* best-effort cleanup of orphan chart */
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
chartName,
|
||||
setChartName,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
isSaving,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types";
|
||||
|
||||
describe("chart-types", () => {
|
||||
test("CHART_TYPE_ICONS has all chart types", () => {
|
||||
expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
});
|
||||
|
||||
test("getChartTypes returns chart types with translated labels", () => {
|
||||
const t = vi.fn((key: string) => key);
|
||||
const result = getChartTypes(t);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
expect(t).toHaveBeenCalledWith("environments.analysis.charts.chart_type_area");
|
||||
expect(result[0].label).toBe("environments.analysis.charts.chart_type_area");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const CHART_TYPE_ICONS: Record<
|
||||
TChartType,
|
||||
React.ComponentType<{ className?: string; strokeWidth?: number }>
|
||||
> = {
|
||||
area: AreaChartIcon,
|
||||
bar: BarChart3Icon,
|
||||
line: LineChartIcon,
|
||||
pie: PieChartIcon,
|
||||
big_number: ActivityIcon,
|
||||
};
|
||||
|
||||
export function getChartTypes(t: TFunction): readonly {
|
||||
id: TChartType;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
}[] {
|
||||
return [
|
||||
{ id: "area", icon: CHART_TYPE_ICONS.area, label: t("environments.analysis.charts.chart_type_area") },
|
||||
{ id: "bar", icon: CHART_TYPE_ICONS.bar, label: t("environments.analysis.charts.chart_type_bar") },
|
||||
{ id: "line", icon: CHART_TYPE_ICONS.line, label: t("environments.analysis.charts.chart_type_line") },
|
||||
{ id: "pie", icon: CHART_TYPE_ICONS.pie, label: t("environments.analysis.charts.chart_type_pie") },
|
||||
{
|
||||
id: "big_number",
|
||||
icon: CHART_TYPE_ICONS.big_number,
|
||||
label: t("environments.analysis.charts.chart_type_big_number"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { ZChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const CHART_BRAND_DARK = "#00C4B8";
|
||||
export const CHART_BRAND_LIGHT = "#00E6CA";
|
||||
|
||||
/** Validate a chart type string, defaulting to "bar" if unrecognised. */
|
||||
export const resolveChartType = (raw: string): TChartType => {
|
||||
const parsed = ZChartType.safeParse(raw);
|
||||
return parsed.success ? parsed.data : "bar";
|
||||
};
|
||||
|
||||
const isNumericValue = (val: TChartDataRow[string]): boolean => {
|
||||
if (val === null || val === undefined || val === "") return false;
|
||||
const num = Number(val);
|
||||
return !Number.isNaN(num) && Number.isFinite(num);
|
||||
};
|
||||
|
||||
export const preparePieData = (
|
||||
data: TChartDataRow[],
|
||||
dataKey: string
|
||||
): { processedData: TChartDataRow[]; colors: string[] } | null => {
|
||||
const validData = data.filter((row) => isNumericValue(row[dataKey]));
|
||||
const processedData = validData.map((row) => ({ ...row, [dataKey]: Number(row[dataKey]) }));
|
||||
if (processedData.length === 0) return null;
|
||||
|
||||
const colors = processedData.map((_, i) => {
|
||||
const sat = 70 + (i % 3) * 10;
|
||||
const light = 45 + (i % 2) * 15;
|
||||
return `hsl(180, ${sat}%, ${light}%)`;
|
||||
});
|
||||
if (colors.length > 0) colors[0] = CHART_BRAND_DARK;
|
||||
if (colors.length > 1) colors[1] = CHART_BRAND_LIGHT;
|
||||
return { processedData, colors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a cell value for display in tables and tooltips.
|
||||
* ISO date strings become "MMM d, yyyy"; numbers stay as-is (formatted); objects are stringified.
|
||||
*/
|
||||
export function formatCellValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "number") return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
if (typeof value === "string") {
|
||||
const date = parseISO(value);
|
||||
if (isValid(date)) return format(date, "MMM d, yyyy");
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
if (typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
const ALLOWED_CUBE_PREFIX = "FeedbackRecords.";
|
||||
|
||||
function validateMember(member: string): boolean {
|
||||
return member.startsWith(ALLOWED_CUBE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all measures, dimensions, segments, timeDimensions, and filters
|
||||
* use only members starting with FeedbackRecords.
|
||||
* @throws Error if any member is invalid
|
||||
*/
|
||||
export function validateQueryMembers(query: TChartQuery): void {
|
||||
const invalid: string[] = [];
|
||||
for (const m of query.measures ?? []) {
|
||||
if (!validateMember(m)) invalid.push(m);
|
||||
}
|
||||
for (const d of query.dimensions ?? []) {
|
||||
if (!validateMember(d)) invalid.push(d);
|
||||
}
|
||||
for (const s of query.segments ?? []) {
|
||||
if (!validateMember(s)) invalid.push(s);
|
||||
}
|
||||
for (const td of query.timeDimensions ?? []) {
|
||||
if (!validateMember(td.dimension)) invalid.push(td.dimension);
|
||||
}
|
||||
const checkFilters = (f: TChartQuery["filters"]): void => {
|
||||
if (!f) return;
|
||||
for (const item of f) {
|
||||
if ("member" in item && typeof item.member === "string" && !validateMember(item.member)) {
|
||||
invalid.push(item.member);
|
||||
}
|
||||
if ("and" in item && Array.isArray(item.and)) checkFilters(item.and);
|
||||
if ("or" in item && Array.isArray(item.or)) checkFilters(item.or);
|
||||
}
|
||||
};
|
||||
checkFilters(query.filters);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(`Invalid query members (must start with ${ALLOWED_CUBE_PREFIX}): ${invalid.join(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
var mockTxChart: {
|
||||
// NOSONAR S1135 - var required for vi.mock hoisting
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/database", () => {
|
||||
const tx = {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
mockTxChart = tx;
|
||||
return {
|
||||
prisma: {
|
||||
chart: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((cb: any) => cb({ chart: tx })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: () => Promise.resolve({ project: { id: "project-abc-123" } }),
|
||||
}));
|
||||
|
||||
const mockChartId = "chart-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockUserId = "user-abc-123";
|
||||
|
||||
const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
const mockChart = {
|
||||
id: mockChartId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
};
|
||||
|
||||
const makePrismaError = (code: string) =>
|
||||
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
|
||||
|
||||
describe("Chart Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createChart", () => {
|
||||
test("creates a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue(mockChart as any);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
const result = await createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
projectId: mockProjectId,
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(
|
||||
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
|
||||
);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Duplicate",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Test",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChart", () => {
|
||||
test("updates a chart successfully", async () => {
|
||||
const updatedChart = { ...mockChart, name: "Updated Chart" };
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockResolvedValue(updatedChart);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
const result = await updateChart(mockChartId, mockProjectId, { name: "Updated Chart" });
|
||||
|
||||
expect(result).toEqual({ chart: mockChart, updatedChart });
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.update).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId },
|
||||
data: { name: "Updated Chart", type: undefined, query: undefined, config: undefined },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockProjectId, { name: "Updated" })).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockProjectId, { name: "Taken Name" })).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateChart", () => {
|
||||
test("duplicates a chart with '(copy)' suffix", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({ ...mockChart, name: "Test Chart (copy)" } as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("increments copy number when '(copy)' already exists", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 2)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("finds next available copy number", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([
|
||||
{ name: "Test Chart (copy)" },
|
||||
{ name: "Test Chart (copy 2)" },
|
||||
] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 3)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 3)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("strips existing copy suffix before generating new name", async () => {
|
||||
const chartWithCopy = { ...mockChart, name: "Test Chart (copy)" };
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(chartWithCopy as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId, name: { startsWith: "Test Chart (copy" } },
|
||||
select: { name: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when source chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await expect(duplicateChart(mockChartId, mockProjectId, mockUserId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteChart", () => {
|
||||
test("deletes a chart successfully", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.delete.mockResolvedValue(undefined);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
const result = await deleteChart(mockChartId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.delete).toHaveBeenCalledWith({ where: { id: mockChartId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxChart.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChart", () => {
|
||||
test("returns a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
const result = await getChart(mockChartId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCharts", () => {
|
||||
test("returns all charts for a project", async () => {
|
||||
const chartsFromDb = [
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsFromDb as any);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockProjectId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
]);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no charts exist", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockProjectId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
await expect(getCharts(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChartsWithCreator", () => {
|
||||
test("returns charts with creator info", async () => {
|
||||
const chartsWithCreator = [
|
||||
{ ...mockChart, creator: { name: "Alice" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: null },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsWithCreator as any);
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
const result = await getChartsWithCreator(mockProjectId);
|
||||
|
||||
expect(result).toEqual(chartsWithCreator);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: expect.objectContaining({
|
||||
creator: { select: { name: true } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
await expect(getChartsWithCreator(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZChartConfig, ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TChart,
|
||||
TChartCreateInput,
|
||||
TChartUpdateInput,
|
||||
TChartWithCreator,
|
||||
TChartWithWidgets,
|
||||
ZChartCreateInput,
|
||||
ZChartType,
|
||||
ZChartUpdateInput,
|
||||
} from "@/modules/ee/analysis/types/analysis";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
export const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
|
||||
validateInputs([data, ZChartCreateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
projectId: data.projectId,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateChart = async (
|
||||
chartId: string,
|
||||
projectId: string,
|
||||
data: TChartUpdateInput
|
||||
): Promise<{ chart: TChart; updatedChart: TChart }> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId], [data, ZChartUpdateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const updatedChart = await tx.chart.update({
|
||||
where: { id: chartId },
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
return { chart, updatedChart };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueCopyName = async (baseName: string, projectId: string): Promise<string> => {
|
||||
const stripped = baseName.replace(/ \(copy(?: \d+)?\)$/, "");
|
||||
|
||||
try {
|
||||
const existing = await prisma.chart.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: { startsWith: `${stripped} (copy` },
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const existingNames = new Set(existing.map((c) => c.name));
|
||||
|
||||
const firstCandidate = `${stripped} (copy)`;
|
||||
if (!existingNames.has(firstCandidate)) {
|
||||
return firstCandidate;
|
||||
}
|
||||
|
||||
let n = 2;
|
||||
while (existingNames.has(`${stripped} (copy ${n})`)) {
|
||||
n++;
|
||||
}
|
||||
return `${stripped} (copy ${n})`;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const duplicateChart = async (
|
||||
chartId: string,
|
||||
projectId: string,
|
||||
createdBy: string
|
||||
): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId], [createdBy, ZId]);
|
||||
|
||||
try {
|
||||
const sourceChart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!sourceChart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const uniqueName = await getUniqueCopyName(sourceChart.name, projectId);
|
||||
|
||||
return await createChart({
|
||||
projectId,
|
||||
name: uniqueName,
|
||||
type: ZChartType.parse(sourceChart.type),
|
||||
query: ZChartQuery.parse(sourceChart.query),
|
||||
config: ZChartConfig.parse(sourceChart.config ?? {}),
|
||||
createdBy,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteChart = async (chartId: string, projectId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
await tx.chart.delete({
|
||||
where: { id: chartId },
|
||||
});
|
||||
|
||||
return chart;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChart = async (chartId: string, projectId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
const chart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
return chart;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all charts for the given environment (for list/dashboard UI).
|
||||
* Uses getEnvironmentAuth for access check and enriches with creator names.
|
||||
*/
|
||||
export const getCharts = async (environmentId: string): Promise<TChartWithCreator[]> => {
|
||||
try {
|
||||
const { project } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const charts = await prisma.chart.findMany({
|
||||
where: { projectId: project.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return charts;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChartsWithCreator = async (projectId: string): Promise<TChartWithCreator[]> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
|
||||
|
||||
interface AnalysisPageLayoutProps {
|
||||
pageTitle: string;
|
||||
environmentId: string;
|
||||
cta?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnalysisPageLayout({
|
||||
pageTitle,
|
||||
environmentId,
|
||||
cta,
|
||||
children,
|
||||
}: Readonly<AnalysisPageLayoutProps>) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} cta={cta}>
|
||||
<AnalysisSecondaryNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
{children}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface AnalysisSecondaryNavigationProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function AnalysisSecondaryNavigation({ environmentId }: Readonly<AnalysisSecondaryNavigationProps>) {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeId = pathname?.includes("/charts") ? "charts" : "dashboards";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: t("common.dashboards"),
|
||||
href: `/environments/${environmentId}/analysis/dashboards`,
|
||||
},
|
||||
{
|
||||
id: "charts",
|
||||
label: t("common.charts"),
|
||||
href: `/environments/${environmentId}/analysis/charts`,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
"use server";
|
||||
|
||||
// eslint-disable-next-line
|
||||
// TODO: remove revalidatePath and use revalidateTag instead once this has become stable: https://nextjs.org/docs/app/api-reference/directives/use-cache#usage
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZWidgetLayout } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ZDashboardUpdateInput } from "../types/analysis";
|
||||
import {
|
||||
addChartToDashboard,
|
||||
createDashboard,
|
||||
deleteDashboard,
|
||||
duplicateDashboard,
|
||||
getDashboard,
|
||||
getDashboards,
|
||||
updateDashboard,
|
||||
} from "./lib/dashboards";
|
||||
|
||||
const ZCreateDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const dashboard = await createDashboard({
|
||||
projectId,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
createdBy: ctx.user.id,
|
||||
});
|
||||
|
||||
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = dashboard.id;
|
||||
ctx.auditLoggingCtx.newObject = dashboard;
|
||||
return dashboard;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateDashboardAction = z
|
||||
.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
})
|
||||
.merge(ZDashboardUpdateInput);
|
||||
|
||||
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const { dashboard, updatedDashboard } = await updateDashboard(parsedInput.dashboardId, projectId, {
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
|
||||
ctx.auditLoggingCtx.oldObject = dashboard;
|
||||
ctx.auditLoggingCtx.newObject = updatedDashboard;
|
||||
return updatedDashboard;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
});
|
||||
|
||||
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const dashboard = await deleteDashboard(parsedInput.dashboardId, projectId);
|
||||
|
||||
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
|
||||
ctx.auditLoggingCtx.oldObject = dashboard;
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDuplicateDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateDashboardAction = authenticatedActionClient.schema(ZDuplicateDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const dashboard = await duplicateDashboard(parsedInput.dashboardId, projectId, ctx.user.id);
|
||||
|
||||
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = dashboard.id;
|
||||
ctx.auditLoggingCtx.newObject = dashboard;
|
||||
return dashboard;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetDashboardsAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getDashboardsAction = authenticatedActionClient
|
||||
.schema(ZGetDashboardsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetDashboardsAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getDashboards(projectId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
});
|
||||
|
||||
export const getDashboardAction = authenticatedActionClient
|
||||
.schema(ZGetDashboardAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetDashboardAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getDashboard(parsedInput.dashboardId, projectId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZAddChartToDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
chartId: ZId,
|
||||
title: z.string().optional(),
|
||||
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
|
||||
});
|
||||
|
||||
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"dashboardWidget",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZAddChartToDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const widget = await addChartToDashboard({
|
||||
dashboardId: parsedInput.dashboardId,
|
||||
chartId: parsedInput.chartId,
|
||||
projectId,
|
||||
title: parsedInput.title,
|
||||
layout: parsedInput.layout,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
ctx.auditLoggingCtx.newObject = widget;
|
||||
return widget;
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { createDashboardAction } from "../actions";
|
||||
import { CreateDashboardDialog } from "./create-dashboard-dialog";
|
||||
|
||||
interface CreateDashboardButtonProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const CreateDashboardButton = ({ environmentId }: Readonly<CreateDashboardButtonProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [dashboardName, setDashboardName] = useState("");
|
||||
const [dashboardDescription, setDashboardDescription] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsCreateDialogOpen(open);
|
||||
if (!open) {
|
||||
setDashboardName("");
|
||||
setDashboardDescription("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!dashboardName.trim()) {
|
||||
toast.error(t("environments.analysis.dashboards.please_enter_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await createDashboardAction({
|
||||
environmentId,
|
||||
name: dashboardName.trim(),
|
||||
description: dashboardDescription.trim() || undefined,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.dashboards.create_success"));
|
||||
handleOpenChange(false);
|
||||
router.push(`/environments/${environmentId}/analysis/dashboards/${result.data.id}`);
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.create_failed"));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => handleOpenChange(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.dashboards.create_dashboard")}
|
||||
</Button>
|
||||
<CreateDashboardDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
dashboardName={dashboardName}
|
||||
onDashboardNameChange={setDashboardName}
|
||||
dashboardDescription={dashboardDescription}
|
||||
onDashboardDescriptionChange={setDashboardDescription}
|
||||
onCreate={handleCreate}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CreateDashboardDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
dashboardName: string;
|
||||
onDashboardNameChange: (name: string) => void;
|
||||
dashboardDescription: string;
|
||||
onDashboardDescriptionChange: (description: string) => void;
|
||||
onCreate: () => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export const CreateDashboardDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
dashboardName,
|
||||
onDashboardNameChange,
|
||||
dashboardDescription,
|
||||
onDashboardDescriptionChange,
|
||||
onCreate,
|
||||
isCreating,
|
||||
}: Readonly<CreateDashboardDialogProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.dashboards.create_dashboard")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.analysis.dashboards.create_dashboard_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (dashboardName.trim() && !isCreating) {
|
||||
onCreate();
|
||||
}
|
||||
}}
|
||||
className="space-y-4">
|
||||
<DialogBody className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dashboard-name">{t("environments.analysis.dashboards.dashboard_name")}</Label>
|
||||
<Input
|
||||
id="dashboard-name"
|
||||
placeholder={t("environments.analysis.dashboards.dashboard_name_placeholder")}
|
||||
value={dashboardName}
|
||||
onChange={(e) => onDashboardNameChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dashboard-description">
|
||||
{t("environments.analysis.dashboards.description_optional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="dashboard-description"
|
||||
placeholder={t("environments.analysis.dashboards.description_placeholder")}
|
||||
value={dashboardDescription}
|
||||
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCreating}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isCreating} disabled={!dashboardName.trim()}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { deleteDashboardAction, duplicateDashboardAction } from "../actions";
|
||||
|
||||
interface DashboardDropdownMenuProps {
|
||||
environmentId: string;
|
||||
dashboardId: string;
|
||||
dashboardName: string;
|
||||
}
|
||||
|
||||
export const DashboardDropdownMenu = ({
|
||||
environmentId,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
}: Readonly<DashboardDropdownMenuProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
|
||||
const handleDuplicateDashboard = async () => {
|
||||
setIsDuplicating(true);
|
||||
try {
|
||||
const result = await duplicateDashboardAction({ environmentId, dashboardId });
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.analysis.dashboards.duplicate_success"));
|
||||
} else {
|
||||
toast.error(result?.serverError || t("environments.analysis.dashboards.duplicate_failed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.duplicate_failed"));
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDashboard = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteDashboardAction({ environmentId, dashboardId });
|
||||
if (result?.data) {
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success(t("environments.analysis.dashboards.delete_success"));
|
||||
} else {
|
||||
toast.error(result?.serverError || t("environments.analysis.dashboards.delete_failed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.delete_failed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={`${dashboardName.toLowerCase().split(" ").join("-")}-dashboard-actions`}>
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<button type="button" className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
|
||||
<span className="sr-only">{t("common.open_options")}</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/analysis/dashboards/${dashboardId}`}>
|
||||
<SquarePenIcon className="mr-2 size-4" />
|
||||
{t("common.edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={isDuplicating}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
handleDuplicateDashboard();
|
||||
}}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.duplicate")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("common.dashboard")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={handleDeleteDashboard}
|
||||
text={t("environments.analysis.dashboards.delete_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
const SKELETON_ROWS = 3;
|
||||
|
||||
const SkeletonRow = () => {
|
||||
return (
|
||||
<div className="grid h-12 w-full animate-pulse grid-cols-8 content-center">
|
||||
<div className="col-span-7 grid grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center gap-4 pl-6">
|
||||
<div className="h-5 w-5 rounded bg-gray-200" />
|
||||
<div className="h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-6 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-16 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DashboardsListSkeletonProps {
|
||||
columnHeaders: string[];
|
||||
}
|
||||
|
||||
export const DashboardsListSkeleton = ({ columnHeaders }: Readonly<DashboardsListSkeletonProps>) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[4]}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||
<SkeletonRow key={`skeleton-row-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { convertDateString, timeSinceDate } from "@/lib/time";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { DashboardDropdownMenu } from "./dashboard-dropdown-menu";
|
||||
|
||||
interface DashboardsTableProps {
|
||||
dashboards: TDashboardWithCount[];
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const DashboardsTable = async ({
|
||||
dashboards,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardsTableProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.charts")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.analysis.dashboards.no_dashboards_found")}
|
||||
</p>
|
||||
) : (
|
||||
dashboards.map((dashboard) => {
|
||||
return (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className="grid h-12 w-full grid-cols-8 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||
<Link
|
||||
href={`/environments/${environmentId}/analysis/dashboards/${dashboard.id}`}
|
||||
className="col-span-7 grid cursor-pointer grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-8 flex-shrink-0 text-slate-500">
|
||||
<BarChart3Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium text-slate-900">{dashboard.name}</div>
|
||||
{dashboard.description && (
|
||||
<div className="text-xs font-medium text-slate-500">{dashboard.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{dashboard._count.widgets}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{dashboard.creator?.name || "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{convertDateString(dashboard.createdAt.toISOString())}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{timeSinceDate(dashboard.updatedAt)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
|
||||
{!isReadOnly && (
|
||||
<DashboardDropdownMenu
|
||||
environmentId={environmentId}
|
||||
dashboardId={dashboard.id}
|
||||
dashboardName={dashboard.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,605 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
var mockTxDashboard: {
|
||||
// NOSONAR / test code
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
|
||||
|
||||
var mockTxWidget: {
|
||||
// NOSONAR / test code
|
||||
aggregate: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/database", () => {
|
||||
const txDash = { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() };
|
||||
const txChart = { findFirst: vi.fn() };
|
||||
const txWidget = { aggregate: vi.fn(), create: vi.fn() };
|
||||
mockTxDashboard = txDash;
|
||||
mockTxChart = txChart;
|
||||
mockTxWidget = txWidget;
|
||||
return {
|
||||
prisma: {
|
||||
dashboard: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
|
||||
selectChart: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockDashboardId = "dashboard-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockUserId = "user-abc-123";
|
||||
const mockChartId = "chart-abc-123";
|
||||
|
||||
const selectDashboard = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: true,
|
||||
};
|
||||
|
||||
const mockDashboard = {
|
||||
id: mockDashboardId,
|
||||
name: "Test Dashboard",
|
||||
description: "A test dashboard",
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
createdBy: mockUserId,
|
||||
};
|
||||
|
||||
const makePrismaError = (code: string) =>
|
||||
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
|
||||
|
||||
describe("Dashboard Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createDashboard", () => {
|
||||
test("creates a dashboard successfully", async () => {
|
||||
vi.mocked(prisma.dashboard.create).mockResolvedValue(mockDashboard as any);
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Test Dashboard",
|
||||
description: "A test dashboard",
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockDashboard);
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Dashboard",
|
||||
description: "A test dashboard",
|
||||
projectId: mockProjectId,
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a dashboard without description", async () => {
|
||||
const dashboardNoDesc = { ...mockDashboard, description: undefined };
|
||||
vi.mocked(prisma.dashboard.create).mockResolvedValue(dashboardNoDesc as any);
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Test Dashboard",
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(dashboardNoDesc);
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Dashboard",
|
||||
description: undefined,
|
||||
projectId: mockProjectId,
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
vi.mocked(prisma.dashboard.create).mockRejectedValue(
|
||||
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
|
||||
);
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Duplicate",
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.dashboard.create).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Test",
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateDashboard", () => {
|
||||
test("updates a dashboard successfully", async () => {
|
||||
const updatedDashboard = { ...mockDashboard, name: "Updated Dashboard" };
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxDashboard.update.mockResolvedValue(updatedDashboard);
|
||||
const { updateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await updateDashboard(mockDashboardId, mockProjectId, { name: "Updated Dashboard" });
|
||||
|
||||
expect(result).toEqual({ dashboard: mockDashboard, updatedDashboard });
|
||||
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId, projectId: mockProjectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
expect(mockTxDashboard.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId },
|
||||
data: { name: "Updated Dashboard", description: undefined },
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { updateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
updateDashboard(mockDashboardId, mockProjectId, { name: "Updated" })
|
||||
).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxDashboard.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxDashboard.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { updateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
updateDashboard(mockDashboardId, mockProjectId, { name: "Taken Name" })
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteDashboard", () => {
|
||||
test("deletes a dashboard successfully", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxDashboard.delete.mockResolvedValue(undefined);
|
||||
const { deleteDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await deleteDashboard(mockDashboardId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockDashboard);
|
||||
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId, projectId: mockProjectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
expect(mockTxDashboard.delete).toHaveBeenCalledWith({ where: { id: mockDashboardId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { deleteDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxDashboard.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { deleteDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateDashboard", () => {
|
||||
const mockWidgets = [
|
||||
{
|
||||
id: "widget-1",
|
||||
chartId: mockChartId,
|
||||
title: "Widget 1",
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: "widget-2",
|
||||
chartId: "chart-2",
|
||||
title: null,
|
||||
layout: { x: 4, y: 0, w: 4, h: 3 },
|
||||
order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const sourceDashboard = {
|
||||
...mockDashboard,
|
||||
widgets: mockWidgets,
|
||||
};
|
||||
|
||||
const duplicatedDashboard = {
|
||||
...mockDashboard,
|
||||
id: "dashboard-new-123",
|
||||
name: "Test Dashboard (copy)",
|
||||
};
|
||||
|
||||
test("duplicates a dashboard with all widgets", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceDashboard).mockResolvedValueOnce(null);
|
||||
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
|
||||
|
||||
expect(result).toEqual(duplicatedDashboard);
|
||||
expect(mockTxDashboard.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Dashboard (copy)",
|
||||
description: mockDashboard.description,
|
||||
projectId: mockProjectId,
|
||||
createdBy: mockUserId,
|
||||
widgets: {
|
||||
create: [
|
||||
{
|
||||
chartId: mockChartId,
|
||||
title: "Widget 1",
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
chartId: "chart-2",
|
||||
title: null,
|
||||
layout: { x: 4, y: 0, w: 4, h: 3 },
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("duplicates a dashboard with no widgets", async () => {
|
||||
const sourceNoWidgets = { ...mockDashboard, widgets: [] };
|
||||
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceNoWidgets).mockResolvedValueOnce(null);
|
||||
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
|
||||
|
||||
expect(result).toEqual(duplicatedDashboard);
|
||||
expect(mockTxDashboard.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
widgets: { create: [] },
|
||||
}),
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("increments copy suffix when name already exists", async () => {
|
||||
const existingCopy = { id: "existing", name: "Test Dashboard (copy)" };
|
||||
mockTxDashboard.findFirst
|
||||
.mockResolvedValueOnce(sourceDashboard)
|
||||
.mockResolvedValueOnce(existingCopy)
|
||||
.mockResolvedValueOnce(null);
|
||||
mockTxDashboard.create.mockResolvedValue({
|
||||
...duplicatedDashboard,
|
||||
name: "Test Dashboard (copy) 2",
|
||||
});
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
|
||||
|
||||
expect(result.name).toBe("Test Dashboard (copy) 2");
|
||||
expect(mockTxDashboard.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Dashboard (copy) 2" }),
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when source dashboard does not exist", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxDashboard.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDashboard", () => {
|
||||
test("returns a dashboard with widgets", async () => {
|
||||
const dashboardWithWidgets = {
|
||||
...mockDashboard,
|
||||
widgets: [
|
||||
{
|
||||
id: "widget-1",
|
||||
order: 0,
|
||||
chart: { id: mockChartId, name: "Chart 1", type: "bar" },
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(dashboardWithWidgets as any);
|
||||
const { getDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await getDashboard(mockDashboardId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(dashboardWithWidgets);
|
||||
expect(prisma.dashboard.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId, projectId: mockProjectId },
|
||||
include: {
|
||||
widgets: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
chart: {
|
||||
select: expect.objectContaining({ id: true, name: true, type: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(null);
|
||||
const { getDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.dashboard.findFirst).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDashboards", () => {
|
||||
test("returns all dashboards for a project with creator", async () => {
|
||||
const dashboards = [
|
||||
{ ...mockDashboard, creator: { name: "Alice" }, _count: { widgets: 3 } },
|
||||
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", creator: null, _count: { widgets: 0 } },
|
||||
];
|
||||
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
|
||||
const { getDashboards } = await import("./dashboards");
|
||||
|
||||
const result = await getDashboards(mockProjectId);
|
||||
|
||||
expect(result).toEqual(dashboards);
|
||||
expect(prisma.dashboard.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
name: true,
|
||||
creator: { select: { name: true } },
|
||||
_count: { select: { widgets: true } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no dashboards exist", async () => {
|
||||
vi.mocked(prisma.dashboard.findMany).mockResolvedValue([]);
|
||||
const { getDashboards } = await import("./dashboards");
|
||||
|
||||
const result = await getDashboards(mockProjectId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.dashboard.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getDashboards } = await import("./dashboards");
|
||||
|
||||
await expect(getDashboards(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addChartToDashboard", () => {
|
||||
const mockLayout = { x: 0, y: 0, w: 4, h: 3 };
|
||||
const mockWidget = {
|
||||
id: "widget-abc-123",
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
title: "My Widget",
|
||||
layout: mockLayout,
|
||||
order: 0,
|
||||
};
|
||||
|
||||
test("adds a chart to a dashboard as the first widget", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
|
||||
mockTxWidget.create.mockResolvedValue(mockWidget);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
title: "My Widget",
|
||||
layout: mockLayout,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockWidget);
|
||||
expect(mockTxWidget.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
title: "My Widget",
|
||||
layout: mockLayout,
|
||||
order: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("appends widget after existing widgets", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: 2 } });
|
||||
mockTxWidget.create.mockResolvedValue({ ...mockWidget, order: 3 });
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
});
|
||||
|
||||
expect(mockTxWidget.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ order: 3 }),
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxWidget.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxWidget.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
|
||||
mockTxWidget.create.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { selectChart } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import {
|
||||
TAddWidgetInput,
|
||||
TDashboard,
|
||||
TDashboardCreateInput,
|
||||
TDashboardUpdateInput,
|
||||
TDashboardWithCount,
|
||||
ZAddWidgetInput,
|
||||
ZDashboardCreateInput,
|
||||
ZDashboardUpdateInput,
|
||||
} from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
const MAX_NAME_ATTEMPTS = 5;
|
||||
|
||||
const selectDashboard = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: true,
|
||||
} as const;
|
||||
|
||||
export const createDashboard = async (data: TDashboardCreateInput): Promise<TDashboard> => {
|
||||
validateInputs([data, ZDashboardCreateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.dashboard.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
projectId: data.projectId,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A dashboard with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDashboard = async (
|
||||
dashboardId: string,
|
||||
projectId: string,
|
||||
data: TDashboardUpdateInput
|
||||
): Promise<{ dashboard: TDashboard; updatedDashboard: TDashboard }> => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId], [data, ZDashboardUpdateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const dashboard = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
const updatedDashboard = await tx.dashboard.update({
|
||||
where: { id: dashboardId },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
return { dashboard, updatedDashboard };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A dashboard with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDashboard = async (dashboardId: string, projectId: string): Promise<TDashboard> => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const dashboard = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
await tx.dashboard.delete({
|
||||
where: { id: dashboardId },
|
||||
});
|
||||
|
||||
return dashboard;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDashboard = async (dashboardId: string, projectId: string) => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
const dashboard = await prisma.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
include: {
|
||||
widgets: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
chart: {
|
||||
select: selectChart,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
return dashboard;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDashboards = async (projectId: string): Promise<TDashboardWithCount[]> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.dashboard.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectDashboard,
|
||||
creator: { select: { name: true } },
|
||||
_count: { select: { widgets: true } },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const duplicateDashboard = async (
|
||||
dashboardId: string,
|
||||
projectId: string,
|
||||
createdBy: string
|
||||
): Promise<TDashboard> => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId], [createdBy, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const source = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
include: {
|
||||
widgets: { orderBy: { order: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
const baseName = `${source.name} (copy)`;
|
||||
let name = baseName;
|
||||
let suffix = 1;
|
||||
|
||||
while (await tx.dashboard.findFirst({ where: { projectId, name } })) {
|
||||
suffix++;
|
||||
if (suffix > MAX_NAME_ATTEMPTS) {
|
||||
name = `${baseName} ${suffix}`;
|
||||
break;
|
||||
}
|
||||
name = `${baseName} ${suffix}`;
|
||||
}
|
||||
|
||||
const newDashboard = await tx.dashboard.create({
|
||||
data: {
|
||||
name,
|
||||
description: source.description,
|
||||
projectId,
|
||||
createdBy,
|
||||
widgets: {
|
||||
create: source.widgets.map((widget) => ({
|
||||
chartId: widget.chartId,
|
||||
title: widget.title,
|
||||
layout: widget.layout ?? { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: widget.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
return newDashboard;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addChartToDashboard = async (data: TAddWidgetInput) => {
|
||||
validateInputs([data, ZAddWidgetInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const [chart, dashboard] = await Promise.all([
|
||||
tx.chart.findFirst({ where: { id: data.chartId, projectId: data.projectId } }),
|
||||
tx.dashboard.findFirst({ where: { id: data.dashboardId, projectId: data.projectId } }),
|
||||
]);
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", data.chartId);
|
||||
}
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", data.dashboardId);
|
||||
}
|
||||
|
||||
const maxOrder = await tx.dashboardWidget.aggregate({
|
||||
where: { dashboardId: data.dashboardId },
|
||||
_max: { order: true },
|
||||
});
|
||||
|
||||
return tx.dashboardWidget.create({
|
||||
data: {
|
||||
dashboardId: data.dashboardId,
|
||||
chartId: data.chartId,
|
||||
title: data.title,
|
||||
layout: data.layout,
|
||||
order: (maxOrder._max.order ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("This chart is already on the dashboard");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Delay } from "@suspensive/react";
|
||||
import { Suspense, use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { CreateDashboardButton } from "../components/create-dashboard-button";
|
||||
import { DashboardsListSkeleton } from "../components/dashboards-list-skeleton";
|
||||
import { DashboardsTable } from "../components/dashboards-table";
|
||||
import { getDashboards } from "../lib/dashboards";
|
||||
|
||||
interface DashboardsListContentProps {
|
||||
dashboardsPromise: Promise<TDashboardWithCount[]>;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const DashboardsListContent = ({
|
||||
dashboardsPromise,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardsListContentProps>) => {
|
||||
const dashboards = use(dashboardsPromise);
|
||||
|
||||
return <DashboardsTable dashboards={dashboards} environmentId={environmentId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
interface DashboardsListPageProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const DashboardsListPage = async ({ environmentId }: Readonly<DashboardsListPageProps>) => {
|
||||
const t = await getTranslate();
|
||||
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const dashboardsPromise = getDashboards(project.id);
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
environmentId={environmentId}
|
||||
cta={isReadOnly ? undefined : <CreateDashboardButton environmentId={environmentId} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Delay ms={200}>
|
||||
<DashboardsListSkeleton
|
||||
columnHeaders={[
|
||||
t("common.title"),
|
||||
t("common.charts"),
|
||||
t("common.created_by"),
|
||||
t("common.created"),
|
||||
t("common.updated"),
|
||||
]}
|
||||
/>
|
||||
</Delay>
|
||||
}>
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockGetEnvironment = vi.fn();
|
||||
const mockGetOrganizationIdFromProjectId = vi.fn();
|
||||
const mockCheckAuthorizationUpdated = vi.fn();
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: (...args: any[]) => mockGetEnvironment(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromProjectId: (...args: any[]) => mockGetOrganizationIdFromProjectId(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: (...args: any[]) => mockCheckAuthorizationUpdated(...args),
|
||||
}));
|
||||
|
||||
const mockUserId = "user-abc-123";
|
||||
const mockEnvironmentId = "env-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockOrganizationId = "org-abc-123";
|
||||
|
||||
describe("checkProjectAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns organizationId and projectId on successful access check", async () => {
|
||||
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
|
||||
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
const result = await checkProjectAccess(mockUserId, mockEnvironmentId, "readWrite");
|
||||
|
||||
expect(result).toEqual({ organizationId: mockOrganizationId, projectId: mockProjectId });
|
||||
expect(mockGetEnvironment).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(mockGetOrganizationIdFromProjectId).toHaveBeenCalledWith(mockProjectId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", minPermission: "readWrite", projectId: mockProjectId },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when environment is not found", async () => {
|
||||
mockGetEnvironment.mockResolvedValue(null);
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "read")).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "environment",
|
||||
resourceId: mockEnvironmentId,
|
||||
});
|
||||
expect(mockGetOrganizationIdFromProjectId).not.toHaveBeenCalled();
|
||||
expect(mockCheckAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("propagates authorization errors from checkAuthorizationUpdated", async () => {
|
||||
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
|
||||
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
|
||||
mockCheckAuthorizationUpdated.mockRejectedValue(new Error("Unauthorized"));
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "manage")).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import "server-only";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
export const checkProjectAccess = async (
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
minPermission: TTeamPermission
|
||||
) => {
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", environmentId);
|
||||
}
|
||||
|
||||
const projectId = environment.projectId;
|
||||
const organizationId = await getOrganizationIdFromProjectId(projectId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", minPermission, projectId },
|
||||
],
|
||||
});
|
||||
|
||||
return { organizationId, projectId };
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Generates a system prompt for the AI chart query LLM.
|
||||
* Derived from FEEDBACK_FIELDS to keep schema and prompt in sync.
|
||||
*/
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
FEEDBACK_FIELDS,
|
||||
FILTER_OPERATORS,
|
||||
type FieldDefinition,
|
||||
type MeasureDefinition,
|
||||
} from "./schema-definition";
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
function formatMeasure(m: MeasureDefinition): string {
|
||||
const suffix = m.description ? ` (${m.description})` : "";
|
||||
return `- ${m.id}: ${m.label}${suffix}`;
|
||||
}
|
||||
|
||||
function formatDimension(d: FieldDefinition): string {
|
||||
const suffix = d.description ? ` (${d.description})` : "";
|
||||
return `- ${d.id}: ${d.label}${suffix}`;
|
||||
}
|
||||
|
||||
function formatOperators(): string {
|
||||
const lines = Object.entries(FILTER_OPERATORS).map(([type, ops]) => ` ${type}: ${ops.join(", ")}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateSchemaContext(): string {
|
||||
const measuresText = FEEDBACK_FIELDS.measures.map(formatMeasure).join("\n");
|
||||
const dimensionsText = FEEDBACK_FIELDS.dimensions.map(formatDimension).join("\n");
|
||||
const datePresetsText = DATE_PRESETS.map((p) => `"${p.value}"`).join(", ");
|
||||
const operatorsText = formatOperators();
|
||||
|
||||
return `You are an expert at converting natural language questions into Cube.js analytics queries.
|
||||
|
||||
## Available schema
|
||||
|
||||
### Measures (use these measure IDs in the query)
|
||||
${measuresText}
|
||||
|
||||
### Dimensions (use these dimension IDs in the query)
|
||||
${dimensionsText}
|
||||
|
||||
### Time dimension
|
||||
The time field is \`${CUBE_NAME}.collectedAt\`. Supported granularities: hour, day, week, month, quarter, year.
|
||||
Date range presets: ${datePresetsText}
|
||||
|
||||
### Filter operators by field type
|
||||
${operatorsText}
|
||||
|
||||
## Guidelines
|
||||
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
|
||||
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
|
||||
- For time-based questions, add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\`, an appropriate granularity, and a dateRange preset or custom range.
|
||||
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
|
||||
- Filters must use the exact operator strings from the schema.`;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Query builder utility to construct Cube.js queries from chart builder state.
|
||||
*/
|
||||
import { TChartQuery, TCubeFilter, TMemberFilter, TTimeDimension } from "@formbricks/types/analysis";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CustomMeasure {
|
||||
id?: string;
|
||||
field: string;
|
||||
aggregation: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
export type TFilterFieldType = "string" | "number" | "time";
|
||||
|
||||
export interface FilterRow {
|
||||
field: string;
|
||||
operator: TMemberFilter["operator"];
|
||||
values: string[] | number[] | null;
|
||||
}
|
||||
|
||||
export interface TimeDimensionConfig {
|
||||
dimension: string;
|
||||
granularity?: "second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||
dateRange?: string | [Date, Date];
|
||||
}
|
||||
|
||||
export interface ChartBuilderState {
|
||||
chartType: TChartType | "";
|
||||
selectedMeasures: string[];
|
||||
customMeasures: CustomMeasure[];
|
||||
selectedDimensions: string[];
|
||||
filters: FilterRow[];
|
||||
filterLogic: "and" | "or";
|
||||
timeDimension: TimeDimensionConfig | null;
|
||||
limit?: number;
|
||||
orderBy?: { field: string; direction: "asc" | "desc" };
|
||||
}
|
||||
|
||||
function buildMemberFilter(f: FilterRow): TMemberFilter {
|
||||
const filter: TMemberFilter = {
|
||||
member: f.field,
|
||||
operator: f.operator,
|
||||
};
|
||||
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
|
||||
filter.values = f.values.map(String);
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Cube.js query from chart builder state.
|
||||
*/
|
||||
export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
|
||||
const query: TChartQuery = {
|
||||
measures: [...config.selectedMeasures],
|
||||
};
|
||||
|
||||
if (config.selectedDimensions.length > 0) {
|
||||
query.dimensions = config.selectedDimensions;
|
||||
}
|
||||
|
||||
if (config.timeDimension) {
|
||||
const timeDim: TTimeDimension = {
|
||||
dimension: config.timeDimension.dimension,
|
||||
};
|
||||
|
||||
if (config.timeDimension.granularity) {
|
||||
timeDim.granularity = config.timeDimension.granularity;
|
||||
}
|
||||
|
||||
if (typeof config.timeDimension.dateRange === "string") {
|
||||
timeDim.dateRange = config.timeDimension.dateRange;
|
||||
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||
const [startDate, endDate] = config.timeDimension.dateRange;
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
|
||||
}
|
||||
|
||||
query.timeDimensions = [timeDim];
|
||||
}
|
||||
|
||||
if (config.filters.length > 0) {
|
||||
const memberFilters = config.filters.map(buildMemberFilter);
|
||||
|
||||
if (config.filterLogic === "or") {
|
||||
query.filters = [{ or: memberFilters } as TCubeFilter];
|
||||
} else {
|
||||
query.filters = memberFilters;
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function isMemberFilter(f: TCubeFilter): f is TMemberFilter {
|
||||
return "member" in f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cube.js query back into ChartBuilderState.
|
||||
* Preserves absent granularity / dateRange instead of injecting defaults.
|
||||
*/
|
||||
export function parseQueryToState(query: TChartQuery, chartType?: TChartType): Partial<ChartBuilderState> {
|
||||
const state: Partial<ChartBuilderState> = {
|
||||
chartType: chartType || "",
|
||||
selectedMeasures: query.measures || [],
|
||||
customMeasures: [],
|
||||
selectedDimensions: query.dimensions || [],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
|
||||
if (query.filters && query.filters.length > 0) {
|
||||
const first = query.filters[0];
|
||||
|
||||
if (!isMemberFilter(first) && "or" in first && query.filters.length === 1) {
|
||||
state.filterLogic = "or";
|
||||
state.filters = (first.or as TMemberFilter[]).map((f) => ({
|
||||
field: f.member,
|
||||
operator: f.operator,
|
||||
values: f.values || null,
|
||||
}));
|
||||
} else {
|
||||
state.filterLogic = "and";
|
||||
state.filters = query.filters.filter(isMemberFilter).map((f) => ({
|
||||
field: f.member,
|
||||
operator: f.operator,
|
||||
values: f.values || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
||||
const timeDim = query.timeDimensions[0];
|
||||
const config: TimeDimensionConfig = {
|
||||
dimension: timeDim.dimension,
|
||||
};
|
||||
if (timeDim.granularity) {
|
||||
config.granularity = timeDim.granularity;
|
||||
}
|
||||
if (timeDim.dateRange) {
|
||||
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
|
||||
}
|
||||
state.timeDimension = config;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Schema definitions for FeedbackRecords fields.
|
||||
* Used by the advanced chart builder to provide field metadata and operators.
|
||||
*/
|
||||
|
||||
export interface FieldDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "time";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MeasureDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "count" | "number";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const FEEDBACK_FIELDS = {
|
||||
dimensions: [
|
||||
{
|
||||
id: "FeedbackRecords.sentiment",
|
||||
label: "Sentiment",
|
||||
type: "string",
|
||||
description: "Sentiment extracted from feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.sourceType",
|
||||
label: "Source Type",
|
||||
type: "string",
|
||||
description: "Source type of the feedback (e.g., nps_campaign, survey)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.sourceName",
|
||||
label: "Source Name",
|
||||
type: "string",
|
||||
description: "Human-readable name of the source",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.fieldType",
|
||||
label: "Field Type",
|
||||
type: "string",
|
||||
description: "Type of feedback field (e.g., nps, text, rating)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.emotion",
|
||||
label: "Emotion",
|
||||
type: "string",
|
||||
description: "Emotion extracted from metadata JSONB field",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.userIdentifier",
|
||||
label: "User Identifier",
|
||||
type: "string",
|
||||
description: "Identifier of the user who provided feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.responseId",
|
||||
label: "Response ID",
|
||||
type: "string",
|
||||
description: "Unique identifier linking related feedback records",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsValue",
|
||||
label: "NPS Value",
|
||||
type: "number",
|
||||
description: "Raw NPS score value (0-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.collectedAt",
|
||||
label: "Collected At",
|
||||
type: "time",
|
||||
description: "Timestamp when the feedback was collected",
|
||||
},
|
||||
{
|
||||
id: "TopicsUnnested.topic",
|
||||
label: "Topic",
|
||||
type: "string",
|
||||
description: "Individual topic from the topics array",
|
||||
},
|
||||
] as FieldDefinition[],
|
||||
measures: [
|
||||
{
|
||||
id: "FeedbackRecords.count",
|
||||
label: "Count",
|
||||
type: "count",
|
||||
description: "Total number of feedback responses",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.promoterCount",
|
||||
label: "Promoter Count",
|
||||
type: "count",
|
||||
description: "Number of promoters (NPS score 9-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.detractorCount",
|
||||
label: "Detractor Count",
|
||||
type: "count",
|
||||
description: "Number of detractors (NPS score 0-6)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.passiveCount",
|
||||
label: "Passive Count",
|
||||
type: "count",
|
||||
description: "Number of passives (NPS score 7-8)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsScore",
|
||||
label: "NPS Score",
|
||||
type: "number",
|
||||
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.averageScore",
|
||||
label: "Average Score",
|
||||
type: "number",
|
||||
description: "Average NPS score",
|
||||
},
|
||||
] as MeasureDefinition[],
|
||||
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
|
||||
};
|
||||
|
||||
export type FilterOperator =
|
||||
| "equals"
|
||||
| "notEquals"
|
||||
| "contains"
|
||||
| "notContains"
|
||||
| "set"
|
||||
| "notSet"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte";
|
||||
|
||||
export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
|
||||
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
|
||||
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
};
|
||||
|
||||
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
|
||||
|
||||
export type TimeGranularity = (typeof TIME_GRANULARITIES)[number];
|
||||
|
||||
export const DATE_PRESETS = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Yesterday", value: "yesterday" },
|
||||
{ label: "Last 7 days", value: "last 7 days" },
|
||||
{ label: "Last 30 days", value: "last 30 days" },
|
||||
{ label: "This month", value: "this month" },
|
||||
{ label: "Last month", value: "last month" },
|
||||
{ label: "This quarter", value: "this quarter" },
|
||||
{ label: "This year", value: "this year" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get filter operators for a given field type.
|
||||
*/
|
||||
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
|
||||
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field definition by ID.
|
||||
*/
|
||||
export function getFieldById(id: string): FieldDefinition | MeasureDefinition | undefined {
|
||||
const dimension = FEEDBACK_FIELDS.dimensions.find((d) => d.id === id);
|
||||
if (dimension) return dimension;
|
||||
return FEEDBACK_FIELDS.measures.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
const TIME_GRANULARITY_LABELS: Record<string, string> = {
|
||||
hour: "Hour",
|
||||
day: "Day",
|
||||
week: "Week",
|
||||
month: "Month",
|
||||
quarter: "Quarter",
|
||||
year: "Year",
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a Cube.js column key for display (e.g. FeedbackRecords.collectedAt.day → "Day").
|
||||
*/
|
||||
export function formatCubeColumnHeader(key: string): string {
|
||||
const granularity = TIME_GRANULARITIES.find((g) => key.endsWith(`.${g}`));
|
||||
if (granularity && TIME_GRANULARITY_LABELS[granularity]) {
|
||||
return TIME_GRANULARITY_LABELS[granularity];
|
||||
}
|
||||
const field = getFieldById(key);
|
||||
if (field) return field.label;
|
||||
const lastSegment = key.split(".").pop() ?? key;
|
||||
return lastSegment
|
||||
.replaceAll(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (s) => s.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZChartConfig, ZChartQuery, ZWidgetLayout } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
export const CHART_TYPE_IDS = ["area", "bar", "line", "pie", "big_number"] as const;
|
||||
export const ZChartType = z.enum(CHART_TYPE_IDS);
|
||||
export type TChartType = z.infer<typeof ZChartType>;
|
||||
|
||||
// ── Chart input schemas ─────────────────────────────────────────────────────
|
||||
|
||||
export const ZChartCreateInput = z.object({
|
||||
projectId: ZId,
|
||||
name: z.string().min(1),
|
||||
type: ZChartType,
|
||||
query: ZChartQuery,
|
||||
config: ZChartConfig,
|
||||
createdBy: ZId,
|
||||
});
|
||||
export type TChartCreateInput = z.infer<typeof ZChartCreateInput>;
|
||||
|
||||
export const ZChartUpdateInput = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
type: ZChartType.optional(),
|
||||
query: ZChartQuery.optional(),
|
||||
config: ZChartConfig.optional(),
|
||||
});
|
||||
export type TChartUpdateInput = z.infer<typeof ZChartUpdateInput>;
|
||||
|
||||
// ── Chart output type (matches selectChart) ─────────────────────────────────
|
||||
|
||||
export const ZChart = z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
query: ZChartQuery,
|
||||
config: ZChartConfig,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
export type TChart = z.infer<typeof ZChart>;
|
||||
|
||||
export const ZChartWithCreator = ZChart.extend({
|
||||
creator: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export type TChartWithCreator = z.infer<typeof ZChartWithCreator>;
|
||||
|
||||
export const ZChartWithWidgets = ZChart.extend({
|
||||
widgets: z.array(z.object({ dashboardId: ZId })),
|
||||
});
|
||||
export type TChartWithWidgets = z.infer<typeof ZChartWithWidgets>;
|
||||
|
||||
// ── Dashboard input schemas ─────────────────────────────────────────────────
|
||||
|
||||
export const ZDashboardCreateInput = z.object({
|
||||
projectId: ZId,
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
createdBy: ZId,
|
||||
});
|
||||
export type TDashboardCreateInput = z.infer<typeof ZDashboardCreateInput>;
|
||||
|
||||
export const ZDashboardUpdateInput = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
});
|
||||
export type TDashboardUpdateInput = z.infer<typeof ZDashboardUpdateInput>;
|
||||
|
||||
// ── Dashboard output type (matches selectDashboard) ─────────────────────────
|
||||
|
||||
export type TDashboard = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
export type TDashboardWithCount = TDashboard & {
|
||||
creator: { name: string } | null;
|
||||
_count: { widgets: number };
|
||||
};
|
||||
|
||||
// ── Widget input schema ─────────────────────────────────────────────────────
|
||||
|
||||
export const ZAddWidgetInput = z.object({
|
||||
dashboardId: ZId,
|
||||
chartId: ZId,
|
||||
projectId: ZId,
|
||||
title: z.string().optional(),
|
||||
layout: ZWidgetLayout,
|
||||
});
|
||||
export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;
|
||||
|
||||
// ── Charts UI (query execution, AI response) ─────────────────────────────────
|
||||
|
||||
/** Row from Cube.js tablePivot - keys are measure/dimension names, values are primitives */
|
||||
export type TChartDataRow = Record<string, string | number | null | boolean | undefined>;
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
query: z.infer<typeof ZChartQuery>;
|
||||
chartType: TChartType;
|
||||
data?: TChartDataRow[];
|
||||
error?: string;
|
||||
}
|
||||
@@ -229,49 +229,4 @@ describe("withAuditLogging", () => {
|
||||
// Reset for other tests; clearAllMockHandles will also do this in the next beforeEach
|
||||
if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = true;
|
||||
});
|
||||
|
||||
test("resolves targetId for chart target type", async () => {
|
||||
const chartCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, chartId: "chart-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "chart", handlerImpl);
|
||||
await wrapped({ ctx: chartCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("chart");
|
||||
expect(callArgs.target.id).toBe("chart-1");
|
||||
});
|
||||
|
||||
test("resolves targetId for dashboard target type", async () => {
|
||||
const dashCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardId: "dash-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "dashboard", handlerImpl);
|
||||
await wrapped({ ctx: dashCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("dashboard");
|
||||
expect(callArgs.target.id).toBe("dash-1");
|
||||
});
|
||||
|
||||
test("resolves targetId for dashboardWidget target type", async () => {
|
||||
const widgetCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardWidgetId: "widget-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "dashboardWidget", handlerImpl);
|
||||
await wrapped({ ctx: widgetCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("dashboardWidget");
|
||||
expect(callArgs.target.id).toBe("widget-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,15 +292,6 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
|
||||
case "quota":
|
||||
targetId = auditLoggingCtx.quotaId;
|
||||
break;
|
||||
case "chart":
|
||||
targetId = auditLoggingCtx.chartId;
|
||||
break;
|
||||
case "dashboard":
|
||||
targetId = auditLoggingCtx.dashboardId;
|
||||
break;
|
||||
case "dashboardWidget":
|
||||
targetId = auditLoggingCtx.dashboardWidgetId;
|
||||
break;
|
||||
default:
|
||||
targetId = UNKNOWN_DATA;
|
||||
break;
|
||||
|
||||
@@ -25,9 +25,6 @@ export const ZAuditTarget = z.enum([
|
||||
"integration",
|
||||
"file",
|
||||
"quota",
|
||||
"chart",
|
||||
"dashboard",
|
||||
"dashboardWidget",
|
||||
]);
|
||||
export const ZAuditAction = z.enum([
|
||||
"created",
|
||||
|
||||
@@ -52,54 +52,41 @@ export const getPersonSegmentIds = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
// Phase 1: Build all Prisma where clauses concurrently.
|
||||
// This converts segment filters into where clauses without per-contact DB queries.
|
||||
const segmentWithClauses = await Promise.all(
|
||||
segments.map(async (segment) => {
|
||||
const filters = segment.filters as TBaseFilters | null;
|
||||
|
||||
if (!filters || filters.length === 0) {
|
||||
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
|
||||
}
|
||||
|
||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment"
|
||||
);
|
||||
return { segmentId: segment.id, whereClause: null };
|
||||
}
|
||||
|
||||
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
|
||||
})
|
||||
);
|
||||
|
||||
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
|
||||
// Phase 1: Build WHERE clauses sequentially to avoid connection pool contention.
|
||||
// segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks),
|
||||
// so running all builds concurrently would saturate the pool.
|
||||
const alwaysMatchIds: string[] = [];
|
||||
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
|
||||
for (const item of segmentWithClauses) {
|
||||
if (item.whereClause === null) {
|
||||
for (const segment of segments) {
|
||||
const filters = segment.filters as TBaseFilters;
|
||||
|
||||
if (!filters?.length) {
|
||||
alwaysMatchIds.push(segment.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(item.whereClause).length === 0) {
|
||||
alwaysMatchIds.push(item.segmentId);
|
||||
} else {
|
||||
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
|
||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause });
|
||||
}
|
||||
|
||||
if (toCheck.length === 0) {
|
||||
if (dbChecks.length === 0) {
|
||||
return alwaysMatchIds;
|
||||
}
|
||||
|
||||
// Phase 2: Batch all contact-match checks into a single DB transaction.
|
||||
// Replaces N individual findFirst queries with one batched round-trip.
|
||||
const batchResults = await prisma.$transaction(
|
||||
toCheck.map(({ whereClause }) =>
|
||||
// Phase 2: Execute all membership checks in a single transaction.
|
||||
// Uses one connection instead of N concurrent ones, eliminating pool contention.
|
||||
const txResults = await prisma.$transaction(
|
||||
dbChecks.map(({ whereClause }) =>
|
||||
prisma.contact.findFirst({
|
||||
where: { id: contactId, ...whereClause },
|
||||
select: { id: true },
|
||||
@@ -107,17 +94,12 @@ export const getPersonSegmentIds = async (
|
||||
)
|
||||
);
|
||||
|
||||
// Phase 3: Collect matching segment IDs
|
||||
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
|
||||
return [...alwaysMatchIds, ...dbMatchIds];
|
||||
return [...alwaysMatchIds, ...matchedIds];
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId,
|
||||
contactId,
|
||||
error,
|
||||
},
|
||||
{ environmentId, contactId, error },
|
||||
"Failed to get person segment IDs, returning empty array"
|
||||
);
|
||||
return [];
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
import { getUserState } from "./user-state";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./segments", () => ({
|
||||
getPersonSegmentIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-environment-id";
|
||||
const mockUserId = "test-user-id";
|
||||
const mockContactId = "test-contact-id";
|
||||
const mockDevice = "desktop";
|
||||
|
||||
describe("getUserState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return user state with empty responses and displays", async () => {
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [],
|
||||
displays: [],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
select: {
|
||||
id: true,
|
||||
responses: {
|
||||
select: { surveyId: true },
|
||||
},
|
||||
displays: {
|
||||
select: { surveyId: true, createdAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getPersonSegmentIds).toHaveBeenCalledWith(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockDevice
|
||||
);
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: ["segment1"],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return user state with responses and displays, and sort displays by createdAt", async () => {
|
||||
const mockDate1 = new Date("2023-01-01T00:00:00.000Z");
|
||||
const mockDate2 = new Date("2023-01-02T00:00:00.000Z");
|
||||
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [{ surveyId: "survey1" }, { surveyId: "survey2" }],
|
||||
displays: [
|
||||
{ surveyId: "survey4", createdAt: mockDate2 }, // most recent (already sorted by desc)
|
||||
{ surveyId: "survey3", createdAt: mockDate1 },
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: ["segment2", "segment3"],
|
||||
displays: [
|
||||
{ surveyId: "survey4", createdAt: mockDate2 },
|
||||
{ surveyId: "survey3", createdAt: mockDate1 },
|
||||
],
|
||||
responses: ["survey1", "survey2"],
|
||||
lastDisplayAt: mockDate2,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty arrays from prisma", async () => {
|
||||
// This case tests with proper empty arrays instead of null
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [],
|
||||
displays: [],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: [],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
|
||||
/**
|
||||
* Optimized single query to get all user state data
|
||||
* Replaces multiple separate queries with one efficient query
|
||||
*/
|
||||
const getUserStateDataOptimized = async (contactId: string) => {
|
||||
return prisma.contact.findUniqueOrThrow({
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
responses: {
|
||||
select: { surveyId: true },
|
||||
},
|
||||
displays: {
|
||||
select: {
|
||||
surveyId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimized user state fetcher without caching
|
||||
* Uses single database query and efficient data processing
|
||||
* NO CACHING - user state changes frequently with contact updates
|
||||
*
|
||||
* @param environmentId - The environment id
|
||||
* @param userId - The user id
|
||||
* @param device - The device type
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
*/
|
||||
export const getUserState = async ({
|
||||
environmentId,
|
||||
userId,
|
||||
contactId,
|
||||
device,
|
||||
}: {
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
contactId: string;
|
||||
device: "phone" | "desktop";
|
||||
}): Promise<TJsPersonState["data"]> => {
|
||||
// Single optimized query for all contact data
|
||||
const contactData = await getUserStateDataOptimized(contactId);
|
||||
|
||||
// Get segments using Prisma-based evaluation (no attributes needed - fetched from DB)
|
||||
const segments = await getPersonSegmentIds(environmentId, contactId, userId, device);
|
||||
|
||||
// Process displays efficiently
|
||||
const displays = (contactData.displays ?? []).map((display) => ({
|
||||
surveyId: display.surveyId,
|
||||
createdAt: display.createdAt,
|
||||
}));
|
||||
|
||||
// Get latest display date
|
||||
const lastDisplayAt =
|
||||
contactData.displays && contactData.displays.length > 0 ? contactData.displays[0].createdAt : null;
|
||||
|
||||
// Process responses efficiently
|
||||
const responses = (contactData.responses ?? []).map((response) => response.surveyId);
|
||||
|
||||
const userState: TJsPersonState["data"] = {
|
||||
contactId,
|
||||
userId,
|
||||
segments,
|
||||
displays,
|
||||
responses,
|
||||
lastDisplayAt,
|
||||
};
|
||||
|
||||
return userState;
|
||||
};
|
||||
@@ -75,7 +75,7 @@ const createBaseFilter = (
|
||||
connector: "and" | "or" | null = "and",
|
||||
id?: string
|
||||
): TBaseFilter => ({
|
||||
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${crypto.randomUUID()}`), // Use filter ID or UUID for group
|
||||
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${Math.random()}`), // Use filter ID or random for group
|
||||
connector,
|
||||
resource,
|
||||
});
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import { ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Format: { THEME_NAME: CSS_VARIABLE }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof ResponsiveContainer>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replaceAll(":", "")}`;
|
||||
const contextValue = React.useMemo(() => ({ config }), [config]);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "ChartContainer";
|
||||
|
||||
const ChartTooltip = Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
active?: boolean;
|
||||
payload?: unknown[];
|
||||
label?: string;
|
||||
labelFormatter?: (value: unknown, payload: unknown[]) => React.ReactNode;
|
||||
labelClassName?: string;
|
||||
formatter?: (
|
||||
value: unknown,
|
||||
name: string,
|
||||
item: unknown,
|
||||
index: number,
|
||||
payload: unknown[]
|
||||
) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || (item as { dataKey?: string; name?: string }).dataKey || (item as { name?: string }).name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string" ? config[label]?.label || label : itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
const payloadArray: unknown[] = Array.isArray(payload) ? payload : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border/50 grid min-w-[8rem] items-start gap-1.5 rounded-lg border bg-white px-2.5 py-1.5 text-xs shadow-xl dark:bg-gray-950",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel && tooltipLabel}
|
||||
<div className="grid gap-1.5">
|
||||
{payloadArray.map((item, index) => {
|
||||
const itemObj = item as {
|
||||
dataKey?: string;
|
||||
name?: string;
|
||||
value?: number;
|
||||
payload?: { fill?: string };
|
||||
};
|
||||
const key = `${nameKey || itemObj.name || itemObj.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || itemObj.payload?.fill || (item as { color?: string }).color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemObj.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
{formatter && itemObj?.value !== undefined && itemObj.name ? (
|
||||
formatter(itemObj.value, itemObj.name, item, index, payloadArray)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label || itemObj.name}</span>
|
||||
</div>
|
||||
{itemObj.value !== undefined && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{itemObj.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
payload?: unknown[];
|
||||
verticalAlign?: "top" | "bottom" | "middle";
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload.map((item) => {
|
||||
const itemObj = item as { dataKey?: string; value?: unknown; color?: string };
|
||||
const key = `${nameKey || itemObj.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(itemObj.value)}
|
||||
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: itemObj.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof (payload as { payload?: unknown }).payload === "object" && payload !== null
|
||||
? (payload as { payload: Record<string, unknown> }).payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof (payload as Record<string, unknown>)[key] === "string") {
|
||||
configLabelKey = (payload as Record<string, unknown>)[key] as string;
|
||||
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key] === "string") {
|
||||
configLabelKey = payloadPayload[key];
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: Readonly<{ id: string; config: ChartConfig }>) => {
|
||||
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||
@@ -23,7 +23,6 @@
|
||||
"@aws-sdk/s3-presigned-post": "3.971.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.971.0",
|
||||
"@boxyhq/saml-jackson": "1.52.2",
|
||||
"@cubejs-client/core": "1.6.6",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
@@ -76,7 +75,6 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.9",
|
||||
"@radix-ui/react-tooltip": "1.2.6",
|
||||
"@sentry/nextjs": "10.5.0",
|
||||
"@suspensive/react": "3.19.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
@@ -108,7 +106,6 @@
|
||||
"mime-types": "3.0.1",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "4.24.12",
|
||||
"openai": "4.77.0",
|
||||
"next-safe-action": "7.10.8",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "7.0.11",
|
||||
@@ -128,7 +125,6 @@
|
||||
"react-i18next": "15.7.3",
|
||||
"react-turnstile": "1.1.4",
|
||||
"react-use": "17.6.0",
|
||||
"recharts": "2.15.0",
|
||||
"redis": "4.7.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"server-only": "0.0.1",
|
||||
@@ -141,7 +137,7 @@
|
||||
"uuid": "11.1.0",
|
||||
"webpack": "5.99.8",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "3.24.4",
|
||||
"zod": "3.25.76",
|
||||
"zod-openapi": "4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -168,7 +164,6 @@
|
||||
"esbuild": "0.25.12",
|
||||
"postcss": "8.5.3",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"ts-node": "10.9.2",
|
||||
"vite": "6.4.1",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
|
||||
@@ -154,9 +154,5 @@ module.exports = {
|
||||
},
|
||||
safelist: [{ pattern: /max-w-./, variants: "sm" }],
|
||||
darkMode: "class", // Set dark mode to use the 'class' strategy
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
],
|
||||
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
module.exports = {
|
||||
// queryRewrite runs before every Cube query. Use it to enforce row-level security (RLS)
|
||||
// by injecting filters based on the caller's identity (e.g. organizationId, projectId).
|
||||
//
|
||||
// The securityContext is populated from the decoded JWT passed via the API token.
|
||||
// Currently a passthrough because access control is handled in the Next.js API layer
|
||||
// before reaching Cube. When Cube is exposed more broadly or multi-tenancy enforcement
|
||||
// is needed at the Cube level, add filters here based on securityContext claims.
|
||||
queryRewrite: (query, { securityContext }) => {
|
||||
return query;
|
||||
},
|
||||
};
|
||||
@@ -1,159 +0,0 @@
|
||||
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
|
||||
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
|
||||
// this schema must be updated to match.
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `SELECT * FROM feedback_records`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
},
|
||||
|
||||
averageScore: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
description: `Average NPS score`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
sourceType: {
|
||||
sql: `source_type`,
|
||||
type: `string`,
|
||||
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
|
||||
},
|
||||
|
||||
sourceName: {
|
||||
sql: `source_name`,
|
||||
type: `string`,
|
||||
description: `Human-readable name of the source`,
|
||||
},
|
||||
|
||||
fieldType: {
|
||||
sql: `field_type`,
|
||||
type: `string`,
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
npsValue: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
},
|
||||
|
||||
userIdentifier: {
|
||||
sql: `user_identifier`,
|
||||
type: `string`,
|
||||
description: `Identifier of the user who provided feedback`,
|
||||
},
|
||||
|
||||
emotion: {
|
||||
sql: `emotion`,
|
||||
type: `string`,
|
||||
description: `Emotion extracted from metadata JSONB field`,
|
||||
},
|
||||
},
|
||||
|
||||
joins: {
|
||||
TopicsUnnested: {
|
||||
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
|
||||
relationship: `hasMany`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cube(`TopicsUnnested`, {
|
||||
sql: `
|
||||
SELECT
|
||||
fr.id as feedback_record_id,
|
||||
topic_elem.topic
|
||||
FROM feedback_records fr
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
|
||||
`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `feedback_record_id || '-' || topic`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
feedbackRecordId: {
|
||||
sql: `feedback_record_id`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
topic: {
|
||||
sql: `topic`,
|
||||
type: `string`,
|
||||
description: `Individual topic from the topics array`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -36,42 +36,6 @@ services:
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
|
||||
# Cube connects to the Hub DB Postgres which owns the feedback_records table.
|
||||
# Uses the superset_formbricks_hub network to reach formbricks_hub_postgres directly.
|
||||
# Ensure the Formbricks Hub stack is running (docker compose -p superset up, or similar).
|
||||
#
|
||||
# SECURITY: CUBEJS_API_SECRET has no default and must be set explicitly (e.g. in .env).
|
||||
# Never use a weak secret in production/staging. Generate with: openssl rand -hex 32
|
||||
cube:
|
||||
image: cubejs/cube:v1.6.6
|
||||
env_file:
|
||||
- apps/web/.env
|
||||
ports:
|
||||
- 4000:4000
|
||||
- 4001:4001 # Cube Playground UI (dev only)
|
||||
environment:
|
||||
CUBEJS_DB_TYPE: postgres
|
||||
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-formbricks_hub_postgres}
|
||||
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-hub}
|
||||
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-formbricks}
|
||||
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-formbricks_dev}
|
||||
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
|
||||
CUBEJS_DEV_MODE: "true"
|
||||
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
|
||||
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
|
||||
volumes:
|
||||
- ./cube/cube.js:/cube/conf/cube.js
|
||||
- ./cube/schema:/cube/conf/model
|
||||
restart: on-failure
|
||||
networks:
|
||||
- default
|
||||
- hub
|
||||
|
||||
networks:
|
||||
hub:
|
||||
external: true
|
||||
name: superset_formbricks_hub
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
|
||||
+6
-2
@@ -99,7 +99,7 @@
|
||||
"xm-and-surveys/surveys/link-surveys/personal-links",
|
||||
"xm-and-surveys/surveys/link-surveys/single-use-links",
|
||||
"xm-and-surveys/surveys/link-surveys/source-tracking",
|
||||
"xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
"xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
"xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
|
||||
"xm-and-surveys/surveys/link-surveys/market-research-panel",
|
||||
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
|
||||
@@ -516,9 +516,13 @@
|
||||
"source": "/docs/link-surveys/global/schedule-start-end-dates"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
"source": "/docs/link-surveys/start-at-question"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
"source": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
|
||||
"source": "/docs/link-surveys/global/metadata"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: "Start At Specific Block"
|
||||
description:
|
||||
"Start a survey at a specific block using the URL to skip earlier blocks."
|
||||
icon: "arrow-right"
|
||||
---
|
||||
|
||||
The `startAt` URL parameter lets you open a Link Survey at a specific **block** instead of from the beginning. This is useful when you want to link to a specific part of the survey from external sources or reuse the same survey at different points in the user journey.
|
||||
|
||||
## How it works
|
||||
|
||||
The survey navigation is block-based: each block can contain one or more questions. When you pass `startAt` with a Question ID, the survey finds the **block** that contains that question and opens at that block. All questions in that block are shown together.
|
||||
|
||||
<Note>
|
||||
**Multi-question blocks:** When a block has multiple questions, `startAt` opens at the block—you will see all questions in that block, not only the question whose ID you used. For precise "start at this exact question" behavior, use one question per block.
|
||||
</Note>
|
||||
|
||||
## How to use it
|
||||
|
||||
1. In the Survey Editor, open the Questions Tab and ensure the survey is set as a **Link Survey**.
|
||||
|
||||
2. Find the question (or block) you want to start at, click on **Show Advanced Settings**, and copy the **Question ID** of any question in that block.
|
||||
|
||||
<Note>
|
||||
Each question has a unique Question ID. Since `startAt` resolves to the block containing the question, you can use any question ID from the target block—typically the first question in that block.
|
||||
</Note>
|
||||
|
||||
3. Append `?startAt=question_id` to your survey's URL, replacing `question_id` with the copied Question ID.
|
||||
|
||||
4. Share this modified URL with your users to start the survey at the specified block.
|
||||
|
||||
### Sample Link Survey URL with `startAt`
|
||||
|
||||
```sh Example Link Survey URL with startAt configured
|
||||
https://formbricks.com/clny997dj087ho30fdzyf4nkl?startAt=bqd29m94l9k0hnc3azbrexl8
|
||||
```
|
||||
|
||||
## Use cases
|
||||
|
||||
- **Link to a specific block from an external source:** Direct users to a specific block in your survey from emails, chatbots, or web pages.
|
||||
- **Use the same survey in different parts of the user journey:** Reuse the survey at different stages, starting at different blocks to gather insights.
|
||||
- **Create a personalized survey experience:** Tailor the survey by starting at a particular block based on the user's past interactions or preferences.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
title: "Start At Specific Question"
|
||||
description:
|
||||
"Start a survey at a specific question using the URL to skip the initial questions."
|
||||
icon: "arrow-right"
|
||||
---
|
||||
|
||||
You can start a survey at a specific question from the survey using the URL to skip the initial questions. This is useful when you want to link to a specific question from an external source or want to use the same survey in different parts of the user journey.
|
||||
|
||||
## How to Use it?
|
||||
|
||||
1. In the Survey Editor, open the Questions Tab and ensure the survey is set as a **Link Survey**.
|
||||
|
||||
2. Find the question you want to start at, click on **Show Advanced Settings**, and copy the **Question ID**.
|
||||
|
||||
<Note>
|
||||
Each question has a unique Question ID, which is used to identify it in the
|
||||
survey. You can use different Question IDs for multiple **startAt** points in
|
||||
the URL.
|
||||
</Note>
|
||||
|
||||
3. Append `?startAt=question_id` to your survey's URL, replacing `question_id` with the copied Question ID.
|
||||
|
||||
4. Share this modified URL with your users to start the survey at the specified question.
|
||||
|
||||
### Sample Link Survey URL with `startAt`
|
||||
|
||||
```sh Example Link Survey URL with startAt configured
|
||||
https://formbricks.com/clny997dj087ho30fdzyf4nkl?startAt=bqd29m94l9k0hnc3azbrexl8
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Link to a specific question from an external source:** Use this feature to direct users to a specific question in your survey from emails, chatbots, or web pages, providing a seamless experience.
|
||||
- **Use the same survey in different parts of the user journey:** Employ the same survey at various stages of the user journey, starting at different questions to gather comprehensive insights.
|
||||
- **Create a personalized survey experience:** Tailor the survey experience by starting at a particular question based on the user's past interactions or preferences, enhancing engagement.
|
||||
Vendored
+1
-1
@@ -39,7 +39,7 @@
|
||||
"dependencies": {
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"redis": "5.8.1",
|
||||
"zod": "3.24.4"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable import/no-relative-packages -- required for importing types */
|
||||
/* eslint-disable @typescript-eslint/no-namespace -- using namespaces is required for prisma-json-types-generator */
|
||||
import { type TActionClassNoCodeConfig } from "../types/action-classes";
|
||||
import type { TChartConfig, TChartQuery, TWidgetLayout } from "../types/analysis";
|
||||
import type { TOrganizationAccess } from "../types/api-key";
|
||||
import { type TIntegrationConfig } from "../types/integration";
|
||||
import { type TOrganizationBilling } from "../types/organizations";
|
||||
@@ -56,8 +55,5 @@ declare global {
|
||||
export type OrganizationAccess = TOrganizationAccess;
|
||||
export type SurveyMetadata = TSurveyMetadata;
|
||||
export type SurveyQuotaLogic = TSurveyQuotaLogic;
|
||||
export type ChartQuery = TChartQuery;
|
||||
export type ChartConfig = TChartConfig;
|
||||
export type WidgetLayout = TWidgetLayout;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user