Compare commits

..

36 Commits

Author SHA1 Message Date
TheodorTomas 61518b2c0e Merge remote-tracking branch 'origin/epic/dashboards' into feat/charts-ui
# Conflicts:
#	apps/web/app/(app)/environments/[environmentId]/analysis/charts/page.tsx
#	apps/web/locales/en-US.json
#	apps/web/modules/ee/analysis/charts/components/chart-dropdown-menu.tsx
#	apps/web/modules/ee/analysis/charts/components/charts-list-page.tsx
#	apps/web/modules/ee/analysis/charts/components/charts-list-skeleton.tsx
#	apps/web/modules/ee/analysis/charts/components/charts-list.tsx
#	apps/web/modules/ee/analysis/charts/components/create-chart-button.tsx
#	apps/web/modules/ee/analysis/charts/lib/chart-types.ts
#	apps/web/modules/ee/analysis/charts/lib/charts.ts
2026-02-25 18:48:55 +07:00
TheodorTomas fb8ee60228 fix: clean up AddToDashboardDialog props, z-index, and save guard 2026-02-25 18:24:54 +07:00
Dhruwang Jariwala 3a802810e3 feat: Charts list page with demo create/edit and real delete/duplicate (#7353)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-25 11:20:34 +00:00
Dhruwang c5722c05ae fix: ui tweaks in advanced chart builder 2026-02-25 15:37:14 +05:30
TheodorTomas b65feca0a5 fix: align schema definitions and seed data with CubeJS cube, add feedback_records table
- Remove phantom dimensions (channel, rating, surveyName) and measure
  (completionRate) from schema-definition that don't exist in the CubeJS
  FeedbackRecords cube
- Fix seed chart queries to use valid cube fields (collectedAt instead of
  createdAt, sentiment instead of rating, sourceType instead of channel,
  sourceName instead of surveyName)
- Create feedback_records table with sample data in the seed so charts
  work in local dev
- Fix ChartPreview infinite spinner when query fails by separating
  loading/error/empty states
- Prevent duplicate query execution when AdvancedChartBuilder has
  hidePreview enabled
2026-02-25 15:03:32 +07:00
Dhruwang Jariwala fbbf917093 chore: sync dashboard epic (#7351)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
2026-02-25 13:24:51 +05:30
Dhruwang f451658eaf addressed feedback 2026-02-25 11:51:56 +05:30
TheodorTomas b42c3ff97c fix: unify chart types and remove dead mapping code
Drop the separate TApiChartType enum in favour of a single TChartType,
replace mapChartType/mapDatabaseChartTypeToApi with resolveChartType,
remove table/scatter/map chart variants, and simplify chart-types.ts.
2026-02-24 23:17:39 +07:00
TheodorTomas aa9ccd70a0 fix: improve chart type safety and DRY up chart renderer 2026-02-24 22:16:07 +07:00
TheodorTomas fcfb31a1d3 fix: harden chart-preview with error state, safe tab change, and layout cleanup 2026-02-24 21:22:54 +07:00
TheodorTomas 1c2cf0390b fix: format date labels in pie chart 2026-02-24 21:17:03 +07:00
TheodorTomas 8aae875e55 chore: clean up chart dropdown menu and disable seed cache 2026-02-24 21:08:56 +07:00
TheodorTomas 058fca5cef fix: add a11y title and consistent width to chart loading dialog 2026-02-24 19:56:45 +07:00
Dhruwang 74e39b83fd ui ux tweaks 2026-02-24 14:23:21 +05:30
Dhruwang 4e44841e63 fix: propagation issue 2026-02-24 14:16:22 +05:30
Dhruwang 1b625f2bb1 removed unused layout file 2026-02-24 13:48:22 +05:30
Dhruwang 781be1c71a fix width 2026-02-24 13:44:16 +05:30
Dhruwang 27ac42a81f Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into feat/charts-ui 2026-02-24 13:38:44 +05:30
Dhruwang ef73a1df85 feedback 2026-02-24 13:37:30 +05:30
Theodór Tómas d670d5de31 feat: (dashboards) listing page (#7330) 2026-02-23 20:26:03 +07:00
Dhruwang dc454b80ec updated layout 2026-02-23 17:51:39 +05:30
Dhruwang 0340384384 fix: sonar issues 2026-02-23 15:32:08 +05:30
Dhruwang 74c47ed840 fix lint and unit test 2026-02-23 15:04:16 +05:30
Dhruwang b61ce96fa6 code rabbit suggestions 2026-02-23 14:47:14 +05:30
Dhruwang f62ceaa7ae feat: charts ui 2026-02-23 13:39:28 +05:30
Theodór Tómas 5ccb4af249 feat: (dashboards) crud charts/dashboard server actions (#7307)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-23 11:44:26 +05:30
Theodór Tómas 62aa186a81 chore: merge main into dashboard epic (#7321)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 12:22:54 +00:00
Theodór Tómas f35e54f21d feat: (dashboards) adding analysis tab to sidebar along with placeholder pages (#7311) 2026-02-20 09:58:26 +05:30
Theodór Tómas f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas 9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang 4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang 1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:17:44 +05:30
Dhruwang 4f9088559f feat: add Cube.js dev setup and analytics client
- Add Cube container to docker-compose.dev.yml (pinned v1.3.21)
- Add Cube server config (cube/cube.js) and FeedbackRecords schema
- Add @cubejs-client/core dependency and singleton client in EE module
- Add CUBEJS_API_URL and CUBEJS_API_TOKEN to .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:05:47 +05:30
Dhruwang 18550f1d11 feat: link Chart and Dashboard createdBy to User
- Add creator relation on Chart and Dashboard to User
- Add createdBy foreign key constraints in migration (ON DELETE SET NULL)
- Mirror Survey pattern for createdBy user tracking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:26:42 +05:30
Dhruwang 881cd31f74 feat: add Chart, Dashboard, DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- ChartType: area, bar, line, pie, big_number only
- Remove DashboardStatus and WidgetType (widgets are always charts)
- DashboardWidget requires chartId, remove content/type fields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:24:48 +05:30
Dhruwang e00405dca2 feat: add Chart, Dashboard, and DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- Add ChartType, DashboardStatus, WidgetType enums
- Add migration 20260128111722 for charts and dashboards tables

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
107 changed files with 10029 additions and 55 deletions
+19
View File
@@ -229,5 +229,24 @@ 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
+1
View File
@@ -32,6 +32,7 @@ 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
@@ -0,0 +1,8 @@
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;
@@ -0,0 +1,11 @@
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;
@@ -0,0 +1,8 @@
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;
@@ -0,0 +1,8 @@
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,6 +2,7 @@
import {
ArrowUpRightIcon,
ChartBar,
ChevronRightIcon,
Cog,
LogOutIcon,
@@ -114,6 +115,13 @@ 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`,
@@ -188,7 +196,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:ring-0 focus:ring-transparent focus:outline-none"
"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"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors
@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
+40
View File
@@ -106,6 +106,7 @@ 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
@@ -122,6 +123,8 @@ 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
@@ -151,6 +154,7 @@ 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
@@ -160,6 +164,8 @@ 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
@@ -198,7 +204,9 @@ 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
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
@@ -211,6 +219,7 @@ checksums:
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
common/import: 348b8ab981de5b7f1fca6d7302263bbd
@@ -228,6 +237,7 @@ checksums:
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
common/last_name: 2c9a7de7738ca007ba9023c385149c26
common/learn_more: e598091d132f890c37a6d4ed94f6d794
common/license_expired: 7af13535e320e4197989472c01387d2c
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
@@ -277,6 +287,7 @@ 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
@@ -576,6 +587,35 @@ 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
@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"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",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "Tage",
"default": "Standard",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"filter": "Filter",
"finish": "Fertigstellen",
"first_name": "Vorname",
"follow_these": "Folge diesen",
"formbricks_version": "Formbricks Version",
"full_name": "Name",
@@ -239,6 +246,7 @@
"hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder",
"hide_column": "Spalte ausblenden",
"id": "ID",
"image": "Bild",
"images": "Bilder",
"import": "Importieren",
@@ -256,6 +264,7 @@
"key": "Schlüssel",
"label": "Bezeichnung",
"language": "Sprache",
"last_name": "Nachname",
"learn_more": "Mehr erfahren",
"license_expired": "License Expired",
"light_overlay": "Helle Überlagerung",
@@ -305,6 +314,7 @@
"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",
@@ -610,6 +620,41 @@
"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.",
+165
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"bottom_right": "Bottom Right",
"cancel": "Cancel",
"centered_modal": "Centered Modal",
"chart": "Chart",
"charts": "Charts",
"choices": "Choices",
"choose_environment": "Choose environment",
"choose_organization": "Choose organization",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Date",
"days": "days",
"default": "Default",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Failed to load workspaces",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
"follow_these": "Follow these",
"formbricks_version": "Formbricks Version",
"full_name": "Full name",
@@ -238,7 +245,9 @@
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide": "Hide",
"hide_column": "Hide column",
"id": "ID",
"image": "Image",
"images": "Images",
"import": "Import",
@@ -256,6 +265,7 @@
"key": "Key",
"label": "Label",
"language": "Language",
"last_name": "Last Name",
"learn_more": "Learn more",
"license_expired": "License Expired",
"light_overlay": "Light overlay",
@@ -305,6 +315,7 @@
"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",
@@ -447,6 +458,7 @@
"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",
@@ -610,6 +622,159 @@
"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.",
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"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",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"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",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Error al cargar los proyectos",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
"follow_these": "Sigue estos",
"formbricks_version": "Versión de Formbricks",
"full_name": "Nombre completo",
@@ -239,6 +246,7 @@
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide_column": "Ocultar columna",
"id": "ID",
"image": "Imagen",
"images": "Imágenes",
"import": "Importar",
@@ -256,6 +264,7 @@
"key": "Clave",
"label": "Etiqueta",
"language": "Idioma",
"last_name": "Apellido",
"learn_more": "Saber más",
"license_expired": "License Expired",
"light_overlay": "Superposición clara",
@@ -305,6 +314,7 @@
"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",
@@ -610,6 +620,41 @@
"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.",
+45 -1
View File
@@ -112,7 +112,6 @@
"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",
@@ -134,6 +133,7 @@
"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,6 +150,8 @@
"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",
@@ -179,6 +181,7 @@
"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",
@@ -188,6 +191,8 @@
"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",
@@ -228,6 +233,7 @@
"failed_to_load_workspaces": "Échec du chargement des projets",
"filter": "Filtre",
"finish": "Terminer",
"first_name": "Prénom",
"follow_these": "Suivez ceci",
"formbricks_version": "Version de Formbricks",
"full_name": "Nom complet",
@@ -240,6 +246,7 @@
"hidden_field": "Champ caché",
"hidden_fields": "Champs cachés",
"hide_column": "Cacher la colonne",
"id": "ID",
"image": "Image",
"images": "Images",
"import": "Importer",
@@ -257,6 +264,7 @@
"key": "Clé",
"label": "Étiquette",
"language": "Langue",
"last_name": "Nom de famille",
"learn_more": "En savoir plus",
"license_expired": "License Expired",
"light_overlay": "Claire",
@@ -306,6 +314,7 @@
"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",
@@ -611,6 +620,41 @@
"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.",
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"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",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"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",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
"follow_these": "Ezek követése",
"formbricks_version": "Formbricks verziója",
"full_name": "Teljes név",
@@ -239,6 +246,7 @@
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
"hide_column": "Oszlop elrejtése",
"id": "ID",
"image": "Kép",
"images": "Képek",
"import": "Importálás",
@@ -256,6 +264,7 @@
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
"last_name": "Vezetéknév",
"learn_more": "Tudjon meg többet",
"license_expired": "A licenc lejárt",
"light_overlay": "Világos rávetítés",
@@ -305,6 +314,7 @@
"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ó",
@@ -610,6 +620,41 @@
"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.",
+45
View File
@@ -133,6 +133,7 @@
"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": "匿名",
@@ -149,6 +150,8 @@
"bottom_right": "右下",
"cancel": "キャンセル",
"centered_modal": "中央モーダル",
"chart": "チャート",
"charts": "チャート",
"choices": "選択肢",
"choose_environment": "環境を選択",
"choose_organization": "組織を選択",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, other {{value}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"create": "作成",
"create_new_organization": "新しい組織を作成",
"create_segment": "セグメントを作成",
"create_survey": "フォームを作成",
@@ -187,6 +191,8 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"dashboard": "ダッシュボード",
"dashboards": "ダッシュボード",
"date": "日付",
"days": "日",
"default": "デフォルト",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
"filter": "フィルター",
"finish": "完了",
"first_name": "名",
"follow_these": "こちらの手順に従って",
"formbricks_version": "Formbricksバージョン",
"full_name": "氏名",
@@ -239,6 +246,7 @@
"hidden_field": "非表示フィールド",
"hidden_fields": "非表示フィールド",
"hide_column": "列を非表示",
"id": "ID",
"image": "画像",
"images": "画像",
"import": "インポート",
@@ -256,6 +264,7 @@
"key": "キー",
"label": "ラベル",
"language": "言語",
"last_name": "姓",
"learn_more": "詳細を見る",
"license_expired": "License Expired",
"light_overlay": "明るいオーバーレイ",
@@ -305,6 +314,7 @@
"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": "任意",
@@ -610,6 +620,41 @@
"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": "うまくいきました!接続されました。",
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
"centered_modal": "Gecentreerd modaal",
"chart": "Grafiek",
"charts": "Grafieken",
"choices": "Keuzes",
"choose_environment": "Kies omgeving",
"choose_organization": "Kies organisatie",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
"filter": "Filter",
"finish": "Finish",
"first_name": "Voornaam",
"follow_these": "Volg deze",
"formbricks_version": "Formbricks-versie",
"full_name": "Volledige naam",
@@ -239,6 +246,7 @@
"hidden_field": "Verborgen veld",
"hidden_fields": "Verborgen velden",
"hide_column": "Kolom verbergen",
"id": "ID",
"image": "Afbeelding",
"images": "Afbeeldingen",
"import": "Importeren",
@@ -256,6 +264,7 @@
"key": "Sleutel",
"label": "Label",
"language": "Taal",
"last_name": "Achternaam",
"learn_more": "Meer informatie",
"license_expired": "License Expired",
"light_overlay": "Lichte overlay",
@@ -305,6 +314,7 @@
"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",
@@ -610,6 +620,41 @@
"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.",
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"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",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"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",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Terminar",
"first_name": "Primeiro nome",
"follow_these": "Siga esses",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -239,6 +246,7 @@
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide_column": "Ocultar coluna",
"id": "ID",
"image": "imagem",
"images": "Imagens",
"import": "importar",
@@ -256,6 +264,7 @@
"key": "Chave",
"label": "Etiqueta",
"language": "Língua",
"last_name": "Sobrenome",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "sobreposição leve",
@@ -305,6 +314,7 @@
"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",
@@ -610,6 +620,41 @@
"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.",
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"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",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"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",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Concluir",
"first_name": "Primeiro nome",
"follow_these": "Siga estes",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -239,6 +246,7 @@
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide_column": "Ocultar coluna",
"id": "ID",
"image": "Imagem",
"images": "Imagens",
"import": "Importar",
@@ -256,6 +264,7 @@
"key": "Chave",
"label": "Etiqueta",
"language": "Idioma",
"last_name": "Apelido",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "Sobreposição leve",
@@ -305,6 +314,7 @@
"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",
@@ -610,6 +620,41 @@
"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.",
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"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",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"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",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
"filter": "Filtru",
"finish": "Finalizează",
"first_name": "Prenume",
"follow_these": "Urmați acestea",
"formbricks_version": "Versiunea Formbricks",
"full_name": "Nume complet",
@@ -239,6 +246,7 @@
"hidden_field": "Câmp ascuns",
"hidden_fields": "Câmpuri ascunse",
"hide_column": "Ascunde coloana",
"id": "ID",
"image": "Imagine",
"images": "Imagini",
"import": "Import",
@@ -256,6 +264,7 @@
"key": "Cheie",
"label": "Etichetă",
"language": "Limba",
"last_name": "Nume de familie",
"learn_more": "Află mai multe",
"license_expired": "License Expired",
"light_overlay": "Suprapunere ușoară",
@@ -305,6 +314,7 @@
"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",
@@ -610,6 +620,41 @@
"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.",
+45 -1
View File
@@ -112,7 +112,6 @@
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
},
"common": {
"Filter": "Фильтр",
"accepted": "Принято",
"account": "Аккаунт",
"account_settings": "Настройки аккаунта",
@@ -134,6 +133,7 @@
"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,6 +150,8 @@
"bottom_right": "Внизу справа",
"cancel": "Отмена",
"centered_modal": "Центрированное модальное окно",
"chart": "График",
"charts": "Графики",
"choices": "Варианты",
"choose_environment": "Выберите среду",
"choose_organization": "Выберите организацию",
@@ -179,6 +181,7 @@
"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": "Создать опрос",
@@ -188,6 +191,8 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"dashboard": "Панель управления",
"dashboards": "Дашборды",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
@@ -228,6 +233,7 @@
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
"filter": "Фильтр",
"finish": "Завершить",
"first_name": "Имя",
"follow_these": "Выполните следующие действия",
"formbricks_version": "Версия Formbricks",
"full_name": "Полное имя",
@@ -240,6 +246,7 @@
"hidden_field": "Скрытое поле",
"hidden_fields": "Скрытые поля",
"hide_column": "Скрыть столбец",
"id": "ID",
"image": "Изображение",
"images": "Изображения",
"import": "Импорт",
@@ -257,6 +264,7 @@
"key": "Ключ",
"label": "Метка",
"language": "Язык",
"last_name": "Фамилия",
"learn_more": "Подробнее",
"license_expired": "License Expired",
"light_overlay": "Светлый оверлей",
@@ -306,6 +314,7 @@
"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": "Необязательно",
@@ -611,6 +620,41 @@
"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": "Отлично! Мы подключены.",
+45
View File
@@ -133,6 +133,7 @@
"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",
@@ -149,6 +150,8 @@
"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",
@@ -178,6 +181,7 @@
"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",
@@ -187,6 +191,8 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"dashboard": "Instrumentpanel",
"dashboards": "Instrumentpaneler",
"date": "Datum",
"days": "dagar",
"default": "Standard",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
"filter": "Filter",
"finish": "Slutför",
"first_name": "Förnamn",
"follow_these": "Följ dessa",
"formbricks_version": "Formbricks-version",
"full_name": "Fullständigt namn",
@@ -239,6 +246,7 @@
"hidden_field": "Dolt fält",
"hidden_fields": "Dolda fält",
"hide_column": "Dölj kolumn",
"id": "ID",
"image": "Bild",
"images": "Bilder",
"import": "Importera",
@@ -256,6 +264,7 @@
"key": "Nyckel",
"label": "Etikett",
"language": "Språk",
"last_name": "Efternamn",
"learn_more": "Läs mer",
"license_expired": "License Expired",
"light_overlay": "Ljust överlägg",
@@ -305,6 +314,7 @@
"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",
@@ -610,6 +620,41 @@
"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.",
+45
View File
@@ -133,6 +133,7 @@
"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": "匿名",
@@ -149,6 +150,8 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "居中 模态",
"chart": "图表",
"charts": "图表",
"choices": "选项",
"choose_environment": "选择 环境",
"choose_organization": "选择 组织",
@@ -178,6 +181,7 @@
"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": "创建 调查",
@@ -187,6 +191,8 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"dashboard": "Dashboard",
"dashboards": "仪表盘",
"date": "日期",
"days": "天",
"default": "默认",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "加载工作区失败",
"filter": "筛选",
"finish": "完成",
"first_name": "名字",
"follow_these": "遵循 这些",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -239,6 +246,7 @@
"hidden_field": "隐藏 字段",
"hidden_fields": "隐藏 字段",
"hide_column": "隐藏 列",
"id": "ID",
"image": "图片",
"images": "图片",
"import": "导入",
@@ -256,6 +264,7 @@
"key": "键",
"label": "标签",
"language": "语言",
"last_name": "姓",
"learn_more": "了解 更多",
"license_expired": "License Expired",
"light_overlay": "浅色遮罩层",
@@ -305,6 +314,7 @@
"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": "可选",
@@ -610,6 +620,41 @@
"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": "做得好 !我们 已经 连接。",
+45
View File
@@ -133,6 +133,7 @@
"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": "匿名",
@@ -149,6 +150,8 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "置中彈窗",
"chart": "圖表",
"charts": "圖表",
"choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
@@ -178,6 +181,7 @@
"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": "建立問卷",
@@ -187,6 +191,8 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"dashboard": "儀表板",
"dashboards": "儀表板",
"date": "日期",
"days": "天",
"default": "預設",
@@ -227,6 +233,7 @@
"failed_to_load_workspaces": "載入工作區失敗",
"filter": "篩選",
"finish": "完成",
"first_name": "名字",
"follow_these": "按照這些步驟",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -239,6 +246,7 @@
"hidden_field": "隱藏欄位",
"hidden_fields": "隱藏欄位",
"hide_column": "隱藏欄位",
"id": "ID",
"image": "圖片",
"images": "圖片",
"import": "匯入",
@@ -256,6 +264,7 @@
"key": "金鑰",
"label": "標籤",
"language": "語言",
"last_name": "姓氏",
"learn_more": "瞭解更多",
"license_expired": "License Expired",
"light_overlay": "淺色覆蓋",
@@ -305,6 +314,7 @@
"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": "選填",
@@ -610,6 +620,41 @@
"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,7 +12,9 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs
| Prisma.DashboardFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
@@ -0,0 +1,43 @@
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();
});
});
@@ -0,0 +1,26 @@
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();
}
@@ -0,0 +1,458 @@
"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 : [],
};
}
);
@@ -0,0 +1,118 @@
"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>
);
}
@@ -0,0 +1,583 @@
"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>
);
}
@@ -0,0 +1,100 @@
"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>
);
}
@@ -0,0 +1,82 @@
"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>
);
}
@@ -0,0 +1,112 @@
"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>
</>
);
}
@@ -0,0 +1,32 @@
"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>
);
}
@@ -0,0 +1,25 @@
"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>
);
}
@@ -0,0 +1,130 @@
"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>
);
}
@@ -0,0 +1,85 @@
"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>
);
}
@@ -0,0 +1,250 @@
"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>
);
}
}
@@ -0,0 +1,48 @@
"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>
);
}
@@ -0,0 +1,47 @@
"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>
);
}
@@ -0,0 +1,63 @@
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>
);
}
@@ -0,0 +1,43 @@
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>
);
}
@@ -0,0 +1,125 @@
"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>
);
}
@@ -0,0 +1,98 @@
"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>
);
}
@@ -0,0 +1,26 @@
"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} />
</>
);
}
@@ -0,0 +1,123 @@
"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}
/>
);
}
@@ -0,0 +1,176 @@
"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>
);
}
@@ -0,0 +1,68 @@
"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>
);
}
@@ -0,0 +1,43 @@
"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>
);
}
@@ -0,0 +1,131 @@
"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>
);
}
@@ -0,0 +1,264 @@
"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>
);
}
@@ -0,0 +1,47 @@
"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>
);
}
@@ -0,0 +1,172 @@
"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>
);
}
@@ -0,0 +1,35 @@
"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>
);
}
@@ -0,0 +1,67 @@
"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>
);
}
@@ -0,0 +1,238 @@
"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>
);
}
@@ -0,0 +1,340 @@
"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,
};
}
@@ -0,0 +1,185 @@
"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,
};
}
@@ -0,0 +1,18 @@
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");
});
});
@@ -0,0 +1,33 @@
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"),
},
];
}
@@ -0,0 +1,95 @@
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(", ")}`);
}
}
@@ -0,0 +1,426 @@
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",
});
});
});
});
@@ -0,0 +1,276 @@
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;
}
};
@@ -0,0 +1,27 @@
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>
);
}
@@ -0,0 +1,31 @@
"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} />;
}
@@ -0,0 +1,255 @@
"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;
}
)
);
@@ -0,0 +1,81 @@
"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}
/>
</>
);
};
@@ -0,0 +1,97 @@
"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>
);
};
@@ -0,0 +1,130 @@
"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>
);
};
@@ -0,0 +1,49 @@
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>
);
};
@@ -0,0 +1,85 @@
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>
);
};
@@ -0,0 +1,605 @@
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",
});
});
});
});
@@ -0,0 +1,295 @@
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;
}
};
@@ -0,0 +1,65 @@
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>
);
};
@@ -0,0 +1,73 @@
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");
});
});
@@ -0,0 +1,31 @@
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 };
};
@@ -0,0 +1,59 @@
/**
* 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.`;
}
@@ -0,0 +1,155 @@
/**
* 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;
}
@@ -0,0 +1,197 @@
/**
* 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();
}
@@ -0,0 +1,109 @@
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,4 +229,49 @@ 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,6 +292,15 @@ 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,6 +25,9 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
"dashboard",
"dashboardWidget",
]);
export const ZAuditAction = z.enum([
"created",
@@ -1,4 +1,5 @@
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "@/lingodotdev/server";
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -13,7 +14,7 @@ export const AttributesPage = async ({
}) => {
const params = await paramsProps;
const locale = await getLocale();
const t = await getTranslate();
const [{ isReadOnly }, contactAttributeKeys] = await Promise.all([
getEnvironmentAuth(params.environmentId),
getContactAttributeKeys(params.environmentId),
@@ -23,7 +24,7 @@ export const AttributesPage = async ({
return (
<ContactsPageLayout
pageTitle="Contacts"
pageTitle={t("common.contacts")}
activeId="attributes"
environmentId={params.environmentId}
isContactsEnabled={isContactsEnabled}
@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { TFunction } from "i18next";
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
@@ -10,12 +11,13 @@ import { TContactTableData } from "../types/contact";
export const generateContactTableColumns = (
searchValue: string,
data: TContactTableData[],
isReadOnly: boolean
isReadOnly: boolean,
t: TFunction
): ColumnDef<TContactTableData>[] => {
const userColumn: ColumnDef<TContactTableData> = {
id: "contactsTableUser",
accessorKey: "contactsTableUser",
header: "ID",
header: t("common.id"),
cell: ({ row }) => {
const contactId = row.original.id;
return <HighlightedText value={contactId} searchValue={searchValue} />;
@@ -25,7 +27,7 @@ export const generateContactTableColumns = (
const userIdColumn: ColumnDef<TContactTableData> = {
id: "userId",
accessorKey: "userId",
header: "User ID",
header: t("common.user_id"),
cell: ({ row }) => {
const userId = row.original.userId;
return <IdBadge id={userId} />;
@@ -35,7 +37,7 @@ export const generateContactTableColumns = (
const emailColumn: ColumnDef<TContactTableData> = {
id: "email",
accessorKey: "email",
header: "Email",
header: t("common.email"),
cell: ({ row }) => {
const email = row.original.email;
if (email) {
@@ -47,7 +49,7 @@ export const generateContactTableColumns = (
const firstNameColumn: ColumnDef<TContactTableData> = {
id: "firstName",
accessorKey: "firstName",
header: "First Name",
header: t("common.first_name"),
cell: ({ row }) => {
const firstName = row.original.firstName;
return <HighlightedText value={firstName} searchValue={searchValue} />;
@@ -57,7 +59,7 @@ export const generateContactTableColumns = (
const lastNameColumn: ColumnDef<TContactTableData> = {
id: "lastName",
accessorKey: "lastName",
header: "Last Name",
header: t("common.last_name"),
cell: ({ row }) => {
const lastName = row.original.lastName;
return <HighlightedText value={lastName} searchValue={searchValue} />;
@@ -71,7 +71,7 @@ export const ContactsTable = ({
// Generate columns
const columns = useMemo(() => {
return generateContactTableColumns(searchValue, data, isReadOnly);
return generateContactTableColumns(searchValue, data, isReadOnly, t);
}, [searchValue, data, isReadOnly]);
// Load saved settings from localStorage
@@ -2,14 +2,15 @@
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { TFunction } from "i18next";
import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
export const generateSegmentTableColumns = (): ColumnDef<TSegmentWithSurveyNames>[] => {
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
id: "title",
accessorKey: "title",
header: "Title",
header: t("common.title"),
cell: ({ row }) => {
return (
<div className="flex items-center gap-4">
@@ -30,7 +31,7 @@ export const generateSegmentTableColumns = (): ColumnDef<TSegmentWithSurveyNames
const updatedAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
id: "updatedAt",
accessorKey: "updatedAt",
header: "Updated",
header: t("common.updated_at"),
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">
@@ -43,7 +44,7 @@ export const generateSegmentTableColumns = (): ColumnDef<TSegmentWithSurveyNames
const createdAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
id: "createdAt",
accessorKey: "createdAt",
header: "Created",
header: t("common.created_at"),
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
@@ -26,7 +26,7 @@ export function SegmentTable({
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns();
return generateSegmentTableColumns(t);
}, []);
const table = useReactTable({
@@ -75,7 +75,7 @@ const createBaseFilter = (
connector: "and" | "or" | null = "and",
id?: string
): TBaseFilter => ({
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${Math.random()}`), // Use filter ID or random for group
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${crypto.randomUUID()}`), // Use filter ID or UUID for group
connector,
resource,
});
@@ -32,7 +32,7 @@ export const SegmentsPage = async ({
return (
<ContactsPageLayout
pageTitle="Contacts"
pageTitle={t("common.contacts")}
activeId="segments"
environmentId={params.environmentId}
isContactsEnabled={isContactsEnabled}
@@ -0,0 +1,319 @@
"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 };
+9 -4
View File
@@ -23,6 +23,7 @@
"@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",
@@ -54,9 +55,9 @@
"@opentelemetry/sdk-node": "0.211.0",
"@opentelemetry/sdk-trace-base": "2.5.0",
"@opentelemetry/semantic-conventions": "1.38.0",
"@prisma/instrumentation": "6.14.0",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.14.0",
"@prisma/instrumentation": "6.14.0",
"@radix-ui/react-accordion": "1.2.10",
"@radix-ui/react-checkbox": "1.3.1",
"@radix-ui/react-collapsible": "1.1.10",
@@ -75,6 +76,7 @@
"@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",
@@ -106,6 +108,7 @@
"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",
@@ -114,15 +117,18 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.2.3",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-dom": "19.2.3",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"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",
@@ -136,9 +142,7 @@
"webpack": "5.99.8",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "3.24.4",
"zod-openapi": "4.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
"zod-openapi": "4.2.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -164,6 +168,7 @@
"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",
+5 -1
View File
@@ -154,5 +154,9 @@ module.exports = {
},
safelist: [{ pattern: /max-w-./, variants: "sm" }],
darkMode: "class", // Set dark mode to use the 'class' strategy
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
],
};
+12
View File
@@ -0,0 +1,12 @@
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;
},
};
+159
View File
@@ -0,0 +1,159 @@
// 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
View File
@@ -36,6 +36,42 @@ 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
+4
View File
@@ -1,6 +1,7 @@
/* 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";
@@ -55,5 +56,8 @@ 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;
}
}
@@ -0,0 +1,77 @@
-- CreateEnum
CREATE TYPE "public"."ChartType" AS ENUM ('area', 'bar', 'line', 'pie', 'big_number');
-- CreateTable
CREATE TABLE "public"."Chart" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" "public"."ChartType" NOT NULL,
"projectId" TEXT NOT NULL,
"query" JSONB NOT NULL DEFAULT '{}',
"config" JSONB NOT NULL DEFAULT '{}',
"createdBy" TEXT,
CONSTRAINT "Chart_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Dashboard" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"projectId" TEXT NOT NULL,
"createdBy" TEXT,
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."DashboardWidget" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"dashboardId" TEXT NOT NULL,
"title" TEXT,
"chartId" TEXT NOT NULL,
"layout" JSONB NOT NULL DEFAULT '{"x":0,"y":0,"w":4,"h":3}',
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "DashboardWidget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Chart_projectId_created_at_idx" ON "public"."Chart"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Chart_projectId_name_key" ON "public"."Chart"("projectId", "name");
-- CreateIndex
CREATE INDEX "Dashboard_projectId_created_at_idx" ON "public"."Dashboard"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Dashboard_projectId_name_key" ON "public"."Dashboard"("projectId", "name");
-- CreateIndex
CREATE INDEX "DashboardWidget_dashboardId_order_idx" ON "public"."DashboardWidget"("dashboardId", "order");
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_chartId_fkey" FOREIGN KEY ("chartId") REFERENCES "public"."Chart"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+93
View File
@@ -647,6 +647,8 @@ model Project {
logo Json?
projectTeams ProjectTeam[]
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
charts Chart[]
dashboards Dashboard[]
@@unique([organizationId, name])
}
@@ -867,6 +869,8 @@ model User {
/// [Locale]
locale String @default("en-US")
surveys Survey[]
charts Chart[] @relation("chartCreatedBy")
dashboards Dashboard[] @relation("dashboardCreatedBy")
teamUsers TeamUser[]
lastLoginAt DateTime?
isActive Boolean @default(true)
@@ -1004,3 +1008,92 @@ model ProjectTeam {
@@id([projectId, teamId])
@@index([teamId])
}
enum ChartType {
area
bar
line
pie
big_number
}
/// Represents a chart/visualization that can be used in multiple dashboards.
/// Charts are reusable components that query analytics data.
///
/// @property id - Unique identifier for the chart
/// @property name - Display name of the chart
/// @property type - Type of visualization (bar, line, pie, etc.)
/// @property project - The project this chart belongs to
/// @property query - Cube.js query configuration (JSON)
/// @property config - Chart-specific configuration (colors, labels, etc.)
/// @property createdBy - User who created the chart
/// @property dashboards - Dashboards that use this chart
model Chart {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
type ChartType
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
/// [ChartQuery] - Cube.js query configuration
query Json @default("{}")
/// [ChartConfig] - Visualization configuration (colors, labels, formatting)
config Json @default("{}")
creator User? @relation("chartCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a dashboard containing multiple charts.
/// Dashboards aggregate analytics insights at the project level.
///
/// @property id - Unique identifier for the dashboard
/// @property name - Display name of the dashboard
/// @property description - Optional description
/// @property project - The project this dashboard belongs to
/// @property widgets - Charts on this dashboard
/// @property createdBy - User who created the dashboard
model Dashboard {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
description String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
creator User? @relation("dashboardCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a chart widget on a dashboard.
/// Widgets are positioned using a grid layout system.
///
/// @property id - Unique identifier for the widget
/// @property dashboard - The dashboard this widget belongs to
/// @property title - Optional title for the widget
/// @property chart - The chart displayed in this widget
/// @property layout - Grid layout configuration (x, y, width, height)
/// @property order - Display order within the dashboard
model DashboardWidget {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
dashboardId String
title String?
chart Chart @relation(fields: [chartId], references: [id], onDelete: Cascade)
chartId String
/// [WidgetLayout] - Grid layout: { x, y, w, h }
layout Json @default("{\"x\":0,\"y\":0,\"w\":4,\"h\":3}")
order Int @default(0)
@@index([dashboardId, order])
}

Some files were not shown because too many files have changed in this diff Show More