Compare commits

..

35 Commits

Author SHA1 Message Date
Dhruwang a4469dbb46 Merge branch 'main' of https://github.com/formbricks/formbricks into fix-delete-dialog-title 2026-03-02 11:56:57 +05:30
Johannes 6dd2e707fe feat: display Formbricks version alongside organization ID in settings (#7363) 2026-03-02 05:54:23 +00:00
Matti Nannt 58d5de7d45 fix: resolve Dependabot Next.js deserialization alert (#7393) 2026-02-27 22:18:38 +01:00
Dhruwang Jariwala 7c3fa8b5ea fix: restore bullet points in survey preview and public survey (#7356) (#7360)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-27 18:24:15 +00:00
Harsh Bhat 2601169877 docs: add advanced CSS variable updates (#7389)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-27 17:19:22 +00:00
bharath kumar aecf85815a fix(js-core): use closest() fallback for nested click target matching (#7327)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-27 06:24:58 +00:00
Bhagya Amarasinghe c6ebaea989 fix: set success_action_status on S3 presigned POST to fix CORS on Ceph-based providers (#7362) 2026-02-26 10:26:49 +00:00
Bhagya Amarasinghe 68c1422733 fix: copy database package.json to Docker runner stage (#7371) 2026-02-26 10:25:28 +00:00
Dhruwang Jariwala 6942502baf fix: slack missing redirect uri (#7372) 2026-02-26 10:01:25 +00:00
Theodór Tómas a4bd217761 chore: update to zod 3.25.76 (#7366) 2026-02-26 05:17:20 +00:00
Bhagya Amarasinghe fee770358c perf(contacts): build segment WHERE clauses sequentially to prevent pool saturation (#7354)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-25 15:25:32 +00:00
Dhruwang Jariwala 44f8f80cac docs: clarify startAt is block-based, not question-based (#1404) (#7352)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 13:19:30 +00:00
Balázs Úr 207d044707 fix: delete confirmation dialog title translation 2026-02-25 13:29:17 +01:00
Chowdhury Tafsir Ahmed Siddiki 858a7f7aa9 fix: replace toSorted in breadcrumb switchers for compatibility (#7325)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:29:31 +00:00
Gulshan ac40b90e81 fix: made "Filter" string translatable (#7301)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:28:51 +00:00
Balázs Úr aa21b4e442 fix: made Contact's page titles and table headers translatable (#7313)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 14:07:05 +00:00
Dhruwang Jariwala fa72296de5 fix: error state for multi select question (#7335) 2026-02-24 13:34:48 +00:00
Johannes 3776b31794 feat: add impressions tab and display data retrieval for surveys (#7266)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 11:00:58 +00:00
Bhagya Amarasinghe 5c7ea33fb0 feat: add pod disruption budget for helm chart (#7339) 2026-02-24 10:43:16 +00:00
Balázs Úr 33f60ce2be fix: button label on create attribute dialog (#7331)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 08:30:20 +00:00
Bhagya Amarasinghe c0386cea5a perf(contacts): batch segment evaluation queries into single transaction (#7333)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 08:26:46 +00:00
Anshuman Pandey 7cea53130c chore: adds webhook signing to test event (#7320) 2026-02-23 12:36:50 +00:00
Dhruwang Jariwala 0636989d67 fix: update test configuration to exclude .next directory from testing (#7334) 2026-02-23 11:33:17 +01:00
Anshuman Pandey 219883266c fix: add bool support (#7323) 2026-02-20 15:30:40 +00:00
Theodór Tómas 55fc2b2bc8 chore: removing i18n from pre-commit hook (#7318) 2026-02-20 10:48:44 +00:00
neila 6e4ef9a099 fix: make pretty URL paths accessible from public domain (#7264)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:40 +00:00
Chowdhury Tafsir Ahmed Siddiki ebf7d1e3a1 fix: prevent crash in NotificationSwitch via optional chaining (#7268)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:06 +00:00
Dhruwang Jariwala 998162bc48 fix: Google Sheets integration — token expiry & permission error handling (#7282) (#7285) 2026-02-20 08:56:24 +00:00
Anshuman Pandey 4fadc54b4e fix: fixes storage resolution issues (#7310) 2026-02-19 14:03:19 +00:00
Dhruwang Jariwala f4ac9a8292 fix: always validate only responseData fields in client/management APIs (#7292) (#7296)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 08:56:42 +00:00
Anshuman Pandey 7c8a7606b7 fix: fixes the no segment in draft surveys bug (#7290) 2026-02-19 08:16:18 +00:00
Anshuman Pandey 225217330b fix: adds dataType filter in bc code (#7294) 2026-02-19 07:47:58 +00:00
Dhruwang Jariwala 589c04a530 fix: allow CTA elements to proceed when marked required (#1415) (#7293)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 06:56:03 +00:00
Anshuman Pandey aa538a3a51 fix: better query in the backwards compatible code (#7288) 2026-02-18 13:00:19 +00:00
Anshuman Pandey 817e108ff5 docs: adds migration docs (#7281)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-02-17 17:01:46 +01:00
131 changed files with 727 additions and 10527 deletions
-19
View File
@@ -229,24 +229,5 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
-1
View File
@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns
+3
View File
@@ -101,6 +101,9 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
@@ -1,8 +0,0 @@
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <ChartsListPage environmentId={environmentId} />;
};
export default ChartsPage;
@@ -1,11 +0,0 @@
const DashboardDetailPage = async (props: Readonly<{ params: Promise<{ dashboardId: string }> }>) => {
const { dashboardId } = await props.params;
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
Dashboard detail for {dashboardId} will appear here.
</div>
);
};
export default DashboardDetailPage;
@@ -1,8 +0,0 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <DashboardsListPage environmentId={environmentId} />;
};
export default DashboardsPage;
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return redirect(`/environments/${environmentId}/analysis/dashboards`);
};
export default AnalysisPage;
@@ -2,7 +2,6 @@
import {
ArrowUpRightIcon,
ChartBar,
ChevronRightIcon,
Cog,
LogOutIcon,
@@ -115,13 +114,6 @@ export const MainNavigation = ({
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.analysis"),
href: `/environments/${environment.id}/analysis`,
icon: ChartBar,
isActive: pathname?.includes("/analysis"),
isHidden: false,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
@@ -196,7 +188,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -9,6 +9,7 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -81,7 +82,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper>
);
};
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
},
]}
/>
@@ -6,7 +6,7 @@ import {
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
@@ -56,6 +56,7 @@ export const GET = withV1ApiWrapper({
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
redirect_uri: SLACK_REDIRECT_URI,
};
const formBody: string[] = [];
for (const property in formData) {
+3 -38
View File
@@ -106,7 +106,6 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
@@ -123,8 +122,6 @@ checksums:
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -154,7 +151,6 @@ checksums:
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/create: 757ccd28dd533ff3a933355273c1e32a
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -164,12 +160,11 @@ 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
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
common/description: e17686a22ffad04cc7bb70524ed4478b
common/dev_env: e650911d5e19ba256358e0cda154c005
common/development: 85211dbb918bda7a6e87649dcfc1b17a
@@ -287,7 +282,6 @@ checksums:
common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
common/optional: 396fb9a0472daf401c392bdc3e248943
@@ -587,35 +581,6 @@ checksums:
environments/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
environments/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
environments/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
environments/analysis/charts/action_coming_soon: ee2b0671e00972773210c5be5a9ccb89
environments/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
environments/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
environments/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
environments/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
environments/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
environments/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
environments/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
environments/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
environments/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
environments/analysis/charts/create_chart: ca7fdcc964e01f42ea9709924221edba
environments/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
environments/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
environments/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
environments/analysis/dashboards/create_dashboard: 9396aec1ea4a9b05ada94483655d1373
environments/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
environments/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
environments/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
environments/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
environments/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
environments/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
environments/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
environments/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
environments/analysis/dashboards/description_optional: d5519551a79f18fc414dc127b773485f
environments/analysis/dashboards/description_placeholder: 90a599e6b1695e2b026fb1300d1d5903
environments/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
environments/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
environments/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
environments/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
environments/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
environments/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
environments/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
@@ -1056,7 +1021,7 @@ checksums:
environments/settings/general/email_customization_preview_email_heading: 8b798cb8438b3dd356c02dab33b4c897
environments/settings/general/email_customization_preview_email_text: fa6ae92403cc8f3c35c03e6c94cbde51
environments/settings/general/error_deleting_organization_please_try_again: 7f0fe257d4a0b40bff025408a7766706
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
environments/settings/general/from_your_organization: 9ebd6dcd79f7bfad3fea46ed2e3133d2
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
@@ -1409,7 +1374,6 @@ checksums:
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
environments/surveys/edit/form_styling: 1278a2db4257b5500474161133acc857
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
@@ -1626,6 +1590,7 @@ checksums:
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
+2 -1
View File
@@ -63,7 +63,8 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
@@ -22,9 +22,6 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "erlauben",
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
"analysis": "Analyse",
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
@@ -150,8 +149,6 @@
"bottom_right": "Unten rechts",
"cancel": "Abbrechen",
"centered_modal": "Zentriertes Modalfenster",
"chart": "Diagramm",
"charts": "Diagramme",
"choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
"create": "Erstellen",
"create_new_organization": "Neue Organisation erstellen",
"create_segment": "Segment erstellen",
"create_survey": "Umfrage erstellen",
@@ -191,12 +187,11 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "Tage",
"default": "Standard",
"delete": "Löschen",
"delete_what": "{deleteWhat} löschen",
"description": "Beschreibung",
"dev_env": "Entwicklungsumgebung",
"development": "Entwicklung",
@@ -314,7 +309,6 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"open_options": "Optionen öffnen",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"optional": "Optional",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
},
"analysis": {
"charts": {
"action_coming_soon": "Kommt bald",
"chart_deleted_successfully": "Diagramm erfolgreich gelöscht",
"chart_deletion_error": "Diagramm konnte nicht gelöscht werden",
"chart_duplicated_successfully": "Diagramm erfolgreich dupliziert",
"chart_duplication_error": "Diagramm konnte nicht dupliziert werden",
"chart_type_area": "Flächendiagramm",
"chart_type_bar": "Balkendiagramm",
"chart_type_big_number": "Große Zahl",
"chart_type_line": "Liniendiagramm",
"chart_type_pie": "Kreisdiagramm",
"create_chart": "Diagramm erstellen",
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
"no_charts_found": "Keine Diagramme gefunden.",
"open_options": "Diagrammoptionen öffnen"
},
"dashboards": {
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
"create_failed": "Dashboard konnte nicht erstellt werden",
"create_success": "Dashboard erfolgreich erstellt!",
"dashboard_name": "Dashboard-Name",
"dashboard_name_placeholder": "Mein Dashboard",
"delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_failed": "Dashboard konnte nicht gelöscht werden",
"delete_success": "Dashboard erfolgreich gelöscht",
"description_optional": "Beschreibung (optional)",
"description_placeholder": "Dashboard-Beschreibung",
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
"duplicate_success": "Dashboard erfolgreich dupliziert!",
"no_dashboards_found": "Keine Dashboards gefunden.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
}
},
"connect": {
"congrats": "Glückwunsch!",
"connection_successful_message": "Gut gemacht! Wir sind verbunden.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.",
"error_deleting_organization_please_try_again": "Fehler beim Löschen der Organisation. Bitte versuche es erneut.",
"from_your_organization": "von deiner Organisation",
"from_your_organization": "{memberName} aus Ihrer Organisation",
"invitation_sent_once_more": "Einladung nochmal gesendet.",
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
"invite_expires_on": "Einladung läuft ab am {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"form_styling": "Umfrage Styling",
"formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden",
"four_points": "4 Punkte",
"heading": "Überschrift",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage",
"survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
"target_block_not_found": "Zielblock nicht gefunden",
+3 -164
View File
@@ -133,7 +133,6 @@
"allow": "Allow",
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
"analysis": "Analysis",
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
@@ -150,8 +149,6 @@
"bottom_right": "Bottom Right",
"cancel": "Cancel",
"centered_modal": "Centered Modal",
"chart": "Chart",
"charts": "Charts",
"choices": "Choices",
"choose_environment": "Choose environment",
"choose_organization": "Choose organization",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
"create": "Create",
"create_new_organization": "Create new organization",
"create_segment": "Create segment",
"create_survey": "Create survey",
@@ -191,12 +187,11 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Date",
"days": "days",
"default": "Default",
"delete": "Delete",
"delete_what": "Delete {deleteWhat}",
"description": "Description",
"dev_env": "Dev Environment",
"development": "Development",
@@ -245,7 +240,6 @@
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide": "Hide",
"hide_column": "Hide column",
"id": "ID",
"image": "Image",
@@ -315,7 +309,6 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"open_options": "Open options",
"option_id": "Option ID",
"option_ids": "Option IDs",
"optional": "Optional",
@@ -458,7 +451,6 @@
"variables": "Variables",
"verified_email": "Verified Email",
"video": "Video",
"view": "View",
"warning": "Warning",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
"webhook": "Webhook",
@@ -622,159 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
"your_survey_would_not_be_shown": "Your survey would not be shown."
},
"analysis": {
"charts": {
"AND": "AND",
"OR": "OR",
"add_chart_to_dashboard": "Add Chart to Dashboard",
"add_chart_to_dashboard_description": "Select a dashboard to add this chart to. The chart will be saved automatically.",
"add_custom_measure": "Add Custom Measure",
"add_filter": "Add filter",
"add_to_dashboard": "Add to Dashboard",
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
"ai_query_placeholder": "e.g. How many users signed up last week?",
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
"ai_query_section_title": "Ask your data",
"alias_optional": "Alias (optional)",
"apply_changes": "Apply Changes",
"chart": "Chart",
"chart_added_to_dashboard": "Chart added to dashboard!",
"chart_builder_choose_chart_type": "Choose chart type",
"chart_data": "Chart Data",
"chart_data_tab": "Data",
"chart_deleted_successfully": "Chart deleted successfully",
"chart_duplicated_successfully": "Chart duplicated successfully",
"chart_duplication_error": "Failed to duplicate chart",
"chart_name": "Chart Name",
"chart_name_placeholder": "Chart name",
"chart_preview": "Chart Preview",
"chart_saved_successfully": "Chart saved successfully!",
"chart_type_area": "Area Chart",
"chart_type_bar": "Bar Chart",
"chart_type_big_number": "Big Number",
"chart_type_donut": "Donut Chart",
"chart_type_line": "Line Chart",
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
"chart_type_pie": "Pie Chart",
"chart_updated_successfully": "Chart updated successfully!",
"configure_description": "Modify the chart type and other settings for this visualization.",
"configure_title": "Configure Chart",
"configure_type_label": "Chart Type",
"contains": "contains",
"create_chart": "Create Chart",
"create_chart_description": "Use AI to generate a chart or build one manually.",
"cube_js_query": "Cube.js Query",
"custom_aggregations": "Custom Aggregations",
"custom_aggregations_toggle_description": "Define custom metrics using aggregations (avg, sum, min, max, etc.) on numeric dimension fields.",
"custom_range": "Custom Range",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "Select a dashboard",
"data_label": "Data",
"date_range": "Date Range",
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
"dimensions": "Dimensions",
"dimensions_toggle_description": "Group data by categories. Order matters for multi-dimensional charts.",
"edit_chart_description": "View and edit your chart configuration.",
"edit_chart_title": "Edit Chart",
"enable_time_dimension": "Enable Time Dimension",
"end_date": "End date",
"enter_a_name_for_your_chart": "Enter a name for your chart to save it.",
"enter_value": "Enter value",
"equals": "equals",
"failed_to_add_chart_to_dashboard": "Failed to add chart to dashboard",
"failed_to_execute_query": "Failed to execute query",
"failed_to_load_chart": "Failed to load chart",
"failed_to_load_chart_data": "Failed to load chart data",
"failed_to_save_chart": "Failed to save chart",
"field": "Field",
"filters": "Filters",
"filters_toggle_description": "Only include data that meets the following conditions.",
"generating_chart": "Generating chart...",
"granularity": "Granularity",
"greater_than": "greater than",
"greater_than_or_equal": "greater than or equal",
"group_by": "Group By",
"group_by_description": "Select dimensions to break down your data. The order matters for multi-dimensional charts.",
"guide_button": "View field guide",
"guide_chart_type": "Chart type",
"guide_chart_type_desc": "How the data is visualized: Area, Bar, Line, Pie, or Big Number. Choose based on what you want to show (trends, comparisons, parts of a whole, etc.).",
"guide_dimensions": "Dimensions (Group By)",
"guide_dimensions_desc": "How you split or group the data. Each dimension becomes a category on the chart (e.g. Sentiment, Source Type, Survey Name, Channel, Topic). Order matters for multi-dimensional charts.",
"guide_filters": "Filters",
"guide_filters_desc": "Conditions that limit which data is included. Each filter has a field, operator (equals, contains, greater than, etc.), and values. And = all must match; Or = any can match.",
"guide_measures": "Measures (what you count or aggregate)",
"guide_measures_custom": "Custom aggregations let you define your own metrics: pick a numeric field (e.g. Rating, NPS Value) and an aggregation (count, countDistinct, sum, avg, min, max). Alias is an optional label for the chart.",
"guide_measures_predefined": "Predefined measures are pre-built metrics from your feedback data: Count (total responses), Promoter/Detractor/Passive Count (NPS segments), NPS Score, Average Score, Completion Rate.",
"guide_quick_ref": "Quick reference",
"guide_term_custom": "Measure you define: field + aggregation (avg, sum, etc.)",
"guide_term_dimension": "Categorical field used to group or split data",
"guide_term_filter": "Condition that limits which rows are included",
"guide_term_measure": "Numeric value you aggregate (count, sum, avg, etc.)",
"guide_term_time": "Time-based grouping with granularity and date range",
"guide_time_dimension": "Time dimension",
"guide_time_dimension_desc": "Time-based grouping: pick a time field (usually Collected At), granularity (Hour, Day, Week, Month, etc.), and date range (preset or custom). Use for trends over time.",
"guide_title": "Chart Builder Field Guide",
"is_not_set": "is not set",
"is_set": "is set",
"less_than": "less than",
"less_than_or_equal": "less than or equal",
"measures": "Measures",
"measures_toggle_description": "Select predefined or custom metrics to display in the chart.",
"no_charts_found": "No charts found.",
"no_dashboards_available": "No dashboards available",
"no_dashboards_create_first": "Create a dashboard first to add charts to it.",
"no_data_available": "No data available",
"no_data_returned": "No data returned from query",
"no_data_returned_for_chart": "No data returned for chart",
"no_grouping": "None (filter only)",
"no_valid_data_to_display": "No valid data to display",
"not_contains": "not contains",
"not_equals": "not equals",
"open_chart": "Open chart {{name}}",
"open_options": "Open chart options",
"or_filter_logic": "OR",
"original": "Original",
"please_enter_chart_name": "Please enter a chart name",
"please_run_query_first": "Please run a query first",
"please_select_at_least_one_measure": "Please select at least one measure",
"please_select_chart_type": "Please select a chart type",
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"query_executed_successfully": "Query executed successfully",
"query_label": "Query",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"run_query": "Run Query",
"save_chart": "Save Chart",
"save_chart_dialog_title": "Save Chart",
"select_field": "Select field",
"select_measures": "Select measures...",
"select_preset": "Select preset",
"showing_first_10_of": "Showing first 10 of {count} rows",
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_toggle_description": "Add time-based grouping for trends over time.",
"unable_to_determine_chart_data_structure": "Unable to determine chart data structure"
},
"dashboards": {
"create_dashboard": "Create Dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
"create_failed": "Failed to create dashboard",
"create_success": "Dashboard created successfully!",
"dashboard_name": "Dashboard Name",
"dashboard_name_placeholder": "My dashboard",
"delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
"delete_failed": "Failed to delete dashboard",
"delete_success": "Dashboard deleted successfully",
"description_optional": "Description (Optional)",
"description_placeholder": "Dashboard description",
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"no_dashboards_found": "No dashboards found.",
"please_enter_name": "Please enter a dashboard name"
}
},
"connect": {
"congrats": "Congrats!",
"connection_successful_message": "Well done! We are connected.",
@@ -1243,7 +1082,7 @@
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "This is an email preview to show you which logo will be rendered in the emails.",
"error_deleting_organization_please_try_again": "Error deleting organization. Please try again.",
"from_your_organization": "from your organization",
"from_your_organization": "{memberName} from your organization",
"invitation_sent_once_more": "Invitation sent once more.",
"invite_deleted_successfully": "Invite deleted successfully",
"invite_expires_on": "Invite expires on {date}",
@@ -1606,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"form_styling": "Form styling",
"formbricks_sdk_is_not_connected": "Formbricks SDK is not connected",
"four_points": "4 points",
"heading": "Heading",
@@ -1825,6 +1663,7 @@
"survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings",
"survey_placement": "Survey Placement",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
"target_block_not_found": "Target block not found",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
"analysis": "Análisis",
"and": "Y",
"and_response_limit_of": "y límite de respuesta de",
"anonymous": "Anónimo",
@@ -150,8 +149,6 @@
"bottom_right": "Inferior derecha",
"cancel": "Cancelar",
"centered_modal": "Modal centrado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Opciones",
"choose_environment": "Elegir entorno",
"choose_organization": "Elegir organización",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
"create": "Crear",
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
@@ -191,12 +187,11 @@
"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",
"delete": "Eliminar",
"delete_what": "Eliminar {deleteWhat}",
"description": "Descripción",
"dev_env": "Entorno de desarrollo",
"development": "Desarrollo",
@@ -314,7 +309,6 @@
"on": "Activado",
"only_one_file_allowed": "Solo se permite un archivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
"open_options": "Abrir opciones",
"option_id": "ID de opción",
"option_ids": "IDs de opciones",
"optional": "Opcional",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
},
"analysis": {
"charts": {
"action_coming_soon": "Próximamente",
"chart_deleted_successfully": "Gráfico eliminado correctamente",
"chart_deletion_error": "Error al eliminar el gráfico",
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
"chart_duplication_error": "Error al duplicar el gráfico",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de líneas",
"chart_type_pie": "Gráfico circular",
"create_chart": "Crear gráfico",
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
"no_charts_found": "No se encontraron gráficos.",
"open_options": "Abrir opciones del gráfico"
},
"dashboards": {
"create_dashboard": "Crear panel de control",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
"create_failed": "Error al crear el panel de control",
"create_success": "Panel de control creado correctamente",
"dashboard_name": "Nombre del panel de control",
"dashboard_name_placeholder": "Mi panel de control",
"delete_confirmation": "¿Estás seguro de que quieres eliminar este panel de control? Esta acción no se puede deshacer.",
"delete_failed": "Error al eliminar el panel de control",
"delete_success": "Panel de control eliminado correctamente",
"description_optional": "Descripción (opcional)",
"description_placeholder": "Descripción del panel de control",
"duplicate_failed": "Error al duplicar el panel de control",
"duplicate_success": "Panel de control duplicado correctamente",
"no_dashboards_found": "No se han encontrado paneles de control.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
}
},
"connect": {
"congrats": "¡Enhorabuena!",
"connection_successful_message": "¡Bien hecho! Estamos conectados.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Hola {userName}",
"email_customization_preview_email_text": "Este es un correo electrónico de vista previa para mostrarte qué logotipo se mostrará en los correos electrónicos.",
"error_deleting_organization_please_try_again": "Error al eliminar la organización. Por favor, inténtalo de nuevo.",
"from_your_organization": "de tu organización",
"from_your_organization": "{memberName} de tu organización",
"invitation_sent_once_more": "Invitación enviada una vez más.",
"invite_deleted_successfully": "Invitación eliminada correctamente",
"invite_expires_on": "La invitación expira el {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Seguimiento actualizado y se guardará cuando guardes la encuesta.",
"follow_ups_new": "Nuevo seguimiento",
"follow_ups_upgrade_button_text": "Actualiza para habilitar seguimientos",
"form_styling": "Estilo del formulario",
"formbricks_sdk_is_not_connected": "El SDK de Formbricks no está conectado",
"four_points": "4 puntos",
"heading": "Encabezado",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta",
"survey_placement": "Ubicación de la encuesta",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
"target_block_not_found": "Bloque objetivo no encontrado",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "Autoriser",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
"analysis": "Analyse",
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",
@@ -150,8 +149,6 @@
"bottom_right": "En bas à droite",
"cancel": "Annuler",
"centered_modal": "Au centre",
"chart": "Graphique",
"charts": "Graphiques",
"choices": "Choix",
"choose_environment": "Choisir l'environnement",
"choose_organization": "Choisir l'organisation",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
"count_responses": "{value, plural, other {# réponses}}",
"create": "Créer",
"create_new_organization": "Créer une nouvelle organisation",
"create_segment": "Créer un segment",
"create_survey": "Créer un sondage",
@@ -191,12 +187,11 @@
"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",
"delete": "Supprimer",
"delete_what": "Supprimer {deleteWhat}",
"description": "Description",
"dev_env": "Environnement de développement",
"development": "Développement",
@@ -314,7 +309,6 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"open_options": "Ouvrir les options",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"optional": "Facultatif",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
},
"analysis": {
"charts": {
"action_coming_soon": "À venir bientôt",
"chart_deleted_successfully": "Graphique supprimé avec succès",
"chart_deletion_error": "Échec de la suppression du graphique",
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
"chart_duplication_error": "Échec de la duplication du graphique",
"chart_type_area": "Graphique en aires",
"chart_type_bar": "Graphique à barres",
"chart_type_big_number": "Grand nombre",
"chart_type_line": "Graphique linéaire",
"chart_type_pie": "Graphique circulaire",
"create_chart": "Créer un graphique",
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique?",
"no_charts_found": "Aucun graphique trouvé.",
"open_options": "Ouvrir les options du graphique"
},
"dashboards": {
"create_dashboard": "Créer un tableau de bord",
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
"create_failed": "Échec de la création du tableau de bord",
"create_success": "Tableau de bord créé avec succès!",
"dashboard_name": "Nom du tableau de bord",
"dashboard_name_placeholder": "Mon tableau de bord",
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce tableau de bord? Cette action est irréversible.",
"delete_failed": "Échec de la suppression du tableau de bord",
"delete_success": "Tableau de bord supprimé avec succès",
"description_optional": "Description (facultatif)",
"description_placeholder": "Description du tableau de bord",
"duplicate_failed": "Échec de la duplication du tableau de bord",
"duplicate_success": "Tableau de bord dupliqué avec succès!",
"no_dashboards_found": "Aucun tableau de bord trouvé.",
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
}
},
"connect": {
"congrats": "Félicitations !",
"connection_successful_message": "Bien joué ! Nous sommes connectés.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_text": "Cette est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
"error_deleting_organization_please_try_again": "Erreur lors de la suppression de l'organisation. Veuillez réessayer.",
"from_your_organization": "de votre organisation",
"from_your_organization": "{memberName} de votre organisation",
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
"invite_deleted_successfully": "Invitation supprimée avec succès",
"invite_expires_on": "L'invitation expire le {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_new": "Nouveau suivi",
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
"form_styling": "Style de formulaire",
"formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté",
"four_points": "4 points",
"heading": "En-tête",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête",
"survey_placement": "Placement de l'enquête",
"survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
"target_block_not_found": "Bloc cible non trouvé",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "Engedélyezés",
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
"analysis": "Elemzés",
"and": "És",
"and_response_limit_of": "és kérdéskorlátja ennek:",
"anonymous": "Névtelen",
@@ -150,8 +149,6 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"chart": "Diagram",
"charts": "Diagramok",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
"choose_organization": "Szervezet kiválasztása",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
"create": "Létrehozás",
"create_new_organization": "Új szervezet létrehozása",
"create_segment": "Szakasz létrehozása",
"create_survey": "Kérdőív létrehozása",
@@ -191,12 +187,11 @@
"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",
"delete": "Törlés",
"delete_what": "{deleteWhat} törlése",
"description": "Leírás",
"dev_env": "Fejlesztői környezet",
"development": "Fejlesztés",
@@ -314,7 +309,6 @@
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
"open_options": "Beállítások megnyitása",
"option_id": "Választásazonosító",
"option_ids": "Választásazonosítók",
"optional": "Elhagyható",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
},
"analysis": {
"charts": {
"action_coming_soon": "Hamarosan",
"chart_deleted_successfully": "A diagram sikeresen törölve",
"chart_deletion_error": "A diagram törlése sikertelen",
"chart_duplicated_successfully": "A diagram sikeresen duplikálva",
"chart_duplication_error": "A diagram duplikálása sikertelen",
"chart_type_area": "Területdiagram",
"chart_type_bar": "Oszlopdiagram",
"chart_type_big_number": "Nagy szám",
"chart_type_line": "Vonaldiagram",
"chart_type_pie": "Kördiagram",
"create_chart": "Diagram létrehozása",
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
"no_charts_found": "Nem található diagram.",
"open_options": "Diagram beállításainak megnyitása"
},
"dashboards": {
"create_dashboard": "Vezérlőpult létrehozása",
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
"create_failed": "A vezérlőpult létrehozása sikertelen",
"create_success": "A vezérlőpult sikeresen létrehozva!",
"dashboard_name": "Vezérlőpult neve",
"dashboard_name_placeholder": "Saját vezérlőpult",
"delete_confirmation": "Biztosan törölni szeretné ezt a vezérlőpultot? Ez a művelet nem vonható vissza.",
"delete_failed": "A vezérlőpult törlése sikertelen",
"delete_success": "A vezérlőpult sikeresen törölve",
"description_optional": "Leírás (opcionális)",
"description_placeholder": "Vezérlőpult leírása",
"duplicate_failed": "A vezérlőpult másolása sikertelen",
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
"no_dashboards_found": "Nem található vezérlőpult.",
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
}
},
"connect": {
"congrats": "Gratulálunk!",
"connection_successful_message": "Szép munka! Kapcsolódtunk.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Helló {userName}",
"email_customization_preview_email_text": "Ez egy e-mail előnézet, amely azt mutatja meg, hogy melyik logó fog megjelenni az e-mailekben.",
"error_deleting_organization_please_try_again": "Hiba a szervezet törlésekor. Próbálja meg újra.",
"from_your_organization": "a szervezetétől",
"from_your_organization": "{memberName} a szervezetből",
"invitation_sent_once_more": "A meghívó még egyszer elküldve.",
"invite_deleted_successfully": "A meghívó sikeresen törölve",
"invite_expires_on": "A meghívó lejár ekkor: {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új követés",
"follow_ups_upgrade_button_text": "Magasabb csomagra váltás a követések engedélyezéséhez",
"form_styling": "Űrlap stílusának beállítása",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont",
"heading": "Címsor",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
"survey_placement": "Kérdőív elhelyezése",
"survey_styling": "Űrlap stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
"target_block_not_found": "A célblokk nem található",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "許可",
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
"analysis": "分析",
"and": "および",
"and_response_limit_of": "と回答数の上限",
"anonymous": "匿名",
@@ -150,8 +149,6 @@
"bottom_right": "右下",
"cancel": "キャンセル",
"centered_modal": "中央モーダル",
"chart": "チャート",
"charts": "チャート",
"choices": "選択肢",
"choose_environment": "環境を選択",
"choose_organization": "組織を選択",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, other {{value}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"create": "作成",
"create_new_organization": "新しい組織を作成",
"create_segment": "セグメントを作成",
"create_survey": "フォームを作成",
@@ -191,12 +187,11 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"dashboard": "ダッシュボード",
"dashboards": "ダッシュボード",
"date": "日付",
"days": "日",
"default": "デフォルト",
"delete": "削除",
"delete_what": "{deleteWhat}を削除",
"description": "説明",
"dev_env": "開発環境",
"development": "開発",
@@ -314,7 +309,6 @@
"on": "オン",
"only_one_file_allowed": "ファイルは1つのみ許可されています",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
"open_options": "オプションを開く",
"option_id": "オプションID",
"option_ids": "オプションID",
"optional": "任意",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
},
"analysis": {
"charts": {
"action_coming_soon": "近日公開",
"chart_deleted_successfully": "チャートを削除しました",
"chart_deletion_error": "チャートの削除に失敗しました",
"chart_duplicated_successfully": "チャートを複製しました",
"chart_duplication_error": "チャートの複製に失敗しました",
"chart_type_area": "エリアチャート",
"chart_type_bar": "棒グラフ",
"chart_type_big_number": "大きな数値",
"chart_type_line": "折れ線グラフ",
"chart_type_pie": "円グラフ",
"create_chart": "チャートを作成",
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
"no_charts_found": "チャートが見つかりません。",
"open_options": "チャートオプションを開く"
},
"dashboards": {
"create_dashboard": "ダッシュボードを作成",
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
"create_failed": "ダッシュボードの作成に失敗しました",
"create_success": "ダッシュボードを正常に作成しました!",
"dashboard_name": "ダッシュボード名",
"dashboard_name_placeholder": "マイダッシュボード",
"delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
"delete_failed": "ダッシュボードの削除に失敗しました",
"delete_success": "ダッシュボードを正常に削除しました",
"description_optional": "説明(任意)",
"description_placeholder": "ダッシュボードの説明",
"duplicate_failed": "ダッシュボードの複製に失敗しました",
"duplicate_success": "ダッシュボードを正常に複製しました!",
"no_dashboards_found": "ダッシュボードが見つかりません。",
"please_enter_name": "ダッシュボード名を入力してください"
}
},
"connect": {
"congrats": "おめでとうございます!",
"connection_successful_message": "うまくいきました!接続されました。",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "こんにちは、{userName}さん",
"email_customization_preview_email_text": "これは、メールに表示されるロゴを確認するためのプレビューメールです。",
"error_deleting_organization_please_try_again": "組織の削除中にエラーが発生しました。もう一度お試しください。",
"from_your_organization": "あなたの組織から",
"from_your_organization": "組織から{memberName}を削除",
"invitation_sent_once_more": "招待状を再度送信しました。",
"invite_deleted_successfully": "招待を正常に削除しました",
"invite_expires_on": "招待は{date}に期限切れ",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
"follow_ups_new": "新しいフォローアップ",
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
"form_styling": "フォームのスタイル",
"formbricks_sdk_is_not_connected": "Formbricks SDKが接続されていません",
"four_points": "4点",
"heading": "見出し",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定",
"survey_placement": "フォームの配置",
"survey_styling": "フォームのスタイル",
"survey_trigger": "フォームのトリガー",
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
"target_block_not_found": "対象ブロックが見つかりません",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "Toestaan",
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
"analysis": "Analyse",
"and": "En",
"and_response_limit_of": "en responslimiet van",
"anonymous": "Anoniem",
@@ -150,8 +149,6 @@
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
"centered_modal": "Gecentreerd modaal",
"chart": "Grafiek",
"charts": "Grafieken",
"choices": "Keuzes",
"choose_environment": "Kies omgeving",
"choose_organization": "Kies organisatie",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
"create": "Creëren",
"create_new_organization": "Creëer een nieuwe organisatie",
"create_segment": "Segment maken",
"create_survey": "Enquête maken",
@@ -191,12 +187,11 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
"delete": "Verwijderen",
"delete_what": "Verwijder {deleteWhat}",
"description": "Beschrijving",
"dev_env": "Ontwikkelomgeving",
"development": "Ontwikkeling",
@@ -314,7 +309,6 @@
"on": "Op",
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
"open_options": "Opties openen",
"option_id": "Optie-ID",
"option_ids": "Optie-ID's",
"optional": "Optioneel",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
},
"analysis": {
"charts": {
"action_coming_soon": "Binnenkort beschikbaar",
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
"chart_deletion_error": "Verwijderen van grafiek mislukt",
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
"chart_duplication_error": "Dupliceren van grafiek mislukt",
"chart_type_area": "Vlakdiagram",
"chart_type_bar": "Staafdiagram",
"chart_type_big_number": "Groot getal",
"chart_type_line": "Lijndiagram",
"chart_type_pie": "Cirkeldiagram",
"create_chart": "Diagram maken",
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
"no_charts_found": "Geen diagrammen gevonden.",
"open_options": "Open diagramopties"
},
"dashboards": {
"create_dashboard": "Dashboard creëren",
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
"create_failed": "Dashboard creëren mislukt",
"create_success": "Dashboard succesvol aangemaakt!",
"dashboard_name": "Dashboardnaam",
"dashboard_name_placeholder": "Mijn dashboard",
"delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_failed": "Dashboard verwijderen mislukt",
"delete_success": "Dashboard succesvol verwijderd",
"description_optional": "Beschrijving (optioneel)",
"description_placeholder": "Dashboardbeschrijving",
"duplicate_failed": "Dashboard dupliceren mislukt",
"duplicate_success": "Dashboard succesvol gedupliceerd!",
"no_dashboards_found": "Geen dashboards gevonden.",
"please_enter_name": "Voer een dashboardnaam in"
}
},
"connect": {
"congrats": "Gefeliciteerd!",
"connection_successful_message": "Goed gedaan! We zijn verbonden.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Hé {userName}",
"email_customization_preview_email_text": "Dit is een e-mailvoorbeeld om u te laten zien welk logo in de e-mails wordt weergegeven.",
"error_deleting_organization_please_try_again": "Fout bij verwijderen van organisatie. Probeer het opnieuw.",
"from_your_organization": "vanuit uw organisatie",
"from_your_organization": "{memberName} uit je organisatie",
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
"invite_expires_on": "Uitnodiging verloopt op {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Follow-up bijgewerkt en wordt opgeslagen zodra u de enquête opslaat.",
"follow_ups_new": "Nieuw vervolg",
"follow_ups_upgrade_button_text": "Upgrade om follow-ups mogelijk te maken",
"form_styling": "Vorm styling",
"formbricks_sdk_is_not_connected": "Formbricks SDK is niet verbonden",
"four_points": "4 punten",
"heading": "Rubriek",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen",
"survey_placement": "Enquête plaatsing",
"survey_styling": "Vorm styling",
"survey_trigger": "Enquêtetrigger",
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
"target_block_not_found": "Doelblok niet gevonden",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
@@ -150,8 +149,6 @@
"bottom_right": "Canto Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolher ambiente",
"choose_organization": "Escolher organização",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
@@ -191,12 +187,11 @@
"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",
"delete": "Apagar",
"delete_what": "Excluir {deleteWhat}",
"description": "Descrição",
"dev_env": "Ambiente de Desenvolvimento",
"development": "Desenvolvimento",
@@ -314,7 +309,6 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"open_options": "Abrir opções",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"optional": "Opcional",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
},
"analysis": {
"charts": {
"action_coming_soon": "Em breve",
"chart_deleted_successfully": "Gráfico excluído com sucesso",
"chart_deletion_error": "Falha ao excluir gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de linhas",
"chart_type_pie": "Gráfico de pizza",
"create_chart": "Criar gráfico",
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
"no_charts_found": "Nenhum gráfico encontrado.",
"open_options": "Abrir opções do gráfico"
},
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Digite um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_success": "Painel criado com sucesso!",
"dashboard_name": "Nome do painel",
"dashboard_name_placeholder": "Meu painel",
"delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
"delete_failed": "Falha ao excluir painel",
"delete_success": "Painel excluído com sucesso",
"description_optional": "Descrição (opcional)",
"description_placeholder": "Descrição do painel",
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"no_dashboards_found": "Nenhum painel encontrado.",
"please_enter_name": "Por favor, digite um nome para o painel"
}
},
"connect": {
"congrats": "Parabéns!",
"connection_successful_message": "Mandou bem! Estamos conectados.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Oi {userName}",
"email_customization_preview_email_text": "Esta é uma pré-visualização de e-mail para mostrar qual logo será renderizado nos e-mails.",
"error_deleting_organization_please_try_again": "Erro ao deletar a organização. Por favor, tente novamente.",
"from_your_organization": "da sua organização",
"from_your_organization": "{memberName} da sua organização",
"invitation_sent_once_more": "Convite enviado de novo.",
"invite_deleted_successfully": "Convite deletado com sucesso",
"invite_expires_on": "O convite expira em {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"form_styling": "Estilização de Formulários",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Título",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa",
"survey_placement": "Posicionamento da Pesquisa",
"survey_styling": "Estilização de Formulários",
"survey_trigger": "Gatilho de Pesquisa",
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
@@ -150,8 +149,6 @@
"bottom_right": "Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
"choose_organization": "Escolher organização",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
@@ -191,12 +187,11 @@
"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",
"delete": "Eliminar",
"delete_what": "Eliminar {deleteWhat}",
"description": "Descrição",
"dev_env": "Ambiente de Desenvolvimento",
"development": "Desenvolvimento",
@@ -314,7 +309,6 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"open_options": "Abrir opções",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"optional": "Opcional",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
},
"analysis": {
"charts": {
"action_coming_soon": "Em breve",
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
"chart_deletion_error": "Falha ao eliminar gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de linhas",
"chart_type_pie": "Gráfico circular",
"create_chart": "Criar gráfico",
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
"no_charts_found": "Nenhum gráfico encontrado.",
"open_options": "Abrir opções do gráfico"
},
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_success": "Painel criado com sucesso!",
"dashboard_name": "Nome do painel",
"dashboard_name_placeholder": "O meu painel",
"delete_confirmation": "Tem a certeza de que pretende eliminar este painel? Esta ação não pode ser revertida.",
"delete_failed": "Falha ao eliminar painel",
"delete_success": "Painel eliminado com sucesso",
"description_optional": "Descrição (opcional)",
"description_placeholder": "Descrição do painel",
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"no_dashboards_found": "Nenhum painel encontrado.",
"please_enter_name": "Por favor, introduza um nome para o painel"
}
},
"connect": {
"congrats": "Parabéns!",
"connection_successful_message": "Muito bem! Estamos ligados.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Olá {userName}",
"email_customization_preview_email_text": "Esta é uma pré-visualização de email para mostrar qual logotipo será exibido nos emails.",
"error_deleting_organization_please_try_again": "Erro ao eliminar a organização. Por favor, tente novamente.",
"from_your_organization": "da sua organização",
"from_your_organization": "{memberName} da sua organização",
"invitation_sent_once_more": "Convite enviado mais uma vez.",
"invite_deleted_successfully": "Convite eliminado com sucesso",
"invite_expires_on": "O convite expira em {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
"form_styling": "Estilo do formulário",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Cabeçalho",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito",
"survey_placement": "Colocação do Inquérito",
"survey_styling": "Estilo do formulário",
"survey_trigger": "Desencadeador de Inquérito",
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "Permite",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
"analysis": "Analiză",
"and": "Și",
"and_response_limit_of": "și limită răspuns",
"anonymous": "Anonim",
@@ -150,8 +149,6 @@
"bottom_right": "Dreapta Jos",
"cancel": "Anulare",
"centered_modal": "Modală centralizată",
"chart": "Grafic",
"charts": "Grafice",
"choices": "Alegeri",
"choose_environment": "Alege mediul",
"choose_organization": "Alege organizația",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
"create": "Creează",
"create_new_organization": "Creează organizație nouă",
"create_segment": "Creați segment",
"create_survey": "Creează sondaj",
@@ -191,12 +187,11 @@
"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",
"delete": "Șterge",
"delete_what": "Șterge {deleteWhat}",
"description": "Descriere",
"dev_env": "Mediu de dezvoltare",
"development": "Dezvoltare",
@@ -314,7 +309,6 @@
"on": "Pe",
"only_one_file_allowed": "Este permis doar un fișier",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
"open_options": "Deschide opțiunile",
"option_id": "ID opțiune",
"option_ids": "ID-uri opțiuni",
"optional": "Opțional",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
},
"analysis": {
"charts": {
"action_coming_soon": "În curând",
"chart_deleted_successfully": "Graficul a fost șters cu succes",
"chart_deletion_error": "Nu s-a putut șterge graficul",
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
"chart_duplication_error": "Nu s-a putut duplica graficul",
"chart_type_area": "Grafic de tip arie",
"chart_type_bar": "Grafic de tip bară",
"chart_type_big_number": "Număr mare",
"chart_type_line": "Grafic de tip linie",
"chart_type_pie": "Grafic de tip plăcintă",
"create_chart": "Creează grafic",
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
"no_charts_found": "Nu s-au găsit grafice.",
"open_options": "Deschide opțiunile graficului"
},
"dashboards": {
"create_dashboard": "Creează tablou de bord",
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
"create_failed": "Crearea tabloului de bord a eșuat",
"create_success": "Tablou de bord creat cu succes!",
"dashboard_name": "Nume tablou de bord",
"dashboard_name_placeholder": "Tabloul meu de bord",
"delete_confirmation": "Ești sigur că vrei să ștergi acest tablou de bord? Această acțiune nu poate fi anulată.",
"delete_failed": "Ștergerea tabloului de bord a eșuat",
"delete_success": "Tablou de bord șters cu succes",
"description_optional": "Descriere (opțional)",
"description_placeholder": "Descriere tablou de bord",
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
"duplicate_success": "Tablou de bord duplicat cu succes!",
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
}
},
"connect": {
"congrats": "Felicitări!",
"connection_successful_message": "Bravo! Suntem conectați.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_text": "Acesta este o previzualizare a e-mailului pentru a vă arăta ce logo va fi afișat în e-mailurile.",
"error_deleting_organization_please_try_again": "Eroare la ștergerea organizației. Vă rugăm să încercați din nou.",
"from_your_organization": "din organizația ta",
"from_your_organization": "{memberName} din organizația ta",
"invitation_sent_once_more": "Invitație trimisă din nou.",
"invite_deleted_successfully": "Invitație ștearsă cu succes",
"invite_expires_on": "Invitația expiră pe {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
"follow_ups_new": "Follow-up nou",
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
"form_styling": "Stilizare formular",
"formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat",
"four_points": "4 puncte",
"heading": "Titlu",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
"survey_display_settings": "Setări de afișare a sondajului",
"survey_placement": "Amplasarea sondajului",
"survey_styling": "Stilizare formular",
"survey_trigger": "Declanșator sondaj",
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
"target_block_not_found": "Blocul țintă nu a fost găsit",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "Разрешить",
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
"analysis": "Аналитика",
"and": "и",
"and_response_limit_of": "и лимит ответов",
"anonymous": "Аноним",
@@ -150,8 +149,6 @@
"bottom_right": "Внизу справа",
"cancel": "Отмена",
"centered_modal": "Центрированное модальное окно",
"chart": "График",
"charts": "Графики",
"choices": "Варианты",
"choose_environment": "Выберите среду",
"choose_organization": "Выберите организацию",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
"create": "Создать",
"create_new_organization": "Создать новую организацию",
"create_segment": "Создать сегмент",
"create_survey": "Создать опрос",
@@ -191,12 +187,11 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"dashboard": "Панель управления",
"dashboards": "Дашборды",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
"delete": "Удалить",
"delete_what": "Удалить {deleteWhat}",
"description": "Описание",
"dev_env": "Dev Environment",
"development": "Разработка",
@@ -314,7 +309,6 @@
"on": "Вкл.",
"only_one_file_allowed": "Разрешён только один файл",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
"open_options": "Открыть параметры",
"option_id": "ID опции",
"option_ids": "ID опций",
"optional": "Необязательно",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
},
"analysis": {
"charts": {
"action_coming_soon": "Скоро будет",
"chart_deleted_successfully": "График успешно удалён",
"chart_deletion_error": "Не удалось удалить график",
"chart_duplicated_successfully": "График успешно дублирован",
"chart_duplication_error": "Не удалось дублировать график",
"chart_type_area": "График областью",
"chart_type_bar": "Столбчатая диаграмма",
"chart_type_big_number": "Большое число",
"chart_type_line": "Линейный график",
"chart_type_pie": "Круговая диаграмма",
"create_chart": "Создать график",
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
"no_charts_found": "Графики не найдены.",
"open_options": "Открыть настройки графика"
},
"dashboards": {
"create_dashboard": "Создать панель управления",
"create_dashboard_description": "Введите название для новой панели управления.",
"create_failed": "Не удалось создать панель управления",
"create_success": "Панель управления успешно создана!",
"dashboard_name": "Название панели управления",
"dashboard_name_placeholder": "Моя панель управления",
"delete_confirmation": "Ты уверен, что хочешь удалить эту панель управления? Это действие нельзя отменить.",
"delete_failed": "Не удалось удалить панель управления",
"delete_success": "Панель управления успешно удалена",
"description_optional": "Описание (необязательно)",
"description_placeholder": "Описание панели управления",
"duplicate_failed": "Не удалось дублировать панель управления",
"duplicate_success": "Панель управления успешно продублирована!",
"no_dashboards_found": "Панели управления не найдены.",
"please_enter_name": "Пожалуйста, введите название панели управления"
}
},
"connect": {
"congrats": "Поздравляем!",
"connection_successful_message": "Отлично! Мы подключены.",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "Привет, {userName}",
"email_customization_preview_email_text": "Это предварительный просмотр письма, чтобы показать, какой логотип будет отображаться в письмах.",
"error_deleting_organization_please_try_again": "Ошибка при удалении организации. Пожалуйста, попробуйте ещё раз.",
"from_your_organization": "из вашей организации",
"from_your_organization": "{memberName} из вашей организации",
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
"invite_deleted_successfully": "Приглашение успешно удалено",
"invite_expires_on": "Приглашение истекает {date}",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Фоллоу-ап обновлён и будет сохранён после сохранения опроса.",
"follow_ups_new": "Новый фоллоу-ап",
"follow_ups_upgrade_button_text": "Обновите тариф для активации фоллоу-апов",
"form_styling": "Оформление формы",
"formbricks_sdk_is_not_connected": "Formbricks SDK не подключён",
"four_points": "4 балла",
"heading": "Заголовок",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
"survey_display_settings": "Настройки отображения опроса",
"survey_placement": "Размещение опроса",
"survey_styling": "Оформление формы",
"survey_trigger": "Триггер опроса",
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
"target_block_not_found": "Целевой блок не найден",
+2 -43
View File
@@ -133,7 +133,6 @@
"allow": "Tillåt",
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
"analysis": "Analys",
"and": "Och",
"and_response_limit_of": "och svarsgräns på",
"anonymous": "Anonym",
@@ -150,8 +149,6 @@
"bottom_right": "Nedre höger",
"cancel": "Avbryt",
"centered_modal": "Centrerad modal",
"chart": "Diagram",
"charts": "Diagram",
"choices": "Val",
"choose_environment": "Välj miljö",
"choose_organization": "Välj organisation",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
"create": "Skapa",
"create_new_organization": "Skapa ny organisation",
"create_segment": "Skapa segment",
"create_survey": "Skapa enkät",
@@ -191,12 +187,11 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"dashboard": "Instrumentpanel",
"dashboards": "Instrumentpaneler",
"date": "Datum",
"days": "dagar",
"default": "Standard",
"delete": "Ta bort",
"delete_what": "Ta bort {deleteWhat}",
"description": "Beskrivning",
"dev_env": "Utvecklingsmiljö",
"development": "Utveckling",
@@ -314,7 +309,6 @@
"on": "På",
"only_one_file_allowed": "Endast en fil är tillåten",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
"open_options": "Öppna alternativ",
"option_id": "Alternativ-ID",
"option_ids": "Alternativ-ID:n",
"optional": "Valfritt",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
},
"analysis": {
"charts": {
"action_coming_soon": "Kommer snart",
"chart_deleted_successfully": "Diagrammet har tagits bort",
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
"chart_duplicated_successfully": "Diagrammet har duplicerats",
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
"chart_type_area": "Ytdiagram",
"chart_type_bar": "Stapeldiagram",
"chart_type_big_number": "Stort tal",
"chart_type_line": "Linjediagram",
"chart_type_pie": "Cirkeldiagram",
"create_chart": "Skapa diagram",
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
"no_charts_found": "Inga diagram hittades.",
"open_options": "Öppna diagramalternativ"
},
"dashboards": {
"create_dashboard": "Skapa instrumentpanel",
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
"create_failed": "Det gick inte att skapa instrumentpanelen",
"create_success": "Instrumentpanelen har skapats!",
"dashboard_name": "Instrumentpanelens namn",
"dashboard_name_placeholder": "Min instrumentpanel",
"delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
"delete_failed": "Det gick inte att ta bort instrumentpanelen",
"delete_success": "Instrumentpanelen har tagits bort",
"description_optional": "Beskrivning (valfritt)",
"description_placeholder": "Beskrivning av instrumentpanelen",
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
"duplicate_success": "Instrumentpanelen har duplicerats!",
"no_dashboards_found": "Inga instrumentpaneler hittades.",
"please_enter_name": "Ange ett namn på instrumentpanelen"
}
},
"connect": {
"congrats": "Grattis!",
"connection_successful_message": "Bra gjort! Vi är anslutna.",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "Uppföljning uppdaterad och sparas när du sparar enkäten.",
"follow_ups_new": "Ny uppföljning",
"follow_ups_upgrade_button_text": "Uppgradera för att aktivera uppföljningar",
"form_styling": "Formulärstil",
"formbricks_sdk_is_not_connected": "Formbricks SDK är inte anslutet",
"four_points": "4 poäng",
"heading": "Rubrik",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
"survey_display_settings": "Visningsinställningar för enkät",
"survey_placement": "Enkätplacering",
"survey_styling": "Formulärstil",
"survey_trigger": "Enkätutlösare",
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
"target_block_not_found": "Målblock hittades inte",
+3 -44
View File
@@ -133,7 +133,6 @@
"allow": "允许",
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
"analysis": "分析",
"and": "和",
"and_response_limit_of": "和 响应限制",
"anonymous": "匿名",
@@ -150,8 +149,6 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "居中 模态",
"chart": "图表",
"charts": "图表",
"choices": "选项",
"choose_environment": "选择 环境",
"choose_organization": "选择 组织",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
"count_contacts": "{value, plural, other {{value} 联系人} }",
"count_responses": "{value, plural, other {{value} 回复} }",
"create": "创建",
"create_new_organization": "创建 新的 组织",
"create_segment": "创建 细分",
"create_survey": "创建 调查",
@@ -191,12 +187,11 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"dashboard": "Dashboard",
"dashboards": "仪表盘",
"date": "日期",
"days": "天",
"default": "默认",
"delete": "删除",
"delete_what": "删除{deleteWhat}",
"description": "描述",
"dev_env": "开发 环境",
"development": "开发环境",
@@ -314,7 +309,6 @@
"on": "开启",
"only_one_file_allowed": "只 允许 一个 文件",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
"open_options": "打开选项",
"option_id": "选项 ID",
"option_ids": "选项 ID",
"optional": "可选",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
},
"analysis": {
"charts": {
"action_coming_soon": "即将推出",
"chart_deleted_successfully": "图表删除成功",
"chart_deletion_error": "图表删除失败",
"chart_duplicated_successfully": "图表复制成功",
"chart_duplication_error": "图表复制失败",
"chart_type_area": "面积图",
"chart_type_bar": "柱状图",
"chart_type_big_number": "大数字",
"chart_type_line": "折线图",
"chart_type_pie": "饼图",
"create_chart": "创建图表",
"delete_chart_confirmation": "你确定要删除这个图表吗?",
"no_charts_found": "未找到图表。",
"open_options": "打开图表选项"
},
"dashboards": {
"create_dashboard": "创建 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": "做得好 !我们 已经 连接。",
@@ -1123,7 +1082,7 @@
"email_customization_preview_email_heading": "嘿 {userName}",
"email_customization_preview_email_text": "这 是 一封 电子邮件 预览,展示 哪个 徽标 将在 电子邮件 中 渲染。",
"error_deleting_organization_please_try_again": "删除 组织时 出错 。 请重试 。",
"from_your_organization": "来自你的组织",
"from_your_organization": "来自您组织的{memberName}",
"invitation_sent_once_more": "再次发送邀请。",
"invite_deleted_successfully": "邀请 删除 成功",
"invite_expires_on": "邀请将于 {date} 过期",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_new": "新的跟进",
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
"form_styling": "表单 样式",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未连接",
"four_points": "4 分",
"heading": "标题",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
"survey_display_settings": "调查显示设置",
"survey_placement": "调查 放置",
"survey_styling": "表单 样式",
"survey_trigger": "调查 触发",
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
"target_block_not_found": "未找到目标区块",
+2 -43
View File
@@ -133,7 +133,6 @@
"allow": "允許",
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
"analysis": "分析",
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
@@ -150,8 +149,6 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "置中彈窗",
"chart": "圖表",
"charts": "圖表",
"choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
"count_responses": "{value, plural, other {{value} 回應} }",
"create": "建立",
"create_new_organization": "建立新組織",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
@@ -191,12 +187,11 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"dashboard": "儀表板",
"dashboards": "儀表板",
"date": "日期",
"days": "天",
"default": "預設",
"delete": "刪除",
"delete_what": "刪除{deleteWhat}",
"description": "描述",
"dev_env": "開發環境",
"development": "開發",
@@ -314,7 +309,6 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"open_options": "開啟選項",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"optional": "選填",
@@ -620,41 +614,6 @@
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
},
"analysis": {
"charts": {
"action_coming_soon": "即將推出",
"chart_deleted_successfully": "圖表已成功刪除",
"chart_deletion_error": "刪除圖表失敗",
"chart_duplicated_successfully": "圖表已成功複製",
"chart_duplication_error": "圖表複製失敗",
"chart_type_area": "區域圖",
"chart_type_bar": "長條圖",
"chart_type_big_number": "大數字",
"chart_type_line": "折線圖",
"chart_type_pie": "圓餅圖",
"create_chart": "建立圖表",
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
"no_charts_found": "找不到圖表。",
"open_options": "開啟圖表選項"
},
"dashboards": {
"create_dashboard": "建立儀表板",
"create_dashboard_description": "請輸入新儀表板的名稱。",
"create_failed": "建立儀表板失敗",
"create_success": "儀表板建立成功!",
"dashboard_name": "儀表板名稱",
"dashboard_name_placeholder": "我的儀表板",
"delete_confirmation": "你確定要刪除此儀表板嗎?此操作無法復原。",
"delete_failed": "刪除儀表板失敗",
"delete_success": "儀表板刪除成功",
"description_optional": "描述(選填)",
"description_placeholder": "儀表板描述",
"duplicate_failed": "複製儀表板失敗",
"duplicate_success": "儀表板複製成功!",
"no_dashboards_found": "找不到儀表板。",
"please_enter_name": "請輸入儀表板名稱"
}
},
"connect": {
"congrats": "恭喜!",
"connection_successful_message": "做得好!我們已連線。",
@@ -1486,7 +1445,6 @@
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_new": "新增後續追蹤",
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
"form_styling": "表單樣式設定",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未連線",
"four_points": "4 分",
"heading": "標題",
@@ -1705,6 +1663,7 @@
"survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定",
"survey_placement": "問卷位置",
"survey_styling": "表單樣式設定",
"survey_trigger": "問卷觸發器",
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
"target_block_not_found": "找不到目標區塊",
@@ -12,9 +12,7 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs
| Prisma.DashboardFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
@@ -1,43 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockLoad = vi.fn();
const mockTablePivot = vi.fn();
vi.mock("@cubejs-client/core", () => ({
default: vi.fn(() => ({
load: mockLoad,
})),
}));
describe("executeQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
const resultSet = { tablePivot: mockTablePivot };
mockLoad.mockResolvedValue(resultSet);
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
});
test("loads query and returns tablePivot result", async () => {
const { executeQuery } = await import("./cube-client");
const query = { measures: ["FeedbackRecords.count"] };
const result = await executeQuery(query);
expect(mockLoad).toHaveBeenCalledWith(query);
expect(mockTablePivot).toHaveBeenCalled();
expect(result).toEqual([{ id: "1", count: 42 }]);
});
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
const fullUrl = "https://cube.example.com/cubejs-api/v1";
vi.stubEnv("CUBEJS_API_URL", fullUrl);
const { executeQuery } = await import("./cube-client");
await executeQuery({ measures: ["FeedbackRecords.count"] });
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
vi.unstubAllEnvs();
});
});
@@ -1,26 +0,0 @@
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
const getApiUrl = (): string => {
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
if (baseUrl.includes("/cubejs-api/v1")) {
return baseUrl;
}
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
};
let cubeClient: CubeApi | null = null;
function getCubeClient(): CubeApi {
if (!cubeClient) {
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
const token = process.env.CUBEJS_API_TOKEN ?? "";
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
}
return cubeClient;
}
export async function executeQuery(query: Query) {
const client = getCubeClient();
const resultSet = await client.load(query);
return resultSet.tablePivot();
}
@@ -1,458 +0,0 @@
"use server";
import OpenAI from "openai";
import { z } from "zod";
import { ZChartQuery } from "@formbricks/types/analysis";
import { ZId } from "@formbricks/types/common";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { validateQueryMembers } from "@/modules/ee/analysis/charts/lib/chart-utils";
import {
createChart,
deleteChart,
duplicateChart,
getChart,
getCharts,
updateChart,
} from "@/modules/ee/analysis/charts/lib/charts";
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
/** Client-facing chart input (projectId and createdBy are resolved server-side) */
const ZChartCreateInputClient = ZChartCreateInput.omit({ projectId: true, createdBy: true });
const ZCreateChartAction = z.object({
environmentId: ZId,
chartInput: ZChartCreateInputClient,
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await createChart({
...parsedInput.chartInput,
projectId,
createdBy: ctx.user.id,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = chart.id;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
chartUpdateInput: ZChartUpdateInput,
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const { chart, updatedChart } = await updateChart(
parsedInput.chartId,
projectId,
parsedInput.chartUpdateInput
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const duplicatedChart = await duplicateChart(parsedInput.chartId, projectId, ctx.user.id);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
const ZDeleteChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
withAuditLogging(
"deleted",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await deleteChart(parsedInput.chartId, projectId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
return { success: true };
}
)
);
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getChart(parsedInput.chartId, projectId);
}
);
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getCharts(projectId);
}
);
// ── Charts UI specific actions (query execution & AI generation) ─────────────
const ZExecuteQueryAction = z.object({
environmentId: ZId,
query: ZChartQuery,
});
export const executeQueryAction = authenticatedActionClient
.schema(ZExecuteQueryAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZExecuteQueryAction>;
}) => {
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
validateQueryMembers(parsedInput.query);
try {
return await executeQuery(parsedInput.query as Record<string, unknown>);
} catch (error) {
throw error instanceof Error ? error : new Error("Failed to execute query");
}
}
);
const CUBE_NAME = "FeedbackRecords";
const ZGenerateAIQueryResponse = z.object({
measures: z.array(z.string()),
dimensions: z.array(z.string()).nullable(),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
dateRange: z.string().nullable(),
})
)
.nullable(),
chartType: ZChartType,
filters: z
.array(
z.object({
member: z.string(),
operator: z.enum([
"equals",
"notEquals",
"contains",
"notContains",
"set",
"notSet",
"gt",
"gte",
"lt",
"lte",
]),
values: z.array(z.string()).nullable(),
})
)
.nullable(),
});
const AI_QUERY_JSON_SCHEMA = {
type: "object" as const,
additionalProperties: false,
properties: {
measures: {
type: "array" as const,
items: { type: "string" as const },
description: "List of measures to query",
},
dimensions: {
anyOf: [{ type: "array" as const, items: { type: "string" as const } }, { type: "null" as const }],
description: "List of dimensions to query",
},
timeDimensions: {
anyOf: [
{
type: "array" as const,
items: {
type: "object" as const,
additionalProperties: false,
properties: {
dimension: { type: "string" as const },
granularity: {
anyOf: [
{
type: "string" as const,
enum: ["hour", "day", "week", "month", "quarter", "year"],
},
{ type: "null" as const },
],
},
dateRange: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
required: ["dimension", "granularity", "dateRange"],
},
},
{ type: "null" as const },
],
description: "Time dimensions with granularity and date range",
},
chartType: {
type: "string" as const,
enum: [...ZChartType.options],
description: "Suggested chart type for visualization",
},
filters: {
anyOf: [
{
type: "array" as const,
items: {
type: "object" as const,
additionalProperties: false,
properties: {
member: { type: "string" as const },
operator: {
type: "string" as const,
enum: [
"equals",
"notEquals",
"contains",
"notContains",
"set",
"notSet",
"gt",
"gte",
"lt",
"lte",
],
},
values: {
anyOf: [
{ type: "array" as const, items: { type: "string" as const } },
{ type: "null" as const },
],
},
},
required: ["member", "operator", "values"],
},
},
{ type: "null" as const },
],
description: "Filters to apply to the query",
},
},
required: ["measures", "dimensions", "timeDimensions", "chartType", "filters"],
};
const ZGenerateAIChartAction = z.object({
environmentId: ZId,
prompt: z.string().min(1).max(2000),
});
export const generateAIChartAction = authenticatedActionClient
.schema(ZGenerateAIChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGenerateAIChartAction>;
}) => {
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not configured");
}
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const schemaContext = generateSchemaContext();
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: schemaContext },
{ role: "user", content: `User request: "${parsedInput.prompt}"` },
],
tools: [
{
type: "function",
function: {
name: "generate_cube_query",
description: "Generate a Cube.js query based on the user request",
parameters: AI_QUERY_JSON_SCHEMA,
strict: true,
},
},
],
tool_choice: { type: "function", function: { name: "generate_cube_query" } },
});
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
if (toolCall?.function?.name !== "generate_cube_query") {
throw new Error("Failed to generate structured output from OpenAI");
}
const rawQuery = JSON.parse(toolCall.function.arguments);
const validated = ZGenerateAIQueryResponse.parse(rawQuery);
if (!validated.measures || validated.measures.length === 0) {
validated.measures = [`${CUBE_NAME}.count`];
}
const { chartType, ...cubeQuery } = validated;
const cleanQuery: Record<string, unknown> = {
measures: cubeQuery.measures,
};
if (Array.isArray(cubeQuery.dimensions) && cubeQuery.dimensions.length > 0) {
cleanQuery.dimensions = cubeQuery.dimensions;
}
if (Array.isArray(cubeQuery.filters) && cubeQuery.filters.length > 0) {
cleanQuery.filters = cubeQuery.filters.map(
(f: { member: string; operator: string; values?: string[] | null }) => {
const cleaned: Record<string, unknown> = { member: f.member, operator: f.operator };
if (f.values !== null && f.values !== undefined) cleaned.values = f.values;
return cleaned;
}
);
}
if (Array.isArray(cubeQuery.timeDimensions) && cubeQuery.timeDimensions.length > 0) {
cleanQuery.timeDimensions = cubeQuery.timeDimensions.map(
(td: { dimension: string; granularity?: string | null; dateRange?: string | null }) => {
const cleaned: Record<string, unknown> = { dimension: td.dimension };
if (td.granularity !== null && td.granularity !== undefined) cleaned.granularity = td.granularity;
if (td.dateRange !== null && td.dateRange !== undefined) cleaned.dateRange = td.dateRange;
return cleaned;
}
);
}
const data = await executeQuery(cleanQuery);
return {
query: cleanQuery,
chartType,
data: Array.isArray(data) ? data : [],
};
}
);
@@ -1,118 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface AddToDashboardDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
dashboards: Array<{ id: string; name: string }>;
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onConfirm: () => void;
isSaving: boolean;
}
export function AddToDashboardDialog({
isOpen,
onOpenChange,
chartName,
onChartNameChange,
dashboards,
selectedDashboardId,
onDashboardSelect,
onConfirm,
isSaving,
}: Readonly<AddToDashboardDialogProps>) {
const { t } = useTranslation();
return (
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && onOpenChange(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.add_chart_to_dashboard")}</DialogTitle>
<DialogDescription>
{t("environments.analysis.charts.add_chart_to_dashboard_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-4">
<div>
<Label htmlFor="chart-name">{t("environments.analysis.charts.chart_name")}</Label>
<Input
id="chart-name"
className="mt-2"
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
/>
</div>
<div>
<Label htmlFor="dashboard-select">{t("environments.analysis.charts.dashboard")}</Label>
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
<SelectTrigger
id="dashboard-select"
className="mt-2 w-full bg-white"
disabled={dashboards.length === 0}>
<SelectValue
placeholder={
dashboards.length === 0
? t("environments.analysis.charts.no_dashboards_available")
: t("environments.analysis.charts.dashboard_select_placeholder")
}
/>
</SelectTrigger>
<SelectContent position="popper" className="max-h-[200px]">
{dashboards.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-gray-500">
{t("environments.analysis.charts.no_dashboards_available")}
</div>
) : (
dashboards.map((dashboard) => (
<SelectItem key={dashboard.id} value={dashboard.id}>
{dashboard.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{dashboards.length === 0 && (
<p className="mt-1 text-xs text-gray-500">
{t("environments.analysis.charts.no_dashboards_create_first")}
</p>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
{t("environments.analysis.charts.add_to_dashboard")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,583 +0,0 @@
"use client";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { executeQueryAction } from "@/modules/ee/analysis/charts/actions";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartPreview } from "@/modules/ee/analysis/charts/components/advanced-chart-preview";
import { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
import { ChartTypeSelector } from "@/modules/ee/analysis/charts/components/chart-type-selector";
import { DimensionsPanel } from "@/modules/ee/analysis/charts/components/dimensions-panel";
import { FiltersPanel } from "@/modules/ee/analysis/charts/components/filters-panel";
import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures-panel";
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel";
import { useSaveDashboardDialogs } from "@/modules/ee/analysis/charts/hooks/use-save-dashboard-dialogs";
import {
ChartBuilderState,
type CustomMeasure,
type FilterRow,
type TimeDimensionConfig,
buildCubeQuery,
parseQueryToState,
} from "@/modules/ee/analysis/lib/query-builder";
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
import type { AnalyticsResponse, TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
const DEBOUNCE_MS = 300;
interface AdvancedChartBuilderProps {
environmentId: string;
initialChartType?: TChartType;
initialQuery?: TChartQuery;
hidePreview?: boolean;
/** Must be stable (memoized) to avoid effect re-runs on every parent render */
onChartGenerated?: (data: AnalyticsResponse) => void;
onSave?: (chartId: string) => void;
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
}
type Action =
| { type: "SET_CHART_TYPE"; payload: TChartType }
| { type: "SET_MEASURES"; payload: string[] }
| { type: "SET_CUSTOM_MEASURES"; payload: CustomMeasure[] }
| { type: "SET_DIMENSIONS"; payload: string[] }
| { type: "SET_FILTERS"; payload: FilterRow[] }
| { type: "SET_FILTER_LOGIC"; payload: "and" | "or" }
| { type: "SET_TIME_DIMENSION"; payload: TimeDimensionConfig | null }
| { type: "QUERY_START" }
| { type: "QUERY_SUCCESS"; payload: { data: TChartDataRow[]; query: TChartQuery } }
| { type: "QUERY_ERROR"; payload: string };
interface QueryState {
chartData: TChartDataRow[] | null;
query: TChartQuery | null;
isLoading: boolean;
error: string | null;
}
const initialQueryState: QueryState = {
chartData: null,
query: null,
isLoading: false,
error: null,
};
const initialState: ChartBuilderState = {
chartType: "",
selectedMeasures: [],
customMeasures: [],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBuilderState => {
switch (action.type) {
case "SET_CHART_TYPE":
return { ...state, chartType: action.payload };
case "SET_MEASURES":
return { ...state, selectedMeasures: action.payload };
case "SET_CUSTOM_MEASURES":
return { ...state, customMeasures: action.payload };
case "SET_DIMENSIONS":
return { ...state, selectedDimensions: action.payload };
case "SET_FILTERS":
return { ...state, filters: action.payload };
case "SET_FILTER_LOGIC":
return { ...state, filterLogic: action.payload };
case "SET_TIME_DIMENSION":
return { ...state, timeDimension: action.payload };
default:
return state;
}
};
const queryReducer = (state: QueryState, action: Action): QueryState => {
switch (action.type) {
case "QUERY_START":
return { ...state, isLoading: true, error: null };
case "QUERY_SUCCESS":
return {
chartData: action.payload.data,
query: action.payload.query,
isLoading: false,
error: null,
};
case "QUERY_ERROR":
return { ...state, isLoading: false, error: action.payload };
default:
return state;
}
};
export function AdvancedChartBuilder({
environmentId,
initialChartType,
initialQuery,
hidePreview = false,
onChartGenerated,
onSave,
onAddToDashboard,
}: Readonly<AdvancedChartBuilderProps>) {
const { t } = useTranslation();
const onChartGeneratedRef = useRef(onChartGenerated);
onChartGeneratedRef.current = onChartGenerated;
const prevInitialChartTypeRef = useRef(initialChartType);
const getInitialState = useCallback((): ChartBuilderState => {
if (initialQuery) {
const parsedState = parseQueryToState(initialQuery, initialChartType);
return {
...initialState,
...parsedState,
chartType: parsedState.chartType || initialChartType || "",
};
}
return { ...initialState, chartType: initialChartType || "" };
}, [initialQuery, initialChartType]);
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
const [queryState, dispatchQuery] = useReducer(queryReducer, {
...initialQueryState,
query: initialQuery || null,
});
const [isInitialized, setIsInitialized] = useState(false);
const [showQuery, setShowQuery] = useState(false);
const [showData, setShowData] = useState(false);
const [dimensionsOpen, setDimensionsOpen] = useState(false);
const [timeDimensionOpen, setTimeDimensionOpen] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [customAggregationsOpen, setCustomAggregationsOpen] = useState(false);
const lastStateRef = useRef<string>("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const requestIdRef = useRef(0);
const saveDashboard = useSaveDashboardDialogs({
environmentId,
getChartInput: () => {
if (!queryState.chartData || !queryState.query || !state.chartType) return null;
return { query: queryState.query, chartType: state.chartType };
},
onSave,
onAddToDashboard,
});
// Sync initialChartType only when the prop changes (not when state diverges)
useEffect(() => {
if (initialChartType && initialChartType !== prevInitialChartTypeRef.current) {
prevInitialChartTypeRef.current = initialChartType;
dispatch({ type: "SET_CHART_TYPE", payload: initialChartType });
if (!initialQuery && !isInitialized) {
setIsInitialized(true);
}
}
}, [initialChartType, initialQuery, isInitialized]);
// Sync section open states when loading from initialQuery
useEffect(() => {
if (!initialQuery) return;
const parsed = parseQueryToState(initialQuery, initialChartType);
setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0);
setTimeDimensionOpen(parsed.timeDimension != null);
setFiltersOpen((parsed.filters?.length ?? 0) > 0);
// Only set customAggregationsOpen to true when parsed has custom measures.
// Never set to false here: parseQueryToState always returns customMeasures: [] because
// Cube.js query format doesn't store custom measure definitions, so we'd incorrectly
// turn off the toggle after running a query that uses custom measures.
if ((parsed.customMeasures?.length ?? 0) > 0) {
setCustomAggregationsOpen(true);
}
}, [initialQuery, initialChartType]);
// Keep time dimension toggle in sync when panel's disable clears the config
useEffect(() => {
if (state.timeDimension == null) setTimeDimensionOpen(false);
}, [state.timeDimension]);
// Turn off filter toggle when the last filter is deleted
useEffect(() => {
if (state.filters.length === 0 && filtersOpen) setFiltersOpen(false);
}, [state.filters.length, filtersOpen]);
// Turn off custom aggregations toggle when the last custom measure is removed
useEffect(() => {
if (state.customMeasures.length === 0 && customAggregationsOpen) setCustomAggregationsOpen(false);
}, [state.customMeasures.length, customAggregationsOpen]);
// Initialize: execute initialQuery once (deps intentionally minimal to avoid redundant runs).
// Skip when hidePreview is true because the parent component handles data loading.
useEffect(() => {
if (!initialQuery || isInitialized) return;
setIsInitialized(true);
if (hidePreview) {
// Sync lastStateRef so the reactive effect does not re-run the same query on mount.
lastStateRef.current = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
return;
}
const chartType = state.chartType;
const requestId = ++requestIdRef.current;
executeQueryAction({ environmentId, query: initialQuery })
.then((result) => {
if (requestId !== requestIdRef.current) return;
if (result?.serverError) {
dispatchQuery({ type: "QUERY_ERROR", payload: getFormattedErrorMessage(result) });
return;
}
const data = Array.isArray(result?.data) ? result.data : [];
if (data.length > 0) {
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: initialQuery } });
lastStateRef.current = JSON.stringify({
chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
if (onChartGeneratedRef.current && chartType) {
onChartGeneratedRef.current({ query: initialQuery, chartType, data });
}
}
})
.catch((err: unknown) => {
if (requestId !== requestIdRef.current) return;
const message =
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
dispatchQuery({ type: "QUERY_ERROR", payload: message });
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- init runs once; state/onChartGenerated via ref
}, [initialQuery, environmentId, isInitialized]);
// Reactive query with debounce and cancellation
useEffect(() => {
if (!isInitialized || !state.chartType) return;
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) return;
const stateHash = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
if (stateHash === lastStateRef.current) return;
lastStateRef.current = stateHash;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
const chartType = state.chartType;
const updatedQuery = buildCubeQuery(state);
const requestId = ++requestIdRef.current;
dispatchQuery({ type: "QUERY_START" });
executeQueryAction({ environmentId, query: updatedQuery })
.then((result) => {
if (requestId !== requestIdRef.current) return;
if (result?.serverError) {
dispatchQuery({ type: "QUERY_ERROR", payload: getFormattedErrorMessage(result) });
return;
}
const data = Array.isArray(result?.data) ? result.data : [];
if (data.length > 0) {
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: updatedQuery } });
if (onChartGeneratedRef.current && chartType) {
onChartGeneratedRef.current({ query: updatedQuery, chartType, data });
}
} else {
dispatchQuery({
type: "QUERY_ERROR",
payload: t("environments.analysis.charts.no_data_returned"),
});
}
})
.catch((err: unknown) => {
if (requestId !== requestIdRef.current) return;
const message =
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
dispatchQuery({ type: "QUERY_ERROR", payload: message });
});
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced; onChartGenerated via ref
}, [
state.chartType,
state.selectedMeasures,
state.selectedDimensions,
state.filters,
state.filterLogic,
state.customMeasures,
state.timeDimension,
isInitialized,
environmentId,
]);
const processQueryResult = useCallback(
(
result: Awaited<ReturnType<typeof executeQueryAction>>,
cubeQuery: TChartQuery,
chartType: TChartType,
requestId: number
) => {
if (requestId !== requestIdRef.current) return;
if (result?.serverError) {
const errorMsg = getFormattedErrorMessage(result);
dispatchQuery({ type: "QUERY_ERROR", payload: errorMsg });
toast.error(errorMsg);
return;
}
const data = Array.isArray(result?.data) ? result.data : [];
if (data.length === 0) {
dispatchQuery({
type: "QUERY_ERROR",
payload: t("environments.analysis.charts.no_data_returned"),
});
toast.error(t("environments.analysis.charts.no_data_returned"));
return;
}
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: cubeQuery } });
toast.success(t("environments.analysis.charts.query_executed_successfully"));
if (onChartGeneratedRef.current && chartType) {
onChartGeneratedRef.current({ query: cubeQuery, chartType, data });
}
},
[t]
);
const handleRunQuery = async () => {
if (!state.chartType) {
toast.error(t("environments.analysis.charts.please_select_chart_type"));
return;
}
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
toast.error(t("environments.analysis.charts.please_select_at_least_one_measure"));
return;
}
dispatchQuery({ type: "QUERY_START" });
const cubeQuery = buildCubeQuery(state);
const chartType = state.chartType;
const requestId = ++requestIdRef.current;
try {
const result = await executeQueryAction({ environmentId, query: cubeQuery });
processQueryResult(result, cubeQuery, chartType, requestId);
} catch (err: unknown) {
if (requestId !== requestIdRef.current) return;
const message =
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
dispatchQuery({ type: "QUERY_ERROR", payload: message });
toast.error(message);
}
};
const { chartData, query, isLoading, error } = queryState;
const showSaveDashboard = !onSave || !onAddToDashboard;
return (
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
<div className="mx-1 space-y-2">
{!hidePreview && (
<>
<ChartBuilderGuide />
<ChartTypeSelector
selectedChartType={state.chartType}
onChartTypeSelect={(chartType) => dispatch({ type: "SET_CHART_TYPE", payload: chartType })}
/>
</>
)}
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
<MeasuresPanel
selectedMeasures={state.selectedMeasures}
customMeasures={state.customMeasures}
customAggregationsOpen={customAggregationsOpen}
onCustomAggregationsOpenChange={(open) => {
setCustomAggregationsOpen(open);
if (!open) {
dispatch({ type: "SET_CUSTOM_MEASURES", payload: [] });
} else if (state.customMeasures.length === 0) {
const dimensionOptions = FEEDBACK_FIELDS.dimensions
.filter((d) => d.type === "number")
.map((d) => d.id);
dispatch({
type: "SET_CUSTOM_MEASURES",
payload: [
{
id: `measure-${crypto.randomUUID()}`,
field: dimensionOptions[0] ?? "",
aggregation: "avg",
},
],
});
}
}}
onMeasuresChange={(measures) => dispatch({ type: "SET_MEASURES", payload: measures })}
onCustomMeasuresChange={(measures) =>
dispatch({ type: "SET_CUSTOM_MEASURES", payload: measures })
}
/>
</div>
<AdvancedOptionToggle
isChecked={dimensionsOpen}
onToggle={(checked) => {
setDimensionsOpen(checked);
if (!checked) dispatch({ type: "SET_DIMENSIONS", payload: [] });
}}
htmlId="chart-dimensions-toggle"
title={t("environments.analysis.charts.dimensions")}
description={t("environments.analysis.charts.dimensions_toggle_description")}
customContainerClass="mt-2 px-0"
childrenContainerClass="flex-col gap-3 p-4"
childBorder>
<DimensionsPanel
hideTitle
selectedDimensions={state.selectedDimensions}
onDimensionsChange={(dimensions) => dispatch({ type: "SET_DIMENSIONS", payload: dimensions })}
/>
</AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={timeDimensionOpen}
onToggle={(checked) => {
setTimeDimensionOpen(checked);
if (!checked) dispatch({ type: "SET_TIME_DIMENSION", payload: null });
else if (!state.timeDimension) {
dispatch({
type: "SET_TIME_DIMENSION",
payload: {
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: "last 30 days",
},
});
}
}}
htmlId="chart-time-dimension-toggle"
title={t("environments.analysis.charts.time_dimension")}
description={t("environments.analysis.charts.time_dimension_toggle_description")}
customContainerClass="mt-2 px-0"
childrenContainerClass="flex-col gap-3 p-4"
childBorder>
<TimeDimensionPanel
hideTitle
timeDimension={state.timeDimension}
onTimeDimensionChange={(config) => dispatch({ type: "SET_TIME_DIMENSION", payload: config })}
/>
</AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={filtersOpen}
onToggle={(checked) => {
setFiltersOpen(checked);
if (!checked) {
dispatch({ type: "SET_FILTERS", payload: [] });
} else if (state.filters.length === 0) {
const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0];
dispatch({
type: "SET_FILTERS",
payload: [
{
field: firstField?.id ?? "",
operator: "equals" as const,
values: null,
},
],
});
}
}}
htmlId="chart-filters-toggle"
title={t("environments.analysis.charts.filters")}
description={t("environments.analysis.charts.filters_toggle_description")}
customContainerClass="mt-2 px-0"
childrenContainerClass="flex-col gap-3 p-4"
childBorder>
<FiltersPanel
hideTitle
filters={state.filters}
filterLogic={state.filterLogic}
onFiltersChange={(filters) => dispatch({ type: "SET_FILTERS", payload: filters })}
onFilterLogicChange={(logic) => dispatch({ type: "SET_FILTER_LOGIC", payload: logic })}
/>
</AdvancedOptionToggle>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={handleRunQuery} disabled={isLoading || !state.chartType}>
{isLoading ? <LoadingSpinner /> : t("environments.analysis.charts.run_query")}
</Button>
{chartData && showSaveDashboard && (
<>
<Button variant="outline" onClick={() => saveDashboard.setIsSaveDialogOpen(true)}>
{t("environments.analysis.charts.save_chart")}
</Button>
<Button variant="outline" onClick={() => saveDashboard.setIsAddToDashboardDialogOpen(true)}>
{t("environments.analysis.charts.add_to_dashboard")}
</Button>
</>
)}
</div>
</div>
{!hidePreview && (
<AdvancedChartPreview
error={error}
isLoading={isLoading}
chartData={chartData}
chartType={state.chartType}
query={query}
showQuery={showQuery}
onShowQueryChange={setShowQuery}
showData={showData}
onShowDataChange={setShowData}
/>
)}
{!onSave && (
<SaveChartDialog
open={saveDashboard.isSaveDialogOpen}
onOpenChange={saveDashboard.setIsSaveDialogOpen}
chartName={saveDashboard.chartName}
onChartNameChange={saveDashboard.setChartName}
onSave={saveDashboard.handleSaveChart}
isSaving={saveDashboard.isSaving}
/>
)}
{!onAddToDashboard && (
<AddToDashboardDialog
isOpen={saveDashboard.isAddToDashboardDialogOpen}
onOpenChange={saveDashboard.setIsAddToDashboardDialogOpen}
chartName={saveDashboard.chartName}
onChartNameChange={saveDashboard.setChartName}
dashboards={saveDashboard.dashboards}
selectedDashboardId={saveDashboard.selectedDashboardId}
onDashboardSelect={saveDashboard.setSelectedDashboardId}
onConfirm={saveDashboard.handleAddToDashboard}
isSaving={saveDashboard.isSaving}
/>
)}
</div>
);
}
@@ -1,100 +0,0 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CodeIcon, DatabaseIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TChartQuery } from "@formbricks/types/analysis";
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
import { QueryViewer } from "@/modules/ee/analysis/charts/components/query-viewer";
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface AdvancedChartPreviewProps {
error: string | null;
isLoading: boolean;
chartData: TChartDataRow[] | null;
chartType: TChartType | "";
query: TChartQuery | null;
showQuery: boolean;
onShowQueryChange: (open: boolean) => void;
showData: boolean;
onShowDataChange: (open: boolean) => void;
}
export function AdvancedChartPreview({
error,
isLoading,
chartData,
chartType,
query,
showQuery,
onShowQueryChange,
showData,
onShowDataChange,
}: Readonly<AdvancedChartPreviewProps>) {
const { t } = useTranslation();
const hasData = chartData && chartData.length > 0 && !isLoading && chartType && query;
const isEmpty = !chartData && !isLoading && !error;
return (
<div className="space-y-2">
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.chart_preview")}
</h3>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
)}
{isLoading && (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
)}
{hasData && (
<div className="space-y-2">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<ChartRenderer chartType={chartType} data={chartData} query={query} />
</div>
<QueryViewer
query={query}
isOpen={showQuery}
onOpenChange={onShowQueryChange}
trigger={
<Collapsible.CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<CodeIcon className="mr-2 h-4 w-4" />
{showQuery ? t("common.hide") : t("common.view")}{" "}
{t("environments.analysis.charts.query_label")}
</Button>
</Collapsible.CollapsibleTrigger>
}
/>
<Collapsible.Root open={showData} onOpenChange={onShowDataChange}>
<Collapsible.CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<DatabaseIcon className="mr-2 h-4 w-4" />
{showData ? t("common.hide") : t("common.view")}{" "}
{t("environments.analysis.charts.data_label")}
</Button>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="mt-2">
<DataViewer data={chartData} />
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
)}
{isEmpty && (
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-500">
{t("environments.analysis.charts.advanced_chart_builder_config_prompt")}
</div>
)}
</div>
);
}
@@ -1,82 +0,0 @@
"use client";
import { ActivityIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
interface AIQuerySectionProps {
environmentId: string;
onChartGenerated: (data: AnalyticsResponse) => void;
}
export function AIQuerySection({ environmentId, onChartGenerated }: Readonly<AIQuerySectionProps>) {
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { t } = useTranslation();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!userQuery.trim()) return;
setIsGenerating(true);
try {
const result = await generateAIChartAction({
environmentId,
prompt: userQuery.trim(),
});
if (result?.data) {
onChartGenerated(result.data);
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again");
toast.error(message);
} finally {
setIsGenerating(false);
}
};
return (
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
<ActivityIcon className="text-brand-dark h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-gray-900">
{t("environments.analysis.charts.ai_query_section_title")}
</h2>
<p className="text-sm text-gray-500">
{t("environments.analysis.charts.ai_query_section_description")}
</p>
</div>
</div>
<form className="flex gap-4" onSubmit={handleSubmit}>
<Input
placeholder={t("environments.analysis.charts.ai_query_placeholder")}
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
className="flex-1"
disabled={isGenerating}
/>
<Button
type="submit"
disabled={!userQuery.trim() || isGenerating}
loading={isGenerating}
className="bg-brand-dark hover:bg-brand-dark/90">
{t("common.generate")}
</Button>
</form>
</div>
);
}
@@ -1,112 +0,0 @@
"use client";
import { HelpCircle } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
interface ChartBuilderGuideProps {
/** Optional trigger; when not provided, caller renders their own */
trigger?: React.ReactNode;
}
export function ChartBuilderGuide({ trigger }: Readonly<ChartBuilderGuideProps>) {
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
return (
<>
{trigger ?? (
<Button type="button" variant="ghost" size="sm" onClick={() => setIsOpen(true)}>
<HelpCircle className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.guide_button")}
</Button>
)}
<Dialog open={isOpen} onOpenChange={(isOpen) => !isOpen && setIsOpen(false)}>
<DialogContent width="wide" className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.guide_title")}</DialogTitle>
</DialogHeader>
<DialogBody>
<div className="space-y-6">
<section>
<h3 className="text-md mb-1 font-semibold text-gray-900">
{t("environments.analysis.charts.guide_chart_type")}
</h3>
<p className="text-gray-600">{t("environments.analysis.charts.guide_chart_type_desc")}</p>
</section>
<section>
<h3 className="text-md mb-1 font-semibold text-gray-900">
{t("environments.analysis.charts.guide_measures")}
</h3>
<p className="mb-2 text-sm text-gray-600">
{t("environments.analysis.charts.guide_measures_predefined")}
</p>
<p className="text-sm text-gray-600">
{t("environments.analysis.charts.guide_measures_custom")}
</p>
</section>
<section>
<h3 className="text-md mb-1 font-semibold text-gray-900">
{t("environments.analysis.charts.guide_dimensions")}
</h3>
<p className="text-sm text-gray-600">
{t("environments.analysis.charts.guide_dimensions_desc")}
</p>
</section>
<section>
<h3 className="text-md mb-1 font-semibold text-gray-900">
{t("environments.analysis.charts.guide_time_dimension")}
</h3>
<p className="text-sm text-gray-600">
{t("environments.analysis.charts.guide_time_dimension_desc")}
</p>
</section>
<section>
<h3 className="text-md mb-1 font-semibold text-gray-900">
{t("environments.analysis.charts.guide_filters")}
</h3>
<p className="text-sm text-gray-600">
{t("environments.analysis.charts.guide_filters_desc")}
</p>
</section>
<section>
<h3 className="text-md mb-2 font-semibold text-gray-900">
{t("environments.analysis.charts.guide_quick_ref")}
</h3>
<dl className="space-y-1.5 text-sm text-gray-600">
<div>
<dt className="inline font-medium text-gray-900">Measure: </dt>
<dd className="inline">{t("environments.analysis.charts.guide_term_measure")}</dd>
</div>
<div>
<dt className="inline font-medium text-gray-900">Dimension: </dt>
<dd className="inline">{t("environments.analysis.charts.guide_term_dimension")}</dd>
</div>
<div>
<dt className="inline font-medium text-gray-900">Custom aggregation: </dt>
<dd className="inline">{t("environments.analysis.charts.guide_term_custom")}</dd>
</div>
<div>
<dt className="inline font-medium text-gray-900">Time dimension: </dt>
<dd className="inline">{t("environments.analysis.charts.guide_term_time")}</dd>
</div>
<div>
<dt className="inline font-medium text-gray-900">Filter: </dt>
<dd className="inline">{t("environments.analysis.charts.guide_term_filter")}</dd>
</div>
</dl>
</section>
</div>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,32 +0,0 @@
"use client";
import { PlusIcon, SaveIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { DialogFooter } from "@/modules/ui/components/dialog";
interface ChartDialogFooterProps {
onSaveClick: () => void;
onAddToDashboardClick: () => void;
isSaving: boolean;
}
export function ChartDialogFooter({
onSaveClick,
onAddToDashboardClick,
isSaving,
}: Readonly<ChartDialogFooterProps>) {
const { t } = useTranslation();
return (
<DialogFooter>
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.add_to_dashboard")}
</Button>
<Button onClick={onSaveClick} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.save_chart")}
</Button>
</DialogFooter>
);
}
@@ -1,25 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ChartDialogLoadingViewProps {
open: boolean;
onClose: () => void;
}
export function ChartDialogLoadingView({ open, onClose }: Readonly<ChartDialogLoadingViewProps>) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent width="wide">
<DialogTitle className="sr-only">{t("common.loading")}</DialogTitle>
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,130 +0,0 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChartWithCreator;
onEdit?: (chartId: string) => void;
onInteractionStart?: () => void;
}
export function ChartDropdownMenu({
environmentId,
chart,
onEdit,
onInteractionStart,
}: Readonly<ChartDropdownMenuProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const handleDeleteChart = async () => {
setIsDeleting(true);
try {
const result = await deleteChartAction({ environmentId, chartId: chart.id });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
setDeleteDialogOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeleting(false);
}
};
const handleDuplicateChart = async () => {
onInteractionStart?.();
setIsDuplicating(true);
try {
const result = await duplicateChartAction({ environmentId, chartId: chart.id });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_duplicated_successfully"));
router.refresh();
} else {
toast.error(
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_duplication_error")
);
}
} catch {
toast.error(t("environments.analysis.charts.chart_duplication_error"));
} finally {
setIsDuplicating(false);
}
};
const handleEdit = () => {
onInteractionStart?.();
onEdit?.(chart.id);
};
const handleOpenDeleteDialog = () => {
onInteractionStart?.();
setDeleteDialogOpen(true);
};
return (
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
<DropdownMenu>
<DropdownMenuTrigger className="z-10" asChild>
<Button variant="outline" className="px-2" onClick={(e) => e.stopPropagation()}>
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
<MoreVertical className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
<DropdownMenuGroup>
<DropdownMenuItem icon={<SquarePenIcon className="size-4" />} onClick={handleEdit}>
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<CopyIcon className="size-4" />}
onClick={handleDuplicateChart}
disabled={isDuplicating}>
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<TrashIcon className="size-4" />}
onClick={handleOpenDeleteDialog}
disabled={isDeleting}>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.chart")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDeleteChart}
text={t("environments.analysis.charts.delete_chart_confirmation")}
isDeleting={isDeleting}
/>
</div>
);
}
@@ -1,85 +0,0 @@
"use client";
import { BarChart, DatabaseIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
interface ChartPreviewProps {
chartData: AnalyticsResponse | null;
isLoading?: boolean;
error?: string | null;
}
export function ChartPreview({ chartData, isLoading = false, error }: Readonly<ChartPreviewProps>) {
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
const { t } = useTranslation();
const data = chartData?.data ?? [];
const handleTabChange = (value: string) => {
if (value === "chart" || value === "data") {
setActiveTab(value);
}
};
const renderContent = () => {
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (error || chartData?.error) {
return (
<div className="flex h-48 items-center justify-center text-sm text-red-600">
{error || chartData?.error}
</div>
);
}
if (!chartData) {
return (
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
return (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<div className="mb-4 flex justify-end">
<TabsList>
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
{t("environments.analysis.charts.chart")}
</TabsTrigger>
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
{t("environments.analysis.charts.chart_data_tab")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart" className="mt-0">
<ChartRenderer chartType={chartData.chartType} data={data} query={chartData.query} />
</TabsContent>
<TabsContent value="data" className="mt-0">
<DataViewer data={data} />
</TabsContent>
</Tabs>
);
};
return (
<div className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 font-semibold text-gray-900">{t("environments.analysis.charts.chart_preview")}</h3>
{renderContent()}
</div>
);
}
@@ -1,250 +0,0 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import type { ElementType, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
XAxis,
YAxis,
} from "recharts";
import { TChartQuery } from "@formbricks/types/analysis";
import {
CHART_BRAND_DARK,
CHART_BRAND_LIGHT,
formatCellValue,
preparePieData,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
import type { ChartConfig } from "@/modules/ui/components/chart";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
function formatXAxisTick(value: unknown): string {
if (value == null) return "";
let str: string;
if (typeof value === "string") str = value;
else if (typeof value === "number") str = String(value);
else return "";
const date = parseISO(str);
if (isValid(date)) return format(date, "MMM d, yyyy");
return str;
}
function ChartTooltipRow({ value, dataKey }: Readonly<{ value: unknown; dataKey: string }>) {
return (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border border-current"
style={{
backgroundColor: CHART_BRAND_DARK,
borderColor: CHART_BRAND_DARK,
}}
/>
<div className="flex flex-1 items-center justify-between leading-none">
<span className="text-muted-foreground">{formatCubeColumnHeader(dataKey)}</span>
<span className="text-foreground font-mono font-medium tabular-nums">{formatCellValue(value)}</span>
</div>
</>
);
}
/** Creates a tooltip formatter bound to dataKey for Cartesian charts. Defined at module level to avoid Sonar "component in parent" warnings. */
function createTooltipFormatter(dataKey: string) {
const Formatter = (value: unknown) => <ChartTooltipRow value={value} dataKey={dataKey} />;
Formatter.displayName = "ChartTooltipFormatter";
return Formatter;
}
/** Tooltip content for bar/line/area charts with formatted label and value. Extracted to avoid inline component definitions. */
function CartesianChartTooltip({ dataKey }: Readonly<{ dataKey: string }>) {
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={createTooltipFormatter(dataKey)} />;
}
/** Shared layout for bar, line, and area charts to avoid duplicating grid/axis/tooltip boilerplate. */
function CartesianChart({
data,
xAxisKey,
dataKey,
chartConfig,
chart: Chart,
children,
}: Readonly<{
data: TChartDataRow[];
xAxisKey: string;
dataKey: string;
chartConfig: ChartConfig;
chart: ElementType;
children: ReactNode;
}>) {
return (
<div className="h-64 w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<Chart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisTick}
/>
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<CartesianChartTooltip dataKey={dataKey} />} />
{children}
</Chart>
</ChartContainer>
</div>
);
}
interface ChartRendererProps {
chartType: TChartType;
data: TChartDataRow[];
query: TChartQuery;
}
export function ChartRenderer({ chartType, data, query }: Readonly<ChartRendererProps>) {
const { t } = useTranslation();
if (!data || data.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
const dataKey = query.measures?.[0] ?? Object.keys(data[0])[0] ?? "value";
const xAxisKey =
query.dimensions?.[0] ??
query.timeDimensions?.[0]?.dimension ??
Object.keys(data[0]).find((k) => k !== dataKey) ??
"key";
const chartConfig: ChartConfig = {
[dataKey]: {
label: formatCubeColumnHeader(dataKey),
color: CHART_BRAND_DARK,
},
};
switch (chartType) {
case "bar":
return (
<CartesianChart
chart={BarChart}
data={data}
xAxisKey={xAxisKey}
dataKey={dataKey}
chartConfig={chartConfig}>
<Bar dataKey={dataKey} fill={CHART_BRAND_DARK} radius={4} />
</CartesianChart>
);
case "line":
return (
<CartesianChart
chart={LineChart}
data={data}
xAxisKey={xAxisKey}
dataKey={dataKey}
chartConfig={chartConfig}>
<Line
type="monotone"
dataKey={dataKey}
stroke={CHART_BRAND_DARK}
strokeWidth={3}
dot={{ fill: CHART_BRAND_DARK, r: 4 }}
activeDot={{ r: 6 }}
/>
</CartesianChart>
);
case "area":
return (
<CartesianChart
chart={AreaChart}
data={data}
xAxisKey={xAxisKey}
dataKey={dataKey}
chartConfig={chartConfig}>
<Area
type="monotone"
dataKey={dataKey}
stroke={CHART_BRAND_DARK}
fill={CHART_BRAND_LIGHT}
fillOpacity={0.4}
strokeWidth={2}
/>
</CartesianChart>
);
case "pie": {
const pieResult = preparePieData(data, dataKey);
if (!pieResult) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
{t("environments.analysis.charts.no_valid_data_to_display")}
</div>
);
}
const { processedData, colors } = pieResult;
return (
<div className="h-64 w-full min-w-0">
<ChartContainer config={chartConfig} className="h-full w-full min-w-0">
<PieChart>
<Pie
data={processedData}
dataKey={dataKey}
nameKey={xAxisKey}
cx="50%"
cy="50%"
outerRadius={80}
label={({ name, percent }) => {
if (!percent) return "";
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
}}>
{processedData.map((row, index) => {
const rowKey = row[xAxisKey] ?? `row-${index}`;
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
})}
</Pie>
<ChartTooltip
content={<ChartTooltipContent />}
formatter={(value: number | string, name: string) => [
formatCellValue(value),
formatCubeColumnHeader(name),
]}
/>
</PieChart>
</ChartContainer>
</div>
);
}
case "big_number": {
const total = data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="text-4xl font-bold text-gray-900">{total.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-500">{formatCubeColumnHeader(dataKey)}</div>
</div>
</div>
);
}
default:
return (
<div className="flex h-64 items-center justify-center text-gray-500">
{t("environments.analysis.charts.chart_type_not_supported", { chartType })}
</div>
);
}
}
@@ -1,48 +0,0 @@
"use client";
import { BarChart3Icon } from "lucide-react";
import { convertDateString, timeSinceDate } from "@/lib/time";
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartRowProps {
chart: TChartWithCreator;
environmentId: string;
isReadOnly: boolean;
}
export function ChartRow({ chart, environmentId, isReadOnly }: Readonly<ChartRowProps>) {
const IconComponent = CHART_TYPE_ICONS[chart.type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
return (
<div className="grid h-12 w-full grid-cols-7 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-6 grid grid-cols-6 content-center p-2">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<IconComponent className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{convertDateString(chart.createdAt.toISOString())}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(new Date(chart.updatedAt))}</div>
</div>
</div>
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
{!isReadOnly && <ChartDropdownMenu environmentId={environmentId} chart={chart} />}
</div>
</div>
);
}
@@ -1,47 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
interface ChartTypeSelectorProps {
selectedChartType: TChartType | "";
onChartTypeSelect: (chartType: TChartType) => void;
}
export function ChartTypeSelector({
selectedChartType,
onChartTypeSelect,
}: Readonly<ChartTypeSelectorProps>) {
const { t } = useTranslation();
const chartTypes = getChartTypes(t);
return (
<div className="space-y-2">
<h2 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.chart_builder_choose_chart_type")}
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{chartTypes.map((chart) => {
const isSelected = selectedChartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
}`}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
</button>
);
})}
</div>
</div>
);
}
@@ -1,63 +0,0 @@
import { Delay } from "@suspensive/react";
import { Suspense, use } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
interface ChartsListContentProps {
chartsPromise: Promise<TChartWithCreator[]>;
environmentId: string;
isReadOnly: boolean;
}
const ChartsListContent = ({
chartsPromise,
environmentId,
isReadOnly,
}: Readonly<ChartsListContentProps>) => {
const charts = use(chartsPromise);
return <ChartsList charts={charts} environmentId={environmentId} isReadOnly={isReadOnly} />;
};
interface ChartsListPageProps {
environmentId: string;
}
export async function ChartsListPage({ environmentId }: Readonly<ChartsListPageProps>) {
const t = await getTranslate();
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
const chartsPromise = getChartsWithCreator(project.id);
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
environmentId={environmentId}
cta={isReadOnly ? undefined : <CreateChartButton environmentId={environmentId} />}>
<Suspense
fallback={
<Delay ms={200}>
<ChartsListSkeleton
columnHeaders={[
t("common.title"),
t("common.created_by"),
t("common.created_at"),
t("common.updated_at"),
]}
/>
</Delay>
}>
<ChartsListContent
chartsPromise={chartsPromise}
environmentId={environmentId}
isReadOnly={isReadOnly}
/>
</Suspense>
</AnalysisPageLayout>
);
}
@@ -1,43 +0,0 @@
const SKELETON_ROWS = 5;
function SkeletonRow() {
return (
<div className="grid h-12 w-full animate-pulse grid-cols-7 content-center p-2">
<div className="col-span-3 flex items-center gap-4 pl-6">
<div className="h-5 w-5 rounded bg-gray-200" />
<div className="h-4 w-36 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-16 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-20 rounded bg-gray-200" />
</div>
<div className="col-span-1" />
</div>
);
}
interface ChartsListSkeletonProps {
columnHeaders: string[];
}
export function ChartsListSkeleton({ columnHeaders }: Readonly<ChartsListSkeletonProps>) {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
<div className="col-span-1" />
</div>
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<SkeletonRow key={`skeleton-row-${String(i)}`} />
))}
</div>
);
}
@@ -1,125 +0,0 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { BarChart3Icon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartsListProps {
charts: TChartWithCreator[];
environmentId: string;
isReadOnly: boolean;
}
export function ChartsList({ charts, environmentId, isReadOnly }: Readonly<ChartsListProps>) {
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const { t } = useTranslation();
const filteredCharts = charts;
const getChartIcon = (type: string) => {
const IconComponent = CHART_TYPE_ICONS[type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
return <IconComponent className="h-5 w-5" />;
};
const handleChartClick = (chartId: string) => {
setEditingChartId(chartId);
setIsEditDialogOpen(true);
};
const handleEditSuccess = () => {
setIsEditDialogOpen(false);
setEditingChartId(undefined);
};
const handleRowKeyDown = (e: React.KeyboardEvent, chartId: string) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleChartClick(chartId);
}
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
<div className="col-span-1" />
</div>
{filteredCharts.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">
{t("environments.analysis.charts.no_charts_found")}
</p>
) : (
<>
{filteredCharts.map((chart) => (
// Cannot use native <button>; row contains dropdown trigger (nested interactive invalid)
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-static-element-interactions
<div
key={chart.id}
role="button"
tabIndex={0}
onClick={() => handleChartClick(chart.id)}
onKeyDown={(e) => handleRowKeyDown(e, chart.id)}
aria-label={t("environments.analysis.charts.open_chart", { name: chart.name })}
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
{getChartIcon(chart.type)}
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{format(new Date(chart.createdAt), "do 'of' MMMM, yyyy")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(new Date(chart.updatedAt), {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}>
{!isReadOnly && (
<ChartDropdownMenu
environmentId={environmentId}
chart={chart}
onEdit={(chartId) => {
setEditingChartId(chartId);
setIsEditDialogOpen(true);
}}
/>
)}
</div>
</div>
))}
</>
)}
<CreateChartDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
environmentId={environmentId}
chartId={editingChartId}
initialChart={editingChartId ? filteredCharts.find((c) => c.id === editingChartId) : undefined}
onSuccess={handleEditSuccess}
/>
</div>
);
}
@@ -1,98 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface ConfigureChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentChartType: string;
configuredChartType: string | null;
onChartTypeSelect: (type: string) => void;
onReset: () => void;
}
export function ConfigureChartDialog({
open,
onOpenChange,
currentChartType,
configuredChartType,
onChartTypeSelect,
onReset,
}: Readonly<ConfigureChartDialogProps>) {
const { t } = useTranslation();
const chartTypes = getChartTypes(t);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.configure_title")}</DialogTitle>
<DialogDescription>{t("environments.analysis.charts.configure_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-6">
<div>
<h4 className="text-md mb-3 font-semibold text-gray-900">
{t("environments.analysis.charts.configure_type_label")}
</h4>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{chartTypes.map((chart) => {
const isSelected = (configuredChartType || currentChartType) === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
isSelected
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
: "border-gray-200"
)}
aria-label={chart.label}>
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
</button>
);
})}
</div>
<div className="mt-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onReset} className="text-sm">
{t("environments.analysis.charts.reset_to_ai_suggestion")}
</Button>
{configuredChartType && (
<span className="text-sm text-gray-500">
{t("environments.analysis.charts.original")}:{" "}
{chartTypes.find((c) => c.id === currentChartType)?.label ?? currentChartType}
</span>
)}
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.close")}
</Button>
<Button onClick={() => onOpenChange(false)}>
{t("environments.analysis.charts.apply_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,26 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { Button } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
environmentId: string;
}
export function CreateChartButton({ environmentId }: Readonly<CreateChartButtonProps>) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.create_chart")}
</Button>
<CreateChartDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} environmentId={environmentId} />
</>
);
}
@@ -1,123 +0,0 @@
"use client";
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
import { useCreateChartDialog } from "@/modules/ee/analysis/charts/hooks/use-create-chart-dialog";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
/** Pre-loaded chart metadata from list; skips getChartAction when provided */
initialChart?: TChartWithCreator;
onSuccess?: () => void;
}
export function CreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
}: Readonly<CreateChartDialogProps>) {
const hook = useCreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
});
const {
chartData,
chartName,
setChartName,
selectedChartType,
initialQuery,
setSelectedChartType,
handleChartTypeChange,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
isSaving,
isLoadingChart,
chartLoadError,
shouldShowAdvancedBuilder,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
handleClose,
handleAdvancedBuilderSave,
handleAdvancedBuilderAddToDashboard,
} = hook;
if (chartId && isLoadingChart && !initialChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (chartId && (chartData || initialChart)) {
return (
<EditChartView
open={open}
onClose={handleClose}
environmentId={environmentId}
chartData={chartData ?? null}
initialQuery={initialQuery}
isLoadingChart={isLoadingChart}
chartLoadError={chartLoadError}
chartName={chartName}
onChartNameChange={setChartName}
selectedChartType={selectedChartType}
onChartTypeChange={handleChartTypeChange}
onChartGenerated={handleChartGenerated}
onAdvancedBuilderSave={handleAdvancedBuilderSave}
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAddToDashboard={handleAddToDashboard}
onSave={handleSaveChart}
isSaving={isSaving}
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
/>
);
}
return (
<CreateChartView
open={open}
onClose={handleClose}
environmentId={environmentId}
chartId={chartId}
chartData={chartData}
chartName={chartName}
onChartNameChange={setChartName}
selectedChartType={selectedChartType}
onSelectedChartTypeChange={setSelectedChartType}
shouldShowAdvancedBuilder={shouldShowAdvancedBuilder}
onChartGenerated={handleChartGenerated}
onAdvancedBuilderSave={handleAdvancedBuilderSave}
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAddToDashboard={handleAddToDashboard}
onSave={handleSaveChart}
isSaving={isSaving}
isSaveDialogOpen={isSaveDialogOpen}
onSaveDialogOpenChange={setIsSaveDialogOpen}
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
/>
);
}
@@ -1,176 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
import { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface CreateChartViewProps {
open: boolean;
onClose: () => void;
environmentId: string;
chartId?: string;
chartData: AnalyticsResponse | null;
chartName: string;
onChartNameChange: (name: string) => void;
selectedChartType: TChartType | "";
onSelectedChartTypeChange: (type: TChartType) => void;
shouldShowAdvancedBuilder: boolean;
onChartGenerated: (data: AnalyticsResponse) => void;
onAdvancedBuilderSave: (savedChartId: string) => void;
onAdvancedBuilderAddToDashboard: (savedChartId: string, _dashboardId?: string) => void;
dashboards: Array<{ id: string; name: string }>;
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onAddToDashboard: () => void;
onSave: () => void;
isSaving: boolean;
isSaveDialogOpen: boolean;
onSaveDialogOpenChange: (open: boolean) => void;
isAddToDashboardDialogOpen: boolean;
onAddToDashboardDialogOpenChange: (open: boolean) => void;
}
export function CreateChartView({
open,
onClose,
environmentId,
chartId,
chartData,
chartName,
onChartNameChange,
selectedChartType,
onSelectedChartTypeChange,
shouldShowAdvancedBuilder,
onChartGenerated,
onAdvancedBuilderSave,
onAdvancedBuilderAddToDashboard,
dashboards,
selectedDashboardId,
onDashboardSelect,
onAddToDashboard,
onSave,
isSaving,
isSaveDialogOpen,
onSaveDialogOpenChange,
isAddToDashboardDialogOpen,
onAddToDashboardDialogOpenChange,
}: Readonly<CreateChartViewProps>) {
const { t } = useTranslation();
const chartPreviewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (chartData) {
chartPreviewRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [chartData]);
const handleAdvancedChartGenerated = (data: AnalyticsResponse) => {
onChartGenerated(data);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>
{chartId
? t("environments.analysis.charts.edit_chart_title")
: t("environments.analysis.charts.create_chart")}
</DialogTitle>
<DialogDescription>
{chartId
? t("environments.analysis.charts.edit_chart_description")
: t("environments.analysis.charts.create_chart_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
<AIQuerySection environmentId={environmentId} onChartGenerated={onChartGenerated} />
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-2 text-sm text-gray-500">
{t("environments.analysis.charts.OR")}
</span>
</div>
</div>
<div className="space-y-2">
<ChartBuilderGuide />
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={onSelectedChartTypeChange}
/>
</div>
{shouldShowAdvancedBuilder && (
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType}
initialQuery={chartData?.query}
hidePreview={true}
onChartGenerated={handleAdvancedChartGenerated}
onSave={onAdvancedBuilderSave}
onAddToDashboard={onAdvancedBuilderAddToDashboard}
/>
)}
{chartData && (
<div ref={chartPreviewRef}>
<ChartPreview chartData={chartData} />
</div>
)}
</div>
</DialogBody>
{chartData && (
<>
<ChartDialogFooter
onSaveClick={() => onSaveDialogOpenChange(true)}
onAddToDashboardClick={() => onAddToDashboardDialogOpenChange(true)}
isSaving={isSaving}
/>
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={onSaveDialogOpenChange}
chartName={chartName}
onChartNameChange={onChartNameChange}
onSave={onSave}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={onAddToDashboardDialogOpenChange}
chartName={chartName}
onChartNameChange={onChartNameChange}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={onDashboardSelect}
onConfirm={onAddToDashboard}
isSaving={isSaving}
/>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -1,68 +0,0 @@
"use client";
import { DatabaseIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatCellValue } from "@/modules/ee/analysis/charts/lib/chart-utils";
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
interface DataViewerProps {
data: TChartDataRow[];
}
export function DataViewer({ data }: Readonly<DataViewerProps>) {
const { t } = useTranslation();
if (!data || data.length === 0) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p className="text-sm text-gray-500">{t("environments.analysis.charts.no_data_available")}</p>
</div>
);
}
const columns = Object.keys(data[0]);
const displayData = data.slice(0, 50);
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-2 flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-gray-600" />
<h4 className="text-sm font-semibold text-gray-900">
{t("environments.analysis.charts.chart_data")}
</h4>
</div>
<div className="max-h-64 overflow-auto rounded bg-white">
<table className="w-full text-xs">
<thead className="bg-gray-100">
<tr>
{columns.map((key) => (
<th key={key} className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
{formatCubeColumnHeader(key)}
</th>
))}
</tr>
</thead>
<tbody>
{displayData.map((row, index) => {
const rowKey = Object.values(row)[0] ? String(Object.values(row)[0]) : `row-${index}`;
return (
<tr key={`data-row-${rowKey}-${index}`} className="border-b border-gray-100 hover:bg-gray-50">
{Object.entries(row).map(([key, value]) => (
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
{formatCellValue(value)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
{data.length > 50 && (
<div className="px-3 py-2 text-xs text-gray-500">
{t("environments.analysis.charts.showing_first_n_of", { n: 50, count: data.length })}
</div>
)}
</div>
</div>
);
}
@@ -1,43 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface DimensionsPanelProps {
selectedDimensions: string[];
onDimensionsChange: (dimensions: string[]) => void;
hideTitle?: boolean;
}
export function DimensionsPanel({
selectedDimensions,
onDimensionsChange,
hideTitle = false,
}: Readonly<DimensionsPanelProps>) {
const { t } = useTranslation();
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: [d.label, d.description].filter(Boolean).join(" - "),
}));
return (
<div className="w-full space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.dimensions")}
</h3>
)}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.group_by")}</label>
<MultiSelect
options={dimensionOptions}
value={selectedDimensions}
onChange={onDimensionsChange}
placeholder={t("environments.analysis.charts.select_measures")}
/>
<p className="text-sm text-gray-500">{t("environments.analysis.charts.group_by_description")}</p>
</div>
</div>
);
}
@@ -1,131 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
interface EditChartViewProps {
open: boolean;
onClose: () => void;
environmentId: string;
chartData: AnalyticsResponse | null;
/** Query from initialChart when chartData is still loading */
initialQuery?: AnalyticsResponse["query"];
isLoadingChart?: boolean;
chartLoadError?: string | null;
chartName: string;
onChartNameChange: (name: string) => void;
selectedChartType: TChartType | "";
onChartTypeChange: (type: TChartType) => void;
onChartGenerated: (data: AnalyticsResponse) => void;
onAdvancedBuilderSave: (savedChartId: string) => void;
onAdvancedBuilderAddToDashboard: (savedChartId: string, dashboardId?: string) => void;
dashboards: Array<{ id: string; name: string }>;
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onAddToDashboard: () => void;
onSave: () => void;
isSaving: boolean;
isAddToDashboardDialogOpen: boolean;
onAddToDashboardDialogOpenChange: (open: boolean) => void;
}
export function EditChartView({
open,
onClose,
environmentId,
chartData,
initialQuery,
isLoadingChart = false,
chartLoadError,
chartName,
onChartNameChange,
selectedChartType,
onChartTypeChange,
onChartGenerated,
onAdvancedBuilderSave,
onAdvancedBuilderAddToDashboard,
dashboards,
selectedDashboardId,
onDashboardSelect,
onAddToDashboard,
onSave,
isSaving,
isAddToDashboardDialogOpen,
onAddToDashboardDialogOpenChange,
}: Readonly<EditChartViewProps>) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.edit_chart_title")}</DialogTitle>
<DialogDescription>{t("environments.analysis.charts.edit_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4 px-1">
<div className="space-y-2">
<label htmlFor="edit-chart-name" className="text-sm">
{t("environments.analysis.charts.chart_name")}
</label>
<Input
id="edit-chart-name"
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
className="w-full"
/>
</div>
<div className="space-y-2">
<ChartBuilderGuide />
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={onChartTypeChange}
/>
</div>
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={onChartGenerated}
onSave={onAdvancedBuilderSave}
onAddToDashboard={onAdvancedBuilderAddToDashboard}
/>
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
</DialogBody>
<ChartDialogFooter
onSaveClick={onSave}
onAddToDashboardClick={() => onAddToDashboardDialogOpenChange(true)}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={onAddToDashboardDialogOpenChange}
chartName={chartName}
onChartNameChange={onChartNameChange}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={onDashboardSelect}
onConfirm={onAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}
@@ -1,264 +0,0 @@
"use client";
import { Plus, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder";
import {
FEEDBACK_FIELDS,
getFieldById,
getFilterOperatorsForType,
} from "@/modules/ee/analysis/lib/schema-definition";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface FiltersPanelProps {
filters: FilterRow[];
filterLogic: "and" | "or";
onFiltersChange: (filters: FilterRow[]) => void;
onFilterLogicChange: (logic: "and" | "or") => void;
hideTitle?: boolean;
}
export function FiltersPanel({
filters,
filterLogic,
onFiltersChange,
onFilterLogicChange,
hideTitle = false,
}: Readonly<FiltersPanelProps>) {
const { t } = useTranslation();
const fieldOptions = [
...FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: d.label,
type: d.type,
})),
...FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: m.label,
type: "number" as TFilterFieldType,
})),
];
const handleAddFilter = () => {
const firstField = fieldOptions[0];
onFiltersChange([
...filters,
{
field: firstField?.value || "",
operator: "equals",
values: null,
},
]);
};
const handleRemoveFilter = (index: number) => {
onFiltersChange(filters.filter((_, i) => i !== index));
};
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...updates };
// Reset values if operator changed to set/notSet
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
updated[index].values = null;
}
onFiltersChange(updated);
};
const getValueInput = (filter: FilterRow, index: number) => {
const field = getFieldById(filter.field);
const fieldType = (field?.type || "string") as TFilterFieldType;
// For set/notSet operators, no value input needed
if (filter.operator === "set" || filter.operator === "notSet") {
return null;
}
// For number fields with comparison operators, use number input
if (
fieldType === "number" &&
(filter.operator === "gt" ||
filter.operator === "gte" ||
filter.operator === "lt" ||
filter.operator === "lte")
) {
return (
<Input
type="number"
placeholder={t("environments.analysis.charts.enter_value")}
value={filter.values?.[0] ?? ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [Number(e.target.value)] : null,
})
}
className="w-[150px] bg-white"
/>
);
}
// For equals/notEquals with string fields, allow single value
if ((filter.operator === "equals" || filter.operator === "notEquals") && fieldType === "string") {
return (
<Input
placeholder={t("environments.analysis.charts.enter_value")}
value={filter.values?.[0] ?? ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px] bg-white"
/>
);
}
// For contains/notContains, allow multiple values (multi-select)
if (filter.operator === "contains" || filter.operator === "notContains") {
return (
<Input
placeholder={t("environments.analysis.charts.enter_value")}
value={filter.values?.[0] ?? ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px] bg-white"
/>
);
}
// Default: single value input
return (
<Input
placeholder={t("environments.analysis.charts.enter_value")}
value={filter.values?.[0] ?? ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px]"
/>
);
};
const hasFilters = filters.length > 0;
const hasMultipleFilters = filters.length > 1;
return (
<div className="w-full space-y-2">
{hasMultipleFilters && (
<div className={`flex items-center ${hideTitle ? "justify-end" : "justify-between"}`}>
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.filters")}
</h3>
)}
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
<SelectTrigger className="w-[100px] bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="and">{t("common.and")}</SelectItem>
<SelectItem value="or">{t("environments.analysis.charts.or_filter_logic")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-3">
{filters.map((filter, index) => {
const field = getFieldById(filter.field);
const fieldType = (field?.type || "string") as "string" | "number" | "time";
const operators = getFilterOperatorsForType(fieldType);
return (
<div
key={filter.operator + index}
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
<Select
value={filter.field}
onValueChange={(value) => {
const newField = getFieldById(value);
const newType = (newField?.type || "string") as TFilterFieldType;
const newOperators = getFilterOperatorsForType(newType);
handleUpdateFilter(index, {
field: value,
operator: newOperators[0] || "equals",
values: null,
});
}}>
<SelectTrigger className="w-[200px] bg-white">
<SelectValue placeholder={t("environments.analysis.charts.select_field")} />
</SelectTrigger>
<SelectContent>
{fieldOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(value) =>
handleUpdateFilter(index, {
operator: value,
})
}>
<SelectTrigger className="w-[150px] bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op} value={op}>
{op === "equals" && t("environments.analysis.charts.equals")}
{op === "notEquals" && t("environments.analysis.charts.not_equals")}
{op === "contains" && t("environments.analysis.charts.contains")}
{op === "notContains" && t("environments.analysis.charts.not_contains")}
{op === "set" && t("environments.analysis.charts.is_set")}
{op === "notSet" && t("environments.analysis.charts.is_not_set")}
{op === "gt" && t("environments.analysis.charts.greater_than")}
{op === "gte" && t("environments.analysis.charts.greater_than_or_equal")}
{op === "lt" && t("environments.analysis.charts.less_than")}
{op === "lte" && t("environments.analysis.charts.less_than_or_equal")}
</SelectItem>
))}
</SelectContent>
</Select>
{getValueInput(filter, index)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFilter(index)}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
);
})}
{hasFilters && (
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
<Plus className="h-4 w-4" />
{t("environments.analysis.charts.add_filter")}
</Button>
)}
</div>
</div>
);
}
@@ -1,47 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
interface ManualChartBuilderProps {
selectedChartType: TChartType | "";
onChartTypeSelect: (type: TChartType) => void;
}
export function ManualChartBuilder({
selectedChartType,
onChartTypeSelect,
}: Readonly<ManualChartBuilderProps>) {
const { t } = useTranslation();
const chartTypes = getChartTypes(t);
return (
<div className="space-y-2">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{chartTypes.map((chart) => {
const isSelected = selectedChartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
)}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -1,172 +0,0 @@
"use client";
import { Plus, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { CustomMeasure } from "@/modules/ee/analysis/lib/query-builder";
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface MeasuresPanelProps {
selectedMeasures: string[];
customMeasures: CustomMeasure[];
customAggregationsOpen: boolean;
onCustomAggregationsOpenChange: (open: boolean) => void;
onMeasuresChange: (measures: string[]) => void;
onCustomMeasuresChange: (measures: CustomMeasure[]) => void;
hideTitle?: boolean;
}
export function MeasuresPanel({
selectedMeasures,
customMeasures,
customAggregationsOpen,
onCustomAggregationsOpenChange,
onMeasuresChange,
onCustomMeasuresChange,
hideTitle = false,
}: Readonly<MeasuresPanelProps>) {
const { t } = useTranslation();
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: [m.label, m.description].filter(Boolean).join(" - "),
}));
const dimensionOptions = FEEDBACK_FIELDS.dimensions
.filter((d) => d.type === "number")
.map((d) => ({
value: d.id,
label: d.label,
}));
const aggregationOptions = FEEDBACK_FIELDS.customAggregations.map((agg) => ({
value: agg,
label: agg.charAt(0).toUpperCase() + agg.slice(1),
}));
const handleAddCustomMeasure = () => {
onCustomMeasuresChange([
...customMeasures,
{
id: `measure-${crypto.randomUUID()}`,
field: dimensionOptions[0]?.value || "",
aggregation: "avg",
},
]);
};
const handleRemoveCustomMeasure = (index: number) => {
onCustomMeasuresChange(customMeasures.filter((_, i) => i !== index));
};
const handleUpdateCustomMeasure = (index: number, updates: Partial<CustomMeasure>) => {
const updated = [...customMeasures];
updated[index] = { ...updated[index], ...updates };
onCustomMeasuresChange(updated);
};
return (
<div className="w-full space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">{t("environments.analysis.charts.measures")}</h3>
)}
<div className="space-y-2">
{/* Predefined Measures */}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.predefined_measures")}</label>
<MultiSelect
options={measureOptions}
value={selectedMeasures}
onChange={(selected) => onMeasuresChange(selected)}
placeholder={t("environments.analysis.charts.select_measures")}
/>
</div>
{/* Custom Aggregations */}
<AdvancedOptionToggle
isChecked={customAggregationsOpen}
onToggle={onCustomAggregationsOpenChange}
htmlId="chart-custom-aggregations-toggle"
title={t("environments.analysis.charts.custom_aggregations")}
description={t("environments.analysis.charts.custom_aggregations_toggle_description")}
customContainerClass="mt-2 px-0"
childrenContainerClass="flex-col gap-3 p-4"
childBorder>
<div className="w-full space-y-2">
{customMeasures.map((measure, index) => (
<div
key={measure.id ?? `custom-measure-${index}`}
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
<Select
value={measure.field}
onValueChange={(value) => handleUpdateCustomMeasure(index, { field: value })}>
<SelectTrigger className="w-[200px] bg-white">
<SelectValue placeholder={t("environments.analysis.charts.select_field")} />
</SelectTrigger>
<SelectContent>
{dimensionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={measure.aggregation}
onValueChange={(value) => handleUpdateCustomMeasure(index, { aggregation: value })}>
<SelectTrigger className="w-[150px] bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{aggregationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder={t("environments.analysis.charts.alias_optional")}
value={measure.alias || ""}
onChange={(e) => handleUpdateCustomMeasure(index, { alias: e.target.value })}
className="flex-1 bg-white"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCustomMeasure(index)}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
{customMeasures.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddCustomMeasure}
className="h-8">
<Plus className="h-4 w-4" />
{t("environments.analysis.charts.add_custom_measure")}
</Button>
)}
</div>
</AdvancedOptionToggle>
</div>
</div>
);
}
@@ -1,35 +0,0 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CodeIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
interface QueryViewerProps {
query: Record<string, unknown>;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
/** Optional trigger; when provided, renders as CollapsibleTrigger for collapsible UX */
trigger?: React.ReactNode;
}
export function QueryViewer({ query, isOpen, onOpenChange, trigger }: Readonly<QueryViewerProps>) {
const { t } = useTranslation();
return (
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
{trigger}
<Collapsible.CollapsibleContent className="mt-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-2 flex items-center gap-2">
<CodeIcon className="h-4 w-4 text-gray-600" />
<h4 className="text-sm font-semibold text-gray-900">
{t("environments.analysis.charts.cube_js_query")}
</h4>
</div>
<pre className="max-h-64 overflow-auto rounded bg-white p-3 text-xs">
{JSON.stringify(query, null, 2)}
</pre>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}
@@ -1,67 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
interface SaveChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
onSave: () => void;
isSaving: boolean;
}
export function SaveChartDialog({
open,
onOpenChange,
chartName,
onChartNameChange,
onSave,
isSaving,
}: Readonly<SaveChartDialogProps>) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.save_chart_dialog_title")}</DialogTitle>
<DialogDescription>
{t("environments.analysis.charts.enter_a_name_for_your_chart")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<Input
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && chartName.trim() && !isSaving) {
onSave();
}
}}
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,238 +0,0 @@
"use client";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { useState } from "react";
import Calendar from "react-calendar";
import { useTranslation } from "react-i18next";
import type { TimeDimensionConfig } from "@/modules/ee/analysis/lib/query-builder";
import {
DATE_PRESETS,
FEEDBACK_FIELDS,
TIME_GRANULARITIES,
} from "@/modules/ee/analysis/lib/schema-definition";
import { Button } from "@/modules/ui/components/button";
import "@/modules/ui/components/date-picker/styles.css";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface TimeDimensionPanelProps {
timeDimension: TimeDimensionConfig | null;
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
hideTitle?: boolean;
}
export function TimeDimensionPanel({
timeDimension,
onTimeDimensionChange,
hideTitle = false,
}: Readonly<TimeDimensionPanelProps>) {
const { t } = useTranslation();
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
);
const [customStartDate, setCustomStartDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
);
const [customEndDate, setCustomEndDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
);
const [presetValue, setPresetValue] = useState<string>(
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
);
const timeFieldOptions = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
const handleEnableTimeDimension = () => {
if (!timeDimension) {
onTimeDimensionChange({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: "last 30 days",
});
setPresetValue("last 30 days");
setDateRangeType("preset");
}
};
const handleDimensionChange = (dimension: string) => {
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dimension });
}
};
const handleGranularityChange = (value: string) => {
if (timeDimension) {
const granularity = value === "none" ? undefined : (value as TimeDimensionConfig["granularity"]);
onTimeDimensionChange({ ...timeDimension, granularity });
}
};
const handlePresetChange = (preset: string) => {
setPresetValue(preset);
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
}
};
if (!timeDimension) {
return (
<div className="space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.time_dimension")}
</h3>
)}
<div>
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
{t("environments.analysis.charts.enable_time_dimension")}
</Button>
</div>
</div>
);
}
return (
<div className="w-full space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.time_dimension")}
</h3>
)}
<div className="space-y-3">
{/* Field Selector */}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.field")}</label>
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeFieldOptions.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Granularity Selector */}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.granularity")}</label>
<Select value={timeDimension.granularity ?? "none"} onValueChange={handleGranularityChange}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("environments.analysis.charts.no_grouping")}</SelectItem>
{TIME_GRANULARITIES.map((gran) => (
<SelectItem key={gran} value={gran}>
{gran.charAt(0).toUpperCase() + gran.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Date Range */}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.date_range")}</label>
<div className="space-y-2">
<Select
value={dateRangeType}
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preset">{t("environments.analysis.charts.preset")}</SelectItem>
<SelectItem value="custom">{t("environments.analysis.charts.custom_range")}</SelectItem>
</SelectContent>
</Select>
{dateRangeType === "preset" ? (
<Select value={presetValue} onValueChange={handlePresetChange}>
<SelectTrigger className="w-full bg-white">
<SelectValue placeholder={t("environments.analysis.charts.select_preset")} />
</SelectTrigger>
<SelectContent>
{DATE_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
{presetValue && !DATE_PRESETS.some((p) => p.value === presetValue) && (
<SelectItem key={presetValue} value={presetValue}>
{presetValue}
</SelectItem>
)}
</SelectContent>
</Select>
) : (
<div className="grid grid-cols-2 gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customStartDate
? format(customStartDate, "MMM dd, yyyy")
: t("environments.analysis.charts.start_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomStartDate(date);
if (timeDimension && date && customEndDate) {
onTimeDimensionChange({
...timeDimension,
dateRange: [date, customEndDate],
});
}
}}
value={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customEndDate
? format(customEndDate, "MMM dd, yyyy")
: t("environments.analysis.charts.end_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomEndDate(date);
if (timeDimension && customStartDate && date) {
onTimeDimensionChange({
...timeDimension,
dateRange: [customStartDate, date],
});
}
}}
value={customEndDate || undefined}
minDate={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -1,340 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
createChartAction,
executeQueryAction,
getChartAction,
updateChartAction,
} from "@/modules/ee/analysis/charts/actions";
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import type { AnalyticsResponse, TChartType, TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface UseCreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
initialChart?: TChartWithCreator;
defaultDashboardId?: string;
onSuccess?: () => void;
}
export function useCreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
defaultDashboardId,
onSuccess,
}: Readonly<UseCreateChartDialogProps>) {
const { t } = useTranslation();
const [selectedChartType, setSelectedChartType] = useState<TChartType | "">("");
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>(defaultDashboardId ?? "");
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
const router = useRouter();
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
} else if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
if (open && chartId) {
const chartMetadata = initialChart?.id === chartId ? initialChart : undefined;
if (chartMetadata) {
setChartName(chartMetadata.name);
setSelectedChartType(resolveChartType(chartMetadata.type));
setCurrentChartId(chartMetadata.id);
}
setIsLoadingChart(true);
setChartLoadError(null);
const loadChartData = async (query: TChartWithCreator["query"], chartType: string) => {
const queryResult = await executeQueryAction({
environmentId,
query,
});
if (queryResult?.serverError) {
const errorMsg =
getFormattedErrorMessage(queryResult) ||
t("environments.analysis.charts.failed_to_load_chart_data");
toast.error(errorMsg);
setChartLoadError(errorMsg);
setIsLoadingChart(false);
return;
}
const data = Array.isArray(queryResult?.data) ? queryResult.data : undefined;
if (data) {
setChartData({
query,
chartType: resolveChartType(chartType),
data,
});
} else {
const errorMsg = t("environments.analysis.charts.no_data_returned_for_chart");
toast.error(errorMsg);
setChartLoadError(errorMsg);
}
setIsLoadingChart(false);
};
if (chartMetadata) {
loadChartData(chartMetadata.query, chartMetadata.type);
} else {
getChartAction({ environmentId, chartId })
.then(async (result) => {
if (result?.data) {
const chart = result.data;
setChartName(chart.name);
setSelectedChartType(resolveChartType(chart.type));
setCurrentChartId(chart.id);
await loadChartData(chart.query, chart.type);
} else if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsLoadingChart(false);
}
})
.catch((error: unknown) => {
const message =
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_load_chart");
toast.error(message);
setChartLoadError(message);
setIsLoadingChart(false);
});
}
} else if (open && !chartId) {
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
}
}, [open, chartId, environmentId, initialChart]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
if (!currentChartId) {
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
}
setSelectedChartType(data.chartType);
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
return;
}
setIsSaving(true);
try {
if (currentChartId) {
const result = await updateChartAction({
environmentId,
chartId: currentChartId,
chartUpdateInput: {
name: chartName.trim(),
type: resolveChartType(chartData.chartType),
query: chartData.query,
config: {},
},
});
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
return;
}
toast.success(t("environments.analysis.charts.chart_updated_successfully"));
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} else {
const result = await createChartAction({
environmentId,
chartInput: {
name: chartName.trim(),
type: resolveChartType(chartData.chartType),
query: chartData.query,
config: {},
},
});
if (result?.data) {
setCurrentChartId(result.data.id);
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
setIsSaveDialogOpen(false);
onOpenChange(false);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_save_chart");
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error(t("environments.analysis.charts.please_select_dashboard"));
return;
}
setIsSaving(true);
try {
let chartIdToUse = currentChartId;
if (!chartIdToUse) {
if (!chartName.trim()) {
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
setIsSaving(false);
return;
}
const chartResult = await createChartAction({
environmentId,
chartInput: {
name: chartName.trim(),
type: resolveChartType(chartData.chartType),
query: chartData.query,
config: {},
},
});
if (!chartResult?.data) {
toast.error(
(chartResult && getFormattedErrorMessage(chartResult)) ||
t("environments.analysis.charts.failed_to_save_chart")
);
setIsSaving(false);
return;
}
chartIdToUse = chartResult.data.id;
setCurrentChartId(chartResult.data.id);
}
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
title: chartName.trim(),
layout: { x: 0, y: 0, w: 4, h: 3 },
});
if (!widgetResult?.data) {
toast.error(
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
);
return;
}
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
onOpenChange(false);
}
};
const handleAdvancedBuilderSave = (savedChartId: string) => {
setCurrentChartId(savedChartId);
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
};
const handleAdvancedBuilderAddToDashboard = (savedChartId: string) => {
setCurrentChartId(savedChartId);
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
};
const handleChartTypeChange = (type: TChartType) => {
setSelectedChartType(type);
setChartData((prev) => (prev ? { ...prev, chartType: type } : null));
};
const initialQuery = initialChart && initialChart.id === chartId ? initialChart.query : undefined;
return {
chartData,
chartName,
setChartName,
selectedChartType,
initialQuery,
setSelectedChartType,
currentChartId,
setCurrentChartId,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
isSaving,
isLoadingChart,
chartLoadError,
shouldShowAdvancedBuilder,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
handleClose,
handleAdvancedBuilderSave,
handleAdvancedBuilderAddToDashboard,
handleChartTypeChange,
};
}
@@ -1,185 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createChartAction, deleteChartAction } from "@/modules/ee/analysis/charts/actions";
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
export interface ChartInput {
query: TChartQuery;
chartType: TChartType;
}
export interface UseSaveDashboardDialogsProps {
environmentId: string;
/** Returns current query and chart type when save/add is triggered; null if not ready */
getChartInput: () => ChartInput | null;
onSave?: (chartId: string) => void;
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
}
export function useSaveDashboardDialogs({
environmentId,
getChartInput,
onSave,
onAddToDashboard,
}: Readonly<UseSaveDashboardDialogsProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
} else if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
const handleSaveChart = async () => {
const input = getChartInput();
if (!input) return;
if (!chartName.trim()) {
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
return;
}
setIsSaving(true);
try {
const result = await createChartAction({
environmentId,
chartInput: {
name: chartName.trim(),
type: resolveChartType(input.chartType),
query: input.query,
config: {},
},
});
if (!result?.data) {
toast.error(
(result && getFormattedErrorMessage(result)) ||
t("environments.analysis.charts.failed_to_save_chart")
);
return;
}
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
setIsSaveDialogOpen(false);
if (onSave) {
onSave(result.data.id);
} else {
router.push(`/environments/${environmentId}/analysis/charts`);
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_save_chart");
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
const input = getChartInput();
if (!input || !selectedDashboardId) {
toast.error(t("environments.analysis.charts.please_select_dashboard"));
return;
}
const name = chartName.trim() || `Chart ${new Date().toISOString().slice(0, 19)}`;
setIsSaving(true);
let chartId: string | null = null;
try {
const chartResult = await createChartAction({
environmentId,
chartInput: {
name,
type: resolveChartType(input.chartType),
query: input.query,
config: {},
},
});
if (!chartResult?.data) {
toast.error(
(chartResult && getFormattedErrorMessage(chartResult)) ||
t("environments.analysis.charts.failed_to_save_chart")
);
return;
}
chartId = chartResult.data.id;
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId,
dashboardId: selectedDashboardId,
title: name,
layout: { x: 0, y: 0, w: 4, h: 3 },
});
if (!widgetResult?.data) {
toast.error(
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
);
await deleteChartAction({ environmentId, chartId }).catch(() => {
/* best-effort cleanup of orphan chart */
});
return;
}
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
setIsAddToDashboardDialogOpen(false);
if (onAddToDashboard) {
onAddToDashboard(chartId, selectedDashboardId);
} else {
router.push(`/environments/${environmentId}/analysis/dashboards/${selectedDashboardId}`);
}
} catch (err: unknown) {
const message =
err instanceof Error
? err.message
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
toast.error(message);
if (chartId) {
await deleteChartAction({ environmentId, chartId }).catch(() => {
/* best-effort cleanup of orphan chart */
});
}
} finally {
setIsSaving(false);
}
};
return {
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
chartName,
setChartName,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
isSaving,
handleSaveChart,
handleAddToDashboard,
};
}
@@ -1,18 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types";
describe("chart-types", () => {
test("CHART_TYPE_ICONS has all chart types", () => {
expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]);
});
test("getChartTypes returns chart types with translated labels", () => {
const t = vi.fn((key: string) => key);
const result = getChartTypes(t);
expect(result).toHaveLength(5);
expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]);
expect(t).toHaveBeenCalledWith("environments.analysis.charts.chart_type_area");
expect(result[0].label).toBe("environments.analysis.charts.chart_type_area");
});
});
@@ -1,33 +0,0 @@
import type { TFunction } from "i18next";
import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react";
import type React from "react";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
export const CHART_TYPE_ICONS: Record<
TChartType,
React.ComponentType<{ className?: string; strokeWidth?: number }>
> = {
area: AreaChartIcon,
bar: BarChart3Icon,
line: LineChartIcon,
pie: PieChartIcon,
big_number: ActivityIcon,
};
export function getChartTypes(t: TFunction): readonly {
id: TChartType;
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
label: string;
}[] {
return [
{ id: "area", icon: CHART_TYPE_ICONS.area, label: t("environments.analysis.charts.chart_type_area") },
{ id: "bar", icon: CHART_TYPE_ICONS.bar, label: t("environments.analysis.charts.chart_type_bar") },
{ id: "line", icon: CHART_TYPE_ICONS.line, label: t("environments.analysis.charts.chart_type_line") },
{ id: "pie", icon: CHART_TYPE_ICONS.pie, label: t("environments.analysis.charts.chart_type_pie") },
{
id: "big_number",
icon: CHART_TYPE_ICONS.big_number,
label: t("environments.analysis.charts.chart_type_big_number"),
},
];
}
@@ -1,95 +0,0 @@
import { format, isValid, parseISO } from "date-fns";
import type { TChartQuery } from "@formbricks/types/analysis";
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
import { ZChartType } from "@/modules/ee/analysis/types/analysis";
export const CHART_BRAND_DARK = "#00C4B8";
export const CHART_BRAND_LIGHT = "#00E6CA";
/** Validate a chart type string, defaulting to "bar" if unrecognised. */
export const resolveChartType = (raw: string): TChartType => {
const parsed = ZChartType.safeParse(raw);
return parsed.success ? parsed.data : "bar";
};
const isNumericValue = (val: TChartDataRow[string]): boolean => {
if (val === null || val === undefined || val === "") return false;
const num = Number(val);
return !Number.isNaN(num) && Number.isFinite(num);
};
export const preparePieData = (
data: TChartDataRow[],
dataKey: string
): { processedData: TChartDataRow[]; colors: string[] } | null => {
const validData = data.filter((row) => isNumericValue(row[dataKey]));
const processedData = validData.map((row) => ({ ...row, [dataKey]: Number(row[dataKey]) }));
if (processedData.length === 0) return null;
const colors = processedData.map((_, i) => {
const sat = 70 + (i % 3) * 10;
const light = 45 + (i % 2) * 15;
return `hsl(180, ${sat}%, ${light}%)`;
});
if (colors.length > 0) colors[0] = CHART_BRAND_DARK;
if (colors.length > 1) colors[1] = CHART_BRAND_LIGHT;
return { processedData, colors };
};
/**
* Format a cell value for display in tables and tooltips.
* ISO date strings become "MMM d, yyyy"; numbers stay as-is (formatted); objects are stringified.
*/
export function formatCellValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "number") return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
if (typeof value === "string") {
const date = parseISO(value);
if (isValid(date)) return format(date, "MMM d, yyyy");
return value;
}
if (typeof value === "object") return JSON.stringify(value);
if (typeof value === "boolean" || typeof value === "bigint") return String(value);
return "";
}
const ALLOWED_CUBE_PREFIX = "FeedbackRecords.";
function validateMember(member: string): boolean {
return member.startsWith(ALLOWED_CUBE_PREFIX);
}
/**
* Validates that all measures, dimensions, segments, timeDimensions, and filters
* use only members starting with FeedbackRecords.
* @throws Error if any member is invalid
*/
export function validateQueryMembers(query: TChartQuery): void {
const invalid: string[] = [];
for (const m of query.measures ?? []) {
if (!validateMember(m)) invalid.push(m);
}
for (const d of query.dimensions ?? []) {
if (!validateMember(d)) invalid.push(d);
}
for (const s of query.segments ?? []) {
if (!validateMember(s)) invalid.push(s);
}
for (const td of query.timeDimensions ?? []) {
if (!validateMember(td.dimension)) invalid.push(td.dimension);
}
const checkFilters = (f: TChartQuery["filters"]): void => {
if (!f) return;
for (const item of f) {
if ("member" in item && typeof item.member === "string" && !validateMember(item.member)) {
invalid.push(item.member);
}
if ("and" in item && Array.isArray(item.and)) checkFilters(item.and);
if ("or" in item && Array.isArray(item.or)) checkFilters(item.or);
}
};
checkFilters(query.filters);
if (invalid.length > 0) {
throw new Error(`Invalid query members (must start with ${ALLOWED_CUBE_PREFIX}): ${invalid.join(", ")}`);
}
}
@@ -1,426 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
vi.mock("server-only", () => ({}));
var mockTxChart: {
// NOSONAR S1135 - var required for vi.mock hoisting
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
vi.mock("@formbricks/database", () => {
const tx = {
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
mockTxChart = tx;
return {
prisma: {
chart: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn((cb: any) => cb({ chart: tx })),
},
};
});
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: () => Promise.resolve({ project: { id: "project-abc-123" } }),
}));
const mockChartId = "chart-abc-123";
const mockProjectId = "project-abc-123";
const mockUserId = "user-abc-123";
const selectChart = {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
};
const mockChart = {
id: mockChartId,
name: "Test Chart",
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
};
const makePrismaError = (code: string) =>
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
describe("Chart Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createChart", () => {
test("creates a chart successfully", async () => {
vi.mocked(prisma.chart.create).mockResolvedValue(mockChart as any);
const { createChart } = await import("./charts");
const result = await createChart({
projectId: mockProjectId,
name: "Test Chart",
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdBy: mockUserId,
});
expect(result).toEqual(mockChart);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: {
name: "Test Chart",
type: "bar",
projectId: mockProjectId,
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdBy: mockUserId,
},
select: selectChart,
});
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.chart.create).mockRejectedValue(
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
);
const { createChart } = await import("./charts");
await expect(
createChart({
projectId: mockProjectId,
name: "Duplicate",
type: "bar",
query: {},
config: {},
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
test("throws DatabaseError on other Prisma errors", async () => {
vi.mocked(prisma.chart.create).mockRejectedValue(makePrismaError("P9999"));
const { createChart } = await import("./charts");
await expect(
createChart({
projectId: mockProjectId,
name: "Test",
type: "bar",
query: {},
config: {},
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("updateChart", () => {
test("updates a chart successfully", async () => {
const updatedChart = { ...mockChart, name: "Updated Chart" };
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.update.mockResolvedValue(updatedChart);
const { updateChart } = await import("./charts");
const result = await updateChart(mockChartId, mockProjectId, { name: "Updated Chart" });
expect(result).toEqual({ chart: mockChart, updatedChart });
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(mockTxChart.update).toHaveBeenCalledWith({
where: { id: mockChartId },
data: { name: "Updated Chart", type: undefined, query: undefined, config: undefined },
select: selectChart,
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
const { updateChart } = await import("./charts");
await expect(updateChart(mockChartId, mockProjectId, { name: "Updated" })).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxChart.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
const { updateChart } = await import("./charts");
await expect(updateChart(mockChartId, mockProjectId, { name: "Taken Name" })).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
describe("duplicateChart", () => {
test("duplicates a chart with '(copy)' suffix", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
vi.mocked(prisma.chart.create).mockResolvedValue({ ...mockChart, name: "Test Chart (copy)" } as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy)" }),
select: selectChart,
});
});
test("increments copy number when '(copy)' already exists", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 2)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy 2)" }),
select: selectChart,
});
});
test("finds next available copy number", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([
{ name: "Test Chart (copy)" },
{ name: "Test Chart (copy 2)" },
] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 3)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy 3)" }),
select: selectChart,
});
});
test("strips existing copy suffix before generating new name", async () => {
const chartWithCopy = { ...mockChart, name: "Test Chart (copy)" };
vi.mocked(prisma.chart.findFirst).mockResolvedValue(chartWithCopy as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 2)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId, name: { startsWith: "Test Chart (copy" } },
select: { name: true },
});
});
test("throws ResourceNotFoundError when source chart does not exist", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
const { duplicateChart } = await import("./charts");
await expect(duplicateChart(mockChartId, mockProjectId, mockUserId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
});
});
describe("deleteChart", () => {
test("deletes a chart successfully", async () => {
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.delete.mockResolvedValue(undefined);
const { deleteChart } = await import("./charts");
const result = await deleteChart(mockChartId, mockProjectId);
expect(result).toEqual(mockChart);
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(mockTxChart.delete).toHaveBeenCalledWith({ where: { id: mockChartId } });
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
const { deleteChart } = await import("./charts");
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxChart.delete).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxChart.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
const { deleteChart } = await import("./charts");
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getChart", () => {
test("returns a chart successfully", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
const { getChart } = await import("./charts");
const result = await getChart(mockChartId, mockProjectId);
expect(result).toEqual(mockChart);
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
const { getChart } = await import("./charts");
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P9999"));
const { getChart } = await import("./charts");
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getCharts", () => {
test("returns all charts for a project", async () => {
const chartsFromDb = [
{ ...mockChart, creator: { name: "User 1" } },
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
];
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsFromDb as any);
const { getCharts } = await import("./charts");
const result = await getCharts(mockProjectId);
expect(result).toEqual([
{ ...mockChart, creator: { name: "User 1" } },
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
]);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
creator: { select: { name: true } },
},
});
});
test("returns empty array when no charts exist", async () => {
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
const { getCharts } = await import("./charts");
const result = await getCharts(mockProjectId);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getCharts } = await import("./charts");
await expect(getCharts(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getChartsWithCreator", () => {
test("returns charts with creator info", async () => {
const chartsWithCreator = [
{ ...mockChart, creator: { name: "Alice" } },
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: null },
];
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsWithCreator as any);
const { getChartsWithCreator } = await import("./charts");
const result = await getChartsWithCreator(mockProjectId);
expect(result).toEqual(chartsWithCreator);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
creator: { select: { name: true } },
}),
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getChartsWithCreator } = await import("./charts");
await expect(getChartsWithCreator(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
});
@@ -1,276 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/analysis";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import {
TChart,
TChartCreateInput,
TChartUpdateInput,
TChartWithCreator,
TChartWithWidgets,
ZChartCreateInput,
ZChartType,
ZChartUpdateInput,
} from "@/modules/ee/analysis/types/analysis";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
export const selectChart = {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
} as const;
export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
validateInputs([data, ZChartCreateInput]);
try {
return await prisma.chart.create({
data: {
name: data.name,
type: data.type,
projectId: data.projectId,
query: data.query,
config: data.config,
createdBy: data.createdBy,
},
select: selectChart,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A chart with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateChart = async (
chartId: string,
projectId: string,
data: TChartUpdateInput
): Promise<{ chart: TChart; updatedChart: TChart }> => {
validateInputs([chartId, ZId], [projectId, ZId], [data, ZChartUpdateInput]);
try {
return await prisma.$transaction(async (tx) => {
const chart = await tx.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
const updatedChart = await tx.chart.update({
where: { id: chartId },
data: {
name: data.name,
type: data.type,
query: data.query,
config: data.config,
},
select: selectChart,
});
return { chart, updatedChart };
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A chart with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
const getUniqueCopyName = async (baseName: string, projectId: string): Promise<string> => {
const stripped = baseName.replace(/ \(copy(?: \d+)?\)$/, "");
try {
const existing = await prisma.chart.findMany({
where: {
projectId,
name: { startsWith: `${stripped} (copy` },
},
select: { name: true },
});
const existingNames = new Set(existing.map((c) => c.name));
const firstCandidate = `${stripped} (copy)`;
if (!existingNames.has(firstCandidate)) {
return firstCandidate;
}
let n = 2;
while (existingNames.has(`${stripped} (copy ${n})`)) {
n++;
}
return `${stripped} (copy ${n})`;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const duplicateChart = async (
chartId: string,
projectId: string,
createdBy: string
): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId], [createdBy, ZId]);
try {
const sourceChart = await prisma.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!sourceChart) {
throw new ResourceNotFoundError("Chart", chartId);
}
const uniqueName = await getUniqueCopyName(sourceChart.name, projectId);
return await createChart({
projectId,
name: uniqueName,
type: ZChartType.parse(sourceChart.type),
query: ZChartQuery.parse(sourceChart.query),
config: ZChartConfig.parse(sourceChart.config ?? {}),
createdBy,
});
} catch (error) {
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteChart = async (chartId: string, projectId: string): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const chart = await tx.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
await tx.chart.delete({
where: { id: chartId },
});
return chart;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getChart = async (chartId: string, projectId: string): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId]);
try {
const chart = await prisma.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
return chart;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
/**
* Fetches all charts for the given environment (for list/dashboard UI).
* Uses getEnvironmentAuth for access check and enriches with creator names.
*/
export const getCharts = async (environmentId: string): Promise<TChartWithCreator[]> => {
try {
const { project } = await getEnvironmentAuth(environmentId);
const charts = await prisma.chart.findMany({
where: { projectId: project.id },
orderBy: { createdAt: "desc" },
select: {
...selectChart,
creator: { select: { name: true } },
},
});
return charts;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getChartsWithCreator = async (projectId: string): Promise<TChartWithCreator[]> => {
validateInputs([projectId, ZId]);
try {
return await prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
...selectChart,
creator: {
select: { name: true },
},
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -1,27 +0,0 @@
import { ReactNode } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
interface AnalysisPageLayoutProps {
pageTitle: string;
environmentId: string;
cta?: ReactNode;
children: ReactNode;
}
export function AnalysisPageLayout({
pageTitle,
environmentId,
cta,
children,
}: Readonly<AnalysisPageLayoutProps>) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} cta={cta}>
<AnalysisSecondaryNavigation environmentId={environmentId} />
</PageHeader>
{children}
</PageContentWrapper>
);
}
@@ -1,31 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface AnalysisSecondaryNavigationProps {
environmentId: string;
}
export function AnalysisSecondaryNavigation({ environmentId }: Readonly<AnalysisSecondaryNavigationProps>) {
const { t } = useTranslation();
const pathname = usePathname();
const activeId = pathname?.includes("/charts") ? "charts" : "dashboards";
const navigation = [
{
id: "dashboards",
label: t("common.dashboards"),
href: `/environments/${environmentId}/analysis/dashboards`,
},
{
id: "charts",
label: t("common.charts"),
href: `/environments/${environmentId}/analysis/charts`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
}
@@ -1,255 +0,0 @@
"use server";
// eslint-disable-next-line
// TODO: remove revalidatePath and use revalidateTag instead once this has become stable: https://nextjs.org/docs/app/api-reference/directives/use-cache#usage
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZWidgetLayout } from "@formbricks/types/analysis";
import { ZId } from "@formbricks/types/common";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZDashboardUpdateInput } from "../types/analysis";
import {
addChartToDashboard,
createDashboard,
deleteDashboard,
duplicateDashboard,
getDashboard,
getDashboards,
updateDashboard,
} from "./lib/dashboards";
const ZCreateDashboardAction = z.object({
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
});
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await createDashboard({
projectId,
name: parsedInput.name,
description: parsedInput.description,
createdBy: ctx.user.id,
});
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = dashboard.id;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZUpdateDashboardAction = z
.object({
environmentId: ZId,
dashboardId: ZId,
})
.merge(ZDashboardUpdateInput);
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const { dashboard, updatedDashboard } = await updateDashboard(parsedInput.dashboardId, projectId, {
name: parsedInput.name,
description: parsedInput.description,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZDeleteDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
withAuditLogging(
"deleted",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await deleteDashboard(parsedInput.dashboardId, projectId);
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
ctx.auditLoggingCtx.oldObject = dashboard;
return { success: true };
}
)
);
const ZDuplicateDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const duplicateDashboardAction = authenticatedActionClient.schema(ZDuplicateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await duplicateDashboard(parsedInput.dashboardId, projectId, ctx.user.id);
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = dashboard.id;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getDashboards(projectId);
}
);
const ZGetDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const getDashboardAction = authenticatedActionClient
.schema(ZGetDashboardAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getDashboard(parsedInput.dashboardId, projectId);
}
);
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
chartId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
withAuditLogging(
"created",
"dashboardWidget",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZAddChartToDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const widget = await addChartToDashboard({
dashboardId: parsedInput.dashboardId,
chartId: parsedInput.chartId,
projectId,
title: parsedInput.title,
layout: parsedInput.layout,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
ctx.auditLoggingCtx.newObject = widget;
return widget;
}
)
);
@@ -1,81 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { createDashboardAction } from "../actions";
import { CreateDashboardDialog } from "./create-dashboard-dialog";
interface CreateDashboardButtonProps {
environmentId: string;
}
export const CreateDashboardButton = ({ environmentId }: Readonly<CreateDashboardButtonProps>) => {
const { t } = useTranslation();
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [dashboardName, setDashboardName] = useState("");
const [dashboardDescription, setDashboardDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open);
if (!open) {
setDashboardName("");
setDashboardDescription("");
}
};
const handleCreate = async () => {
if (!dashboardName.trim()) {
toast.error(t("environments.analysis.dashboards.please_enter_name"));
return;
}
setIsCreating(true);
try {
const result = await createDashboardAction({
environmentId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || undefined,
});
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
return;
}
toast.success(t("environments.analysis.dashboards.create_success"));
handleOpenChange(false);
router.push(`/environments/${environmentId}/analysis/dashboards/${result.data.id}`);
} catch {
toast.error(t("environments.analysis.dashboards.create_failed"));
} finally {
setIsCreating(false);
}
};
return (
<>
<Button onClick={() => handleOpenChange(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.dashboards.create_dashboard")}
</Button>
<CreateDashboardDialog
open={isCreateDialogOpen}
onOpenChange={handleOpenChange}
dashboardName={dashboardName}
onDashboardNameChange={setDashboardName}
dashboardDescription={dashboardDescription}
onDashboardDescriptionChange={setDashboardDescription}
onCreate={handleCreate}
isCreating={isCreating}
/>
</>
);
};
@@ -1,97 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface CreateDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dashboardName: string;
onDashboardNameChange: (name: string) => void;
dashboardDescription: string;
onDashboardDescriptionChange: (description: string) => void;
onCreate: () => void;
isCreating: boolean;
}
export const CreateDashboardDialog = ({
open,
onOpenChange,
dashboardName,
onDashboardNameChange,
dashboardDescription,
onDashboardDescriptionChange,
onCreate,
isCreating,
}: Readonly<CreateDashboardDialogProps>) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow">
<DialogHeader>
<DialogTitle>{t("environments.analysis.dashboards.create_dashboard")}</DialogTitle>
<DialogDescription>
{t("environments.analysis.dashboards.create_dashboard_description")}
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
if (dashboardName.trim() && !isCreating) {
onCreate();
}
}}
className="space-y-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dashboard-name">{t("environments.analysis.dashboards.dashboard_name")}</Label>
<Input
id="dashboard-name"
placeholder={t("environments.analysis.dashboards.dashboard_name_placeholder")}
value={dashboardName}
onChange={(e) => onDashboardNameChange(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="dashboard-description">
{t("environments.analysis.dashboards.description_optional")}
</Label>
<Input
id="dashboard-description"
placeholder={t("environments.analysis.dashboards.description_placeholder")}
value={dashboardDescription}
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isCreating}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={isCreating} disabled={!dashboardName.trim()}>
{t("common.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -1,130 +0,0 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { deleteDashboardAction, duplicateDashboardAction } from "../actions";
interface DashboardDropdownMenuProps {
environmentId: string;
dashboardId: string;
dashboardName: string;
}
export const DashboardDropdownMenu = ({
environmentId,
dashboardId,
dashboardName,
}: Readonly<DashboardDropdownMenuProps>) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDuplicateDashboard = async () => {
setIsDuplicating(true);
try {
const result = await duplicateDashboardAction({ environmentId, dashboardId });
if (result?.data) {
toast.success(t("environments.analysis.dashboards.duplicate_success"));
} else {
toast.error(result?.serverError || t("environments.analysis.dashboards.duplicate_failed"));
}
} catch {
toast.error(t("environments.analysis.dashboards.duplicate_failed"));
} finally {
setIsDuplicating(false);
}
};
const handleDeleteDashboard = async () => {
setIsDeleting(true);
try {
const result = await deleteDashboardAction({ environmentId, dashboardId });
if (result?.data) {
setDeleteDialogOpen(false);
toast.success(t("environments.analysis.dashboards.delete_success"));
} else {
toast.error(result?.serverError || t("environments.analysis.dashboards.delete_failed"));
}
} catch {
toast.error(t("environments.analysis.dashboards.delete_failed"));
} finally {
setIsDeleting(false);
}
};
return (
<div data-testid={`${dashboardName.toLowerCase().split(" ").join("-")}-dashboard-actions`}>
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild>
<button type="button" className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
<span className="sr-only">{t("common.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/analysis/dashboards/${dashboardId}`}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={isDuplicating}
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
handleDuplicateDashboard();
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.dashboard")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDeleteDashboard}
text={t("environments.analysis.dashboards.delete_confirmation")}
isDeleting={isDeleting}
/>
</div>
);
};
@@ -1,49 +0,0 @@
const SKELETON_ROWS = 3;
const SkeletonRow = () => {
return (
<div className="grid h-12 w-full animate-pulse grid-cols-8 content-center">
<div className="col-span-7 grid grid-cols-7 content-center p-2">
<div className="col-span-3 flex items-center gap-4 pl-6">
<div className="h-5 w-5 rounded bg-gray-200" />
<div className="h-4 w-36 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-6 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-16 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-20 rounded bg-gray-200" />
</div>
</div>
<div className="col-span-1" />
</div>
);
};
interface DashboardsListSkeletonProps {
columnHeaders: string[];
}
export const DashboardsListSkeleton = ({ columnHeaders }: Readonly<DashboardsListSkeletonProps>) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[4]}</div>
<div className="col-span-1" />
</div>
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<SkeletonRow key={`skeleton-row-${String(i)}`} />
))}
</div>
);
};
@@ -1,85 +0,0 @@
import { BarChart3Icon } from "lucide-react";
import Link from "next/link";
import { convertDateString, timeSinceDate } from "@/lib/time";
import { getTranslate } from "@/lingodotdev/server";
import { TDashboardWithCount } from "../../types/analysis";
import { DashboardDropdownMenu } from "./dashboard-dropdown-menu";
interface DashboardsTableProps {
dashboards: TDashboardWithCount[];
environmentId: string;
isReadOnly: boolean;
}
export const DashboardsTable = async ({
dashboards,
environmentId,
isReadOnly,
}: Readonly<DashboardsTableProps>) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.charts")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
<div className="col-span-1" />
</div>
{dashboards.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">
{t("environments.analysis.dashboards.no_dashboards_found")}
</p>
) : (
dashboards.map((dashboard) => {
return (
<div
key={dashboard.id}
className="grid h-12 w-full grid-cols-8 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
<Link
href={`/environments/${environmentId}/analysis/dashboards/${dashboard.id}`}
className="col-span-7 grid cursor-pointer grid-cols-7 content-center p-2">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="w-8 flex-shrink-0 text-slate-500">
<BarChart3Icon className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="font-medium text-slate-900">{dashboard.name}</div>
{dashboard.description && (
<div className="text-xs font-medium text-slate-500">{dashboard.description}</div>
)}
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{dashboard._count.widgets}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{dashboard.creator?.name || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{convertDateString(dashboard.createdAt.toISOString())}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{timeSinceDate(dashboard.updatedAt)}</div>
</div>
</Link>
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
{!isReadOnly && (
<DashboardDropdownMenu
environmentId={environmentId}
dashboardId={dashboard.id}
dashboardName={dashboard.name}
/>
)}
</div>
</div>
);
})
)}
</div>
);
};
@@ -1,605 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
vi.mock("server-only", () => ({}));
var mockTxDashboard: {
// NOSONAR / test code
findFirst: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
var mockTxWidget: {
// NOSONAR / test code
aggregate: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
vi.mock("@formbricks/database", () => {
const txDash = { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() };
const txChart = { findFirst: vi.fn() };
const txWidget = { aggregate: vi.fn(), create: vi.fn() };
mockTxDashboard = txDash;
mockTxChart = txChart;
mockTxWidget = txWidget;
return {
prisma: {
dashboard: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
},
};
});
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
selectChart: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
},
}));
const mockDashboardId = "dashboard-abc-123";
const mockProjectId = "project-abc-123";
const mockUserId = "user-abc-123";
const mockChartId = "chart-abc-123";
const selectDashboard = {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
createdBy: true,
};
const mockDashboard = {
id: mockDashboardId,
name: "Test Dashboard",
description: "A test dashboard",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
createdBy: mockUserId,
};
const makePrismaError = (code: string) =>
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
describe("Dashboard Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createDashboard", () => {
test("creates a dashboard successfully", async () => {
vi.mocked(prisma.dashboard.create).mockResolvedValue(mockDashboard as any);
const { createDashboard } = await import("./dashboards");
const result = await createDashboard({
projectId: mockProjectId,
name: "Test Dashboard",
description: "A test dashboard",
createdBy: mockUserId,
});
expect(result).toEqual(mockDashboard);
expect(prisma.dashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard",
description: "A test dashboard",
projectId: mockProjectId,
createdBy: mockUserId,
},
select: selectDashboard,
});
});
test("creates a dashboard without description", async () => {
const dashboardNoDesc = { ...mockDashboard, description: undefined };
vi.mocked(prisma.dashboard.create).mockResolvedValue(dashboardNoDesc as any);
const { createDashboard } = await import("./dashboards");
const result = await createDashboard({
projectId: mockProjectId,
name: "Test Dashboard",
createdBy: mockUserId,
});
expect(result).toEqual(dashboardNoDesc);
expect(prisma.dashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard",
description: undefined,
projectId: mockProjectId,
createdBy: mockUserId,
},
select: selectDashboard,
});
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.dashboard.create).mockRejectedValue(
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
);
const { createDashboard } = await import("./dashboards");
await expect(
createDashboard({
projectId: mockProjectId,
name: "Duplicate",
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
test("throws DatabaseError on other Prisma errors", async () => {
vi.mocked(prisma.dashboard.create).mockRejectedValue(makePrismaError("P9999"));
const { createDashboard } = await import("./dashboards");
await expect(
createDashboard({
projectId: mockProjectId,
name: "Test",
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("updateDashboard", () => {
test("updates a dashboard successfully", async () => {
const updatedDashboard = { ...mockDashboard, name: "Updated Dashboard" };
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.update.mockResolvedValue(updatedDashboard);
const { updateDashboard } = await import("./dashboards");
const result = await updateDashboard(mockDashboardId, mockProjectId, { name: "Updated Dashboard" });
expect(result).toEqual({ dashboard: mockDashboard, updatedDashboard });
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
select: selectDashboard,
});
expect(mockTxDashboard.update).toHaveBeenCalledWith({
where: { id: mockDashboardId },
data: { name: "Updated Dashboard", description: undefined },
select: selectDashboard,
});
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { updateDashboard } = await import("./dashboards");
await expect(
updateDashboard(mockDashboardId, mockProjectId, { name: "Updated" })
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { updateDashboard } = await import("./dashboards");
await expect(
updateDashboard(mockDashboardId, mockProjectId, { name: "Taken Name" })
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
describe("deleteDashboard", () => {
test("deletes a dashboard successfully", async () => {
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.delete.mockResolvedValue(undefined);
const { deleteDashboard } = await import("./dashboards");
const result = await deleteDashboard(mockDashboardId, mockProjectId);
expect(result).toEqual(mockDashboard);
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
select: selectDashboard,
});
expect(mockTxDashboard.delete).toHaveBeenCalledWith({ where: { id: mockDashboardId } });
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { deleteDashboard } = await import("./dashboards");
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.delete).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { deleteDashboard } = await import("./dashboards");
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("duplicateDashboard", () => {
const mockWidgets = [
{
id: "widget-1",
chartId: mockChartId,
title: "Widget 1",
layout: { x: 0, y: 0, w: 4, h: 3 },
order: 0,
},
{
id: "widget-2",
chartId: "chart-2",
title: null,
layout: { x: 4, y: 0, w: 4, h: 3 },
order: 1,
},
];
const sourceDashboard = {
...mockDashboard,
widgets: mockWidgets,
};
const duplicatedDashboard = {
...mockDashboard,
id: "dashboard-new-123",
name: "Test Dashboard (copy)",
};
test("duplicates a dashboard with all widgets", async () => {
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceDashboard).mockResolvedValueOnce(null);
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
const { duplicateDashboard } = await import("./dashboards");
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
expect(result).toEqual(duplicatedDashboard);
expect(mockTxDashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard (copy)",
description: mockDashboard.description,
projectId: mockProjectId,
createdBy: mockUserId,
widgets: {
create: [
{
chartId: mockChartId,
title: "Widget 1",
layout: { x: 0, y: 0, w: 4, h: 3 },
order: 0,
},
{
chartId: "chart-2",
title: null,
layout: { x: 4, y: 0, w: 4, h: 3 },
order: 1,
},
],
},
},
select: selectDashboard,
});
});
test("duplicates a dashboard with no widgets", async () => {
const sourceNoWidgets = { ...mockDashboard, widgets: [] };
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceNoWidgets).mockResolvedValueOnce(null);
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
const { duplicateDashboard } = await import("./dashboards");
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
expect(result).toEqual(duplicatedDashboard);
expect(mockTxDashboard.create).toHaveBeenCalledWith({
data: expect.objectContaining({
widgets: { create: [] },
}),
select: selectDashboard,
});
});
test("increments copy suffix when name already exists", async () => {
const existingCopy = { id: "existing", name: "Test Dashboard (copy)" };
mockTxDashboard.findFirst
.mockResolvedValueOnce(sourceDashboard)
.mockResolvedValueOnce(existingCopy)
.mockResolvedValueOnce(null);
mockTxDashboard.create.mockResolvedValue({
...duplicatedDashboard,
name: "Test Dashboard (copy) 2",
});
const { duplicateDashboard } = await import("./dashboards");
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
expect(result.name).toBe("Test Dashboard (copy) 2");
expect(mockTxDashboard.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Dashboard (copy) 2" }),
select: selectDashboard,
});
});
test("throws ResourceNotFoundError when source dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { duplicateDashboard } = await import("./dashboards");
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.create).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { duplicateDashboard } = await import("./dashboards");
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getDashboard", () => {
test("returns a dashboard with widgets", async () => {
const dashboardWithWidgets = {
...mockDashboard,
widgets: [
{
id: "widget-1",
order: 0,
chart: { id: mockChartId, name: "Chart 1", type: "bar" },
},
],
};
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(dashboardWithWidgets as any);
const { getDashboard } = await import("./dashboards");
const result = await getDashboard(mockDashboardId, mockProjectId);
expect(result).toEqual(dashboardWithWidgets);
expect(prisma.dashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: expect.objectContaining({ id: true, name: true, type: true }),
},
},
},
},
});
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(null);
const { getDashboard } = await import("./dashboards");
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.dashboard.findFirst).mockRejectedValue(makePrismaError("P9999"));
const { getDashboard } = await import("./dashboards");
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getDashboards", () => {
test("returns all dashboards for a project with creator", async () => {
const dashboards = [
{ ...mockDashboard, creator: { name: "Alice" }, _count: { widgets: 3 } },
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", creator: null, _count: { widgets: 0 } },
];
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
const { getDashboards } = await import("./dashboards");
const result = await getDashboards(mockProjectId);
expect(result).toEqual(dashboards);
expect(prisma.dashboard.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
id: true,
name: true,
creator: { select: { name: true } },
_count: { select: { widgets: true } },
}),
});
});
test("returns empty array when no dashboards exist", async () => {
vi.mocked(prisma.dashboard.findMany).mockResolvedValue([]);
const { getDashboards } = await import("./dashboards");
const result = await getDashboards(mockProjectId);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.dashboard.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getDashboards } = await import("./dashboards");
await expect(getDashboards(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("addChartToDashboard", () => {
const mockLayout = { x: 0, y: 0, w: 4, h: 3 };
const mockWidget = {
id: "widget-abc-123",
dashboardId: mockDashboardId,
chartId: mockChartId,
title: "My Widget",
layout: mockLayout,
order: 0,
};
test("adds a chart to a dashboard as the first widget", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
mockTxWidget.create.mockResolvedValue(mockWidget);
const { addChartToDashboard } = await import("./dashboards");
const result = await addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
title: "My Widget",
layout: mockLayout,
});
expect(result).toEqual(mockWidget);
expect(mockTxWidget.create).toHaveBeenCalledWith({
data: {
dashboardId: mockDashboardId,
chartId: mockChartId,
title: "My Widget",
layout: mockLayout,
order: 0,
},
});
});
test("appends widget after existing widgets", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: 2 } });
mockTxWidget.create.mockResolvedValue({ ...mockWidget, order: 3 });
const { addChartToDashboard } = await import("./dashboards");
await addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
});
expect(mockTxWidget.create).toHaveBeenCalledWith({
data: expect.objectContaining({ order: 3 }),
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(null);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
mockTxWidget.create.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
});
@@ -1,295 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { selectChart } from "@/modules/ee/analysis/charts/lib/charts";
import {
TAddWidgetInput,
TDashboard,
TDashboardCreateInput,
TDashboardUpdateInput,
TDashboardWithCount,
ZAddWidgetInput,
ZDashboardCreateInput,
ZDashboardUpdateInput,
} from "@/modules/ee/analysis/types/analysis";
const MAX_NAME_ATTEMPTS = 5;
const selectDashboard = {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
createdBy: true,
} as const;
export const createDashboard = async (data: TDashboardCreateInput): Promise<TDashboard> => {
validateInputs([data, ZDashboardCreateInput]);
try {
return await prisma.dashboard.create({
data: {
name: data.name,
description: data.description,
projectId: data.projectId,
createdBy: data.createdBy,
},
select: selectDashboard,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A dashboard with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateDashboard = async (
dashboardId: string,
projectId: string,
data: TDashboardUpdateInput
): Promise<{ dashboard: TDashboard; updatedDashboard: TDashboard }> => {
validateInputs([dashboardId, ZId], [projectId, ZId], [data, ZDashboardUpdateInput]);
try {
return await prisma.$transaction(async (tx) => {
const dashboard = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
select: selectDashboard,
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
const updatedDashboard = await tx.dashboard.update({
where: { id: dashboardId },
data: {
name: data.name,
description: data.description,
},
select: selectDashboard,
});
return { dashboard, updatedDashboard };
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A dashboard with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteDashboard = async (dashboardId: string, projectId: string): Promise<TDashboard> => {
validateInputs([dashboardId, ZId], [projectId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const dashboard = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
select: selectDashboard,
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
await tx.dashboard.delete({
where: { id: dashboardId },
});
return dashboard;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getDashboard = async (dashboardId: string, projectId: string) => {
validateInputs([dashboardId, ZId], [projectId, ZId]);
try {
const dashboard = await prisma.dashboard.findFirst({
where: { id: dashboardId, projectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: selectChart,
},
},
},
},
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
return dashboard;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getDashboards = async (projectId: string): Promise<TDashboardWithCount[]> => {
validateInputs([projectId, ZId]);
try {
return await prisma.dashboard.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
...selectDashboard,
creator: { select: { name: true } },
_count: { select: { widgets: true } },
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const duplicateDashboard = async (
dashboardId: string,
projectId: string,
createdBy: string
): Promise<TDashboard> => {
validateInputs([dashboardId, ZId], [projectId, ZId], [createdBy, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const source = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
include: {
widgets: { orderBy: { order: "asc" } },
},
});
if (!source) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
const baseName = `${source.name} (copy)`;
let name = baseName;
let suffix = 1;
while (await tx.dashboard.findFirst({ where: { projectId, name } })) {
suffix++;
if (suffix > MAX_NAME_ATTEMPTS) {
name = `${baseName} ${suffix}`;
break;
}
name = `${baseName} ${suffix}`;
}
const newDashboard = await tx.dashboard.create({
data: {
name,
description: source.description,
projectId,
createdBy,
widgets: {
create: source.widgets.map((widget) => ({
chartId: widget.chartId,
title: widget.title,
layout: widget.layout ?? { x: 0, y: 0, w: 4, h: 3 },
order: widget.order,
})),
},
},
select: selectDashboard,
});
return newDashboard;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addChartToDashboard = async (data: TAddWidgetInput) => {
validateInputs([data, ZAddWidgetInput]);
try {
return await prisma.$transaction(
async (tx) => {
const [chart, dashboard] = await Promise.all([
tx.chart.findFirst({ where: { id: data.chartId, projectId: data.projectId } }),
tx.dashboard.findFirst({ where: { id: data.dashboardId, projectId: data.projectId } }),
]);
if (!chart) {
throw new ResourceNotFoundError("Chart", data.chartId);
}
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", data.dashboardId);
}
const maxOrder = await tx.dashboardWidget.aggregate({
where: { dashboardId: data.dashboardId },
_max: { order: true },
});
return tx.dashboardWidget.create({
data: {
dashboardId: data.dashboardId,
chartId: data.chartId,
title: data.title,
layout: data.layout,
order: (maxOrder._max.order ?? -1) + 1,
},
});
},
{ isolationLevel: "Serializable" }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("This chart is already on the dashboard");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -1,65 +0,0 @@
import { Delay } from "@suspensive/react";
import { Suspense, use } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TDashboardWithCount } from "../../types/analysis";
import { CreateDashboardButton } from "../components/create-dashboard-button";
import { DashboardsListSkeleton } from "../components/dashboards-list-skeleton";
import { DashboardsTable } from "../components/dashboards-table";
import { getDashboards } from "../lib/dashboards";
interface DashboardsListContentProps {
dashboardsPromise: Promise<TDashboardWithCount[]>;
environmentId: string;
isReadOnly: boolean;
}
const DashboardsListContent = ({
dashboardsPromise,
environmentId,
isReadOnly,
}: Readonly<DashboardsListContentProps>) => {
const dashboards = use(dashboardsPromise);
return <DashboardsTable dashboards={dashboards} environmentId={environmentId} isReadOnly={isReadOnly} />;
};
interface DashboardsListPageProps {
environmentId: string;
}
export const DashboardsListPage = async ({ environmentId }: Readonly<DashboardsListPageProps>) => {
const t = await getTranslate();
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
const dashboardsPromise = getDashboards(project.id);
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
environmentId={environmentId}
cta={isReadOnly ? undefined : <CreateDashboardButton environmentId={environmentId} />}>
<Suspense
fallback={
<Delay ms={200}>
<DashboardsListSkeleton
columnHeaders={[
t("common.title"),
t("common.charts"),
t("common.created_by"),
t("common.created"),
t("common.updated"),
]}
/>
</Delay>
}>
<DashboardsListContent
dashboardsPromise={dashboardsPromise}
environmentId={environmentId}
isReadOnly={isReadOnly}
/>
</Suspense>
</AnalysisPageLayout>
);
};
@@ -1,73 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mockGetEnvironment = vi.fn();
const mockGetOrganizationIdFromProjectId = vi.fn();
const mockCheckAuthorizationUpdated = vi.fn();
vi.mock("@/lib/environment/service", () => ({
getEnvironment: (...args: any[]) => mockGetEnvironment(...args),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: (...args: any[]) => mockGetOrganizationIdFromProjectId(...args),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: (...args: any[]) => mockCheckAuthorizationUpdated(...args),
}));
const mockUserId = "user-abc-123";
const mockEnvironmentId = "env-abc-123";
const mockProjectId = "project-abc-123";
const mockOrganizationId = "org-abc-123";
describe("checkProjectAccess", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns organizationId and projectId on successful access check", async () => {
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
mockCheckAuthorizationUpdated.mockResolvedValue(undefined);
const { checkProjectAccess } = await import("./access");
const result = await checkProjectAccess(mockUserId, mockEnvironmentId, "readWrite");
expect(result).toEqual({ organizationId: mockOrganizationId, projectId: mockProjectId });
expect(mockGetEnvironment).toHaveBeenCalledWith(mockEnvironmentId);
expect(mockGetOrganizationIdFromProjectId).toHaveBeenCalledWith(mockProjectId);
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
userId: mockUserId,
organizationId: mockOrganizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission: "readWrite", projectId: mockProjectId },
],
});
});
test("throws ResourceNotFoundError when environment is not found", async () => {
mockGetEnvironment.mockResolvedValue(null);
const { checkProjectAccess } = await import("./access");
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "read")).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "environment",
resourceId: mockEnvironmentId,
});
expect(mockGetOrganizationIdFromProjectId).not.toHaveBeenCalled();
expect(mockCheckAuthorizationUpdated).not.toHaveBeenCalled();
});
test("propagates authorization errors from checkAuthorizationUpdated", async () => {
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
mockCheckAuthorizationUpdated.mockRejectedValue(new Error("Unauthorized"));
const { checkProjectAccess } = await import("./access");
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "manage")).rejects.toThrow("Unauthorized");
});
});
@@ -1,31 +0,0 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
export const checkProjectAccess = async (
userId: string,
environmentId: string,
minPermission: TTeamPermission
) => {
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new ResourceNotFoundError("environment", environmentId);
}
const projectId = environment.projectId;
const organizationId = await getOrganizationIdFromProjectId(projectId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission, projectId },
],
});
return { organizationId, projectId };
};
@@ -1,59 +0,0 @@
/**
* Generates a system prompt for the AI chart query LLM.
* Derived from FEEDBACK_FIELDS to keep schema and prompt in sync.
*/
import {
DATE_PRESETS,
FEEDBACK_FIELDS,
FILTER_OPERATORS,
type FieldDefinition,
type MeasureDefinition,
} from "./schema-definition";
const CUBE_NAME = "FeedbackRecords";
function formatMeasure(m: MeasureDefinition): string {
const suffix = m.description ? ` (${m.description})` : "";
return `- ${m.id}: ${m.label}${suffix}`;
}
function formatDimension(d: FieldDefinition): string {
const suffix = d.description ? ` (${d.description})` : "";
return `- ${d.id}: ${d.label}${suffix}`;
}
function formatOperators(): string {
const lines = Object.entries(FILTER_OPERATORS).map(([type, ops]) => ` ${type}: ${ops.join(", ")}`);
return lines.join("\n");
}
export function generateSchemaContext(): string {
const measuresText = FEEDBACK_FIELDS.measures.map(formatMeasure).join("\n");
const dimensionsText = FEEDBACK_FIELDS.dimensions.map(formatDimension).join("\n");
const datePresetsText = DATE_PRESETS.map((p) => `"${p.value}"`).join(", ");
const operatorsText = formatOperators();
return `You are an expert at converting natural language questions into Cube.js analytics queries.
## Available schema
### Measures (use these measure IDs in the query)
${measuresText}
### Dimensions (use these dimension IDs in the query)
${dimensionsText}
### Time dimension
The time field is \`${CUBE_NAME}.collectedAt\`. Supported granularities: hour, day, week, month, quarter, year.
Date range presets: ${datePresetsText}
### Filter operators by field type
${operatorsText}
## Guidelines
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
- For time-based questions, add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\`, an appropriate granularity, and a dateRange preset or custom range.
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
- Filters must use the exact operator strings from the schema.`;
}
@@ -1,155 +0,0 @@
/**
* Query builder utility to construct Cube.js queries from chart builder state.
*/
import { TChartQuery, TCubeFilter, TMemberFilter, TTimeDimension } from "@formbricks/types/analysis";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
export interface CustomMeasure {
id?: string;
field: string;
aggregation: string;
alias?: string;
}
export type TFilterFieldType = "string" | "number" | "time";
export interface FilterRow {
field: string;
operator: TMemberFilter["operator"];
values: string[] | number[] | null;
}
export interface TimeDimensionConfig {
dimension: string;
granularity?: "second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year";
dateRange?: string | [Date, Date];
}
export interface ChartBuilderState {
chartType: TChartType | "";
selectedMeasures: string[];
customMeasures: CustomMeasure[];
selectedDimensions: string[];
filters: FilterRow[];
filterLogic: "and" | "or";
timeDimension: TimeDimensionConfig | null;
limit?: number;
orderBy?: { field: string; direction: "asc" | "desc" };
}
function buildMemberFilter(f: FilterRow): TMemberFilter {
const filter: TMemberFilter = {
member: f.field,
operator: f.operator,
};
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
filter.values = f.values.map(String);
}
return filter;
}
/**
* Build a Cube.js query from chart builder state.
*/
export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
const query: TChartQuery = {
measures: [...config.selectedMeasures],
};
if (config.selectedDimensions.length > 0) {
query.dimensions = config.selectedDimensions;
}
if (config.timeDimension) {
const timeDim: TTimeDimension = {
dimension: config.timeDimension.dimension,
};
if (config.timeDimension.granularity) {
timeDim.granularity = config.timeDimension.granularity;
}
if (typeof config.timeDimension.dateRange === "string") {
timeDim.dateRange = config.timeDimension.dateRange;
} else if (Array.isArray(config.timeDimension.dateRange)) {
const [startDate, endDate] = config.timeDimension.dateRange;
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
}
query.timeDimensions = [timeDim];
}
if (config.filters.length > 0) {
const memberFilters = config.filters.map(buildMemberFilter);
if (config.filterLogic === "or") {
query.filters = [{ or: memberFilters } as TCubeFilter];
} else {
query.filters = memberFilters;
}
}
return query;
}
function isMemberFilter(f: TCubeFilter): f is TMemberFilter {
return "member" in f;
}
/**
* Parse a Cube.js query back into ChartBuilderState.
* Preserves absent granularity / dateRange instead of injecting defaults.
*/
export function parseQueryToState(query: TChartQuery, chartType?: TChartType): Partial<ChartBuilderState> {
const state: Partial<ChartBuilderState> = {
chartType: chartType || "",
selectedMeasures: query.measures || [],
customMeasures: [],
selectedDimensions: query.dimensions || [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
if (query.filters && query.filters.length > 0) {
const first = query.filters[0];
if (!isMemberFilter(first) && "or" in first && query.filters.length === 1) {
state.filterLogic = "or";
state.filters = (first.or as TMemberFilter[]).map((f) => ({
field: f.member,
operator: f.operator,
values: f.values || null,
}));
} else {
state.filterLogic = "and";
state.filters = query.filters.filter(isMemberFilter).map((f) => ({
field: f.member,
operator: f.operator,
values: f.values || null,
}));
}
}
if (query.timeDimensions && query.timeDimensions.length > 0) {
const timeDim = query.timeDimensions[0];
const config: TimeDimensionConfig = {
dimension: timeDim.dimension,
};
if (timeDim.granularity) {
config.granularity = timeDim.granularity;
}
if (timeDim.dateRange) {
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
}
state.timeDimension = config;
}
return state;
}
@@ -1,197 +0,0 @@
/**
* Schema definitions for FeedbackRecords fields.
* Used by the advanced chart builder to provide field metadata and operators.
*/
export interface FieldDefinition {
id: string;
label: string;
type: "string" | "number" | "time";
description?: string;
}
export interface MeasureDefinition {
id: string;
label: string;
type: "count" | "number";
description?: string;
}
export const FEEDBACK_FIELDS = {
dimensions: [
{
id: "FeedbackRecords.sentiment",
label: "Sentiment",
type: "string",
description: "Sentiment extracted from feedback",
},
{
id: "FeedbackRecords.sourceType",
label: "Source Type",
type: "string",
description: "Source type of the feedback (e.g., nps_campaign, survey)",
},
{
id: "FeedbackRecords.sourceName",
label: "Source Name",
type: "string",
description: "Human-readable name of the source",
},
{
id: "FeedbackRecords.fieldType",
label: "Field Type",
type: "string",
description: "Type of feedback field (e.g., nps, text, rating)",
},
{
id: "FeedbackRecords.emotion",
label: "Emotion",
type: "string",
description: "Emotion extracted from metadata JSONB field",
},
{
id: "FeedbackRecords.userIdentifier",
label: "User Identifier",
type: "string",
description: "Identifier of the user who provided feedback",
},
{
id: "FeedbackRecords.responseId",
label: "Response ID",
type: "string",
description: "Unique identifier linking related feedback records",
},
{
id: "FeedbackRecords.npsValue",
label: "NPS Value",
type: "number",
description: "Raw NPS score value (0-10)",
},
{
id: "FeedbackRecords.collectedAt",
label: "Collected At",
type: "time",
description: "Timestamp when the feedback was collected",
},
{
id: "TopicsUnnested.topic",
label: "Topic",
type: "string",
description: "Individual topic from the topics array",
},
] as FieldDefinition[],
measures: [
{
id: "FeedbackRecords.count",
label: "Count",
type: "count",
description: "Total number of feedback responses",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of promoters (NPS score 9-10)",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of detractors (NPS score 0-6)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of passives (NPS score 7-8)",
},
{
id: "FeedbackRecords.npsScore",
label: "NPS Score",
type: "number",
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
},
{
id: "FeedbackRecords.averageScore",
label: "Average Score",
type: "number",
description: "Average NPS score",
},
] as MeasureDefinition[],
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
};
export type FilterOperator =
| "equals"
| "notEquals"
| "contains"
| "notContains"
| "set"
| "notSet"
| "gt"
| "gte"
| "lt"
| "lte";
export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
};
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
export type TimeGranularity = (typeof TIME_GRANULARITIES)[number];
export const DATE_PRESETS = [
{ label: "Today", value: "today" },
{ label: "Yesterday", value: "yesterday" },
{ label: "Last 7 days", value: "last 7 days" },
{ label: "Last 30 days", value: "last 30 days" },
{ label: "This month", value: "this month" },
{ label: "Last month", value: "last month" },
{ label: "This quarter", value: "this quarter" },
{ label: "This year", value: "this year" },
] as const;
/**
* Get filter operators for a given field type.
*/
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
}
/**
* Get field definition by ID.
*/
export function getFieldById(id: string): FieldDefinition | MeasureDefinition | undefined {
const dimension = FEEDBACK_FIELDS.dimensions.find((d) => d.id === id);
if (dimension) return dimension;
return FEEDBACK_FIELDS.measures.find((m) => m.id === id);
}
const TIME_GRANULARITY_LABELS: Record<string, string> = {
hour: "Hour",
day: "Day",
week: "Week",
month: "Month",
quarter: "Quarter",
year: "Year",
};
/**
* Format a Cube.js column key for display (e.g. FeedbackRecords.collectedAt.day "Day").
*/
export function formatCubeColumnHeader(key: string): string {
const granularity = TIME_GRANULARITIES.find((g) => key.endsWith(`.${g}`));
if (granularity && TIME_GRANULARITY_LABELS[granularity]) {
return TIME_GRANULARITY_LABELS[granularity];
}
const field = getFieldById(key);
if (field) return field.label;
const lastSegment = key.split(".").pop() ?? key;
return lastSegment
.replaceAll(/([A-Z])/g, " $1")
.replace(/^./, (s) => s.toUpperCase())
.trim();
}
@@ -1,109 +0,0 @@
import { z } from "zod";
import { ZChartConfig, ZChartQuery, ZWidgetLayout } from "@formbricks/types/analysis";
import { ZId } from "@formbricks/types/common";
export const CHART_TYPE_IDS = ["area", "bar", "line", "pie", "big_number"] as const;
export const ZChartType = z.enum(CHART_TYPE_IDS);
export type TChartType = z.infer<typeof ZChartType>;
// ── Chart input schemas ─────────────────────────────────────────────────────
export const ZChartCreateInput = z.object({
projectId: ZId,
name: z.string().min(1),
type: ZChartType,
query: ZChartQuery,
config: ZChartConfig,
createdBy: ZId,
});
export type TChartCreateInput = z.infer<typeof ZChartCreateInput>;
export const ZChartUpdateInput = z.object({
name: z.string().min(1).optional(),
type: ZChartType.optional(),
query: ZChartQuery.optional(),
config: ZChartConfig.optional(),
});
export type TChartUpdateInput = z.infer<typeof ZChartUpdateInput>;
// ── Chart output type (matches selectChart) ─────────────────────────────────
export const ZChart = z.object({
id: ZId,
name: z.string(),
type: z.string(),
query: ZChartQuery,
config: ZChartConfig,
createdAt: z.date(),
updatedAt: z.date(),
});
export type TChart = z.infer<typeof ZChart>;
export const ZChartWithCreator = ZChart.extend({
creator: z
.object({
name: z.string().nullable(),
})
.nullable(),
});
export type TChartWithCreator = z.infer<typeof ZChartWithCreator>;
export const ZChartWithWidgets = ZChart.extend({
widgets: z.array(z.object({ dashboardId: ZId })),
});
export type TChartWithWidgets = z.infer<typeof ZChartWithWidgets>;
// ── Dashboard input schemas ─────────────────────────────────────────────────
export const ZDashboardCreateInput = z.object({
projectId: ZId,
name: z.string().min(1),
description: z.string().optional(),
createdBy: ZId,
});
export type TDashboardCreateInput = z.infer<typeof ZDashboardCreateInput>;
export const ZDashboardUpdateInput = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
});
export type TDashboardUpdateInput = z.infer<typeof ZDashboardUpdateInput>;
// ── Dashboard output type (matches selectDashboard) ─────────────────────────
export type TDashboard = {
id: string;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
};
export type TDashboardWithCount = TDashboard & {
creator: { name: string } | null;
_count: { widgets: number };
};
// ── Widget input schema ─────────────────────────────────────────────────────
export const ZAddWidgetInput = z.object({
dashboardId: ZId,
chartId: ZId,
projectId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout,
});
export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;
// ── Charts UI (query execution, AI response) ─────────────────────────────────
/** Row from Cube.js tablePivot - keys are measure/dimension names, values are primitives */
export type TChartDataRow = Record<string, string | number | null | boolean | undefined>;
export interface AnalyticsResponse {
query: z.infer<typeof ZChartQuery>;
chartType: TChartType;
data?: TChartDataRow[];
error?: string;
}
@@ -229,49 +229,4 @@ describe("withAuditLogging", () => {
// Reset for other tests; clearAllMockHandles will also do this in the next beforeEach
if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = true;
});
test("resolves targetId for chart target type", async () => {
const chartCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, chartId: "chart-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "chart", handlerImpl);
await wrapped({ ctx: chartCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("chart");
expect(callArgs.target.id).toBe("chart-1");
});
test("resolves targetId for dashboard target type", async () => {
const dashCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardId: "dash-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "dashboard", handlerImpl);
await wrapped({ ctx: dashCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("dashboard");
expect(callArgs.target.id).toBe("dash-1");
});
test("resolves targetId for dashboardWidget target type", async () => {
const widgetCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardWidgetId: "widget-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "dashboardWidget", handlerImpl);
await wrapped({ ctx: widgetCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("dashboardWidget");
expect(callArgs.target.id).toBe("widget-1");
});
});
@@ -292,15 +292,6 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
case "quota":
targetId = auditLoggingCtx.quotaId;
break;
case "chart":
targetId = auditLoggingCtx.chartId;
break;
case "dashboard":
targetId = auditLoggingCtx.dashboardId;
break;
case "dashboardWidget":
targetId = auditLoggingCtx.dashboardWidgetId;
break;
default:
targetId = UNKNOWN_DATA;
break;
@@ -25,9 +25,6 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
"dashboard",
"dashboardWidget",
]);
export const ZAuditAction = z.enum([
"created",
@@ -99,7 +99,7 @@ export const ContactControlBar = ({
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
deleteWhat={t("common.person")}
onDelete={handleDeletePerson}
isDeleting={isDeletingPerson}
text={
@@ -66,7 +66,7 @@ export const DeleteContactButton = ({
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
deleteWhat={t("common.person")}
onDelete={handleDeletePerson}
isDeleting={isDeletingPerson}
text={
@@ -52,54 +52,41 @@ export const getPersonSegmentIds = async (
return [];
}
// Phase 1: Build all Prisma where clauses concurrently.
// This converts segment filters into where clauses without per-contact DB queries.
const segmentWithClauses = await Promise.all(
segments.map(async (segment) => {
const filters = segment.filters as TBaseFilters | null;
if (!filters || filters.length === 0) {
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
}
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment"
);
return { segmentId: segment.id, whereClause: null };
}
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
})
);
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
// Phase 1: Build WHERE clauses sequentially to avoid connection pool contention.
// segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks),
// so running all builds concurrently would saturate the pool.
const alwaysMatchIds: string[] = [];
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
for (const item of segmentWithClauses) {
if (item.whereClause === null) {
for (const segment of segments) {
const filters = segment.filters as TBaseFilters;
if (!filters?.length) {
alwaysMatchIds.push(segment.id);
continue;
}
if (Object.keys(item.whereClause).length === 0) {
alwaysMatchIds.push(item.segmentId);
} else {
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment, skipping"
);
continue;
}
dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause });
}
if (toCheck.length === 0) {
if (dbChecks.length === 0) {
return alwaysMatchIds;
}
// Phase 2: Batch all contact-match checks into a single DB transaction.
// Replaces N individual findFirst queries with one batched round-trip.
const batchResults = await prisma.$transaction(
toCheck.map(({ whereClause }) =>
// Phase 2: Execute all membership checks in a single transaction.
// Uses one connection instead of N concurrent ones, eliminating pool contention.
const txResults = await prisma.$transaction(
dbChecks.map(({ whereClause }) =>
prisma.contact.findFirst({
where: { id: contactId, ...whereClause },
select: { id: true },
@@ -107,17 +94,12 @@ export const getPersonSegmentIds = async (
)
);
// Phase 3: Collect matching segment IDs
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId);
return [...alwaysMatchIds, ...dbMatchIds];
return [...alwaysMatchIds, ...matchedIds];
} catch (error) {
logger.warn(
{
environmentId,
contactId,
error,
},
{ environmentId, contactId, error },
"Failed to get person segment IDs, returning empty array"
);
return [];
@@ -1,139 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TJsPersonState } from "@formbricks/types/js";
import { getPersonSegmentIds } from "./segments";
import { getUserState } from "./user-state";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUniqueOrThrow: vi.fn(),
},
},
}));
vi.mock("./segments", () => ({
getPersonSegmentIds: vi.fn(),
}));
const mockEnvironmentId = "test-environment-id";
const mockUserId = "test-user-id";
const mockContactId = "test-contact-id";
const mockDevice = "desktop";
describe("getUserState", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should return user state with empty responses and displays", async () => {
const mockContactData = {
id: mockContactId,
responses: [],
displays: [],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: mockContactId },
select: {
id: true,
responses: {
select: { surveyId: true },
},
displays: {
select: { surveyId: true, createdAt: true },
orderBy: { createdAt: "desc" },
},
},
});
expect(getPersonSegmentIds).toHaveBeenCalledWith(
mockEnvironmentId,
mockContactId,
mockUserId,
mockDevice
);
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: ["segment1"],
displays: [],
responses: [],
lastDisplayAt: null,
});
});
test("should return user state with responses and displays, and sort displays by createdAt", async () => {
const mockDate1 = new Date("2023-01-01T00:00:00.000Z");
const mockDate2 = new Date("2023-01-02T00:00:00.000Z");
const mockContactData = {
id: mockContactId,
responses: [{ surveyId: "survey1" }, { surveyId: "survey2" }],
displays: [
{ surveyId: "survey4", createdAt: mockDate2 }, // most recent (already sorted by desc)
{ surveyId: "survey3", createdAt: mockDate1 },
],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: ["segment2", "segment3"],
displays: [
{ surveyId: "survey4", createdAt: mockDate2 },
{ surveyId: "survey3", createdAt: mockDate1 },
],
responses: ["survey1", "survey2"],
lastDisplayAt: mockDate2,
});
});
test("should handle empty arrays from prisma", async () => {
// This case tests with proper empty arrays instead of null
const mockContactData = {
id: mockContactId,
responses: [],
displays: [],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: [],
displays: [],
responses: [],
lastDisplayAt: null,
});
});
});
@@ -1,80 +0,0 @@
import { prisma } from "@formbricks/database";
import { TJsPersonState } from "@formbricks/types/js";
import { getPersonSegmentIds } from "./segments";
/**
* Optimized single query to get all user state data
* Replaces multiple separate queries with one efficient query
*/
const getUserStateDataOptimized = async (contactId: string) => {
return prisma.contact.findUniqueOrThrow({
where: { id: contactId },
select: {
id: true,
responses: {
select: { surveyId: true },
},
displays: {
select: {
surveyId: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
});
};
/**
* Optimized user state fetcher without caching
* Uses single database query and efficient data processing
* NO CACHING - user state changes frequently with contact updates
*
* @param environmentId - The environment id
* @param userId - The user id
* @param device - The device type
* @returns The person state
* @throws {ValidationError} - If the input is invalid
* @throws {ResourceNotFoundError} - If the environment or organization is not found
*/
export const getUserState = async ({
environmentId,
userId,
contactId,
device,
}: {
environmentId: string;
userId: string;
contactId: string;
device: "phone" | "desktop";
}): Promise<TJsPersonState["data"]> => {
// Single optimized query for all contact data
const contactData = await getUserStateDataOptimized(contactId);
// Get segments using Prisma-based evaluation (no attributes needed - fetched from DB)
const segments = await getPersonSegmentIds(environmentId, contactId, userId, device);
// Process displays efficiently
const displays = (contactData.displays ?? []).map((display) => ({
surveyId: display.surveyId,
createdAt: display.createdAt,
}));
// Get latest display date
const lastDisplayAt =
contactData.displays && contactData.displays.length > 0 ? contactData.displays[0].createdAt : null;
// Process responses efficiently
const responses = (contactData.responses ?? []).map((response) => response.surveyId);
const userState: TJsPersonState["data"] = {
contactId,
userId,
segments,
displays,
responses,
lastDisplayAt,
};
return userState;
};
@@ -75,7 +75,7 @@ const createBaseFilter = (
connector: "and" | "or" | null = "and",
id?: string
): TBaseFilter => ({
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${crypto.randomUUID()}`), // Use filter ID or UUID for group
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${Math.random()}`), // Use filter ID or random for group
connector,
resource,
});
@@ -168,7 +168,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
<DeleteDialog
open={isDeleteMemberModalOpen}
setOpen={setDeleteMemberModalOpen}
deleteWhat={`${memberName} ${t("environments.settings.general.from_your_organization")}`}
deleteWhat={t("environments.settings.general.from_your_organization", { memberName })}
onDelete={handleDeleteMember}
isDeleting={isDeleting}
text={t("environments.settings.general.delete_member_confirmation")}
@@ -89,7 +89,7 @@ export const DeleteProjectRender = ({
)}
<DeleteDialog
deleteWhat="Workspace"
deleteWhat={t("environments.settings.domain.workspace")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteProject}
@@ -1,5 +1,5 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -7,7 +7,6 @@ import { ProjectConfigNavigation } from "@/modules/projects/settings/components/
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { CustomScriptsForm } from "./components/custom-scripts-form";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
@@ -59,9 +58,6 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
</SettingsCard>
<div className="space-y-2">
<IdBadge id={project.id} label={t("common.workspace_id")} variant="column" />
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
)}
</div>
</PageContentWrapper>
);
@@ -52,7 +52,7 @@ export const ProjectLookSettingsLoading = () => {
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.form_styling")}
{t("environments.surveys.edit.survey_styling")}
</h2>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.style_the_question_texts_descriptions_and_input_fields")}
@@ -149,7 +149,7 @@ export const ProjectLookSettingsLoading = () => {
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
</div>
</div>
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("common.loading")}
</Button>
</div>
@@ -159,7 +159,7 @@ export const ProjectLookSettingsLoading = () => {
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">
<div className="w-full items-center">
<div className="pointer-events-none flex cursor-not-allowed items-center space-x-2 select-none">
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
<Switch id="signature" checked={false} />
<Label htmlFor="signature">{t("environments.workspace.look.show_powered_by_formbricks")}</Label>
</div>
@@ -67,7 +67,7 @@ export const FormStylingSettings = ({
<div>
<p className={cn("font-semibold text-slate-800", isSettingsPage ? "text-sm" : "text-base")}>
{t("environments.surveys.edit.form_styling")}
{t("environments.surveys.edit.survey_styling")}
</p>
<p className={cn("mt-1 text-slate-500", isSettingsPage ? "text-xs" : "text-sm")}>
{t("environments.surveys.edit.style_the_question_texts_descriptions_and_input_fields")}
@@ -246,7 +246,7 @@ export const SurveyDropDownMenu = ({
{!isSurveyCreationDeletionDisabled && (
<DeleteDialog
deleteWhat="Survey"
deleteWhat={t("common.survey")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey.id)}
@@ -1,319 +0,0 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { ResponsiveContainer, Tooltip } from "recharts";
import { cn } from "@/lib/cn";
// Format: { THEME_NAME: CSS_VARIABLE }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replaceAll(":", "")}`;
const contextValue = React.useMemo(() => ({ config }), [config]);
return (
<ChartContext.Provider value={contextValue}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<ResponsiveContainer>{children}</ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "ChartContainer";
const ChartTooltip = Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof Tooltip> &
React.ComponentProps<"div"> & {
active?: boolean;
payload?: unknown[];
label?: string;
labelFormatter?: (value: unknown, payload: unknown[]) => React.ReactNode;
labelClassName?: string;
formatter?: (
value: unknown,
name: string,
item: unknown,
index: number,
payload: unknown[]
) => React.ReactNode;
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || (item as { dataKey?: string; name?: string }).dataKey || (item as { name?: string }).name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string" ? config[label]?.label || label : itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
const payloadArray: unknown[] = Array.isArray(payload) ? payload : [];
return (
<div
ref={ref}
className={cn(
"border-border/50 grid min-w-[8rem] items-start gap-1.5 rounded-lg border bg-white px-2.5 py-1.5 text-xs shadow-xl dark:bg-gray-950",
className
)}>
{!nestLabel && tooltipLabel}
<div className="grid gap-1.5">
{payloadArray.map((item, index) => {
const itemObj = item as {
dataKey?: string;
name?: string;
value?: number;
payload?: { fill?: string };
};
const key = `${nameKey || itemObj.name || itemObj.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || itemObj.payload?.fill || (item as { color?: string }).color;
return (
<div
key={itemObj.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}>
{formatter && itemObj?.value !== undefined && itemObj.name ? (
formatter(itemObj.value, itemObj.name, item, index, payloadArray)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || itemObj.name}</span>
</div>
{itemObj.value !== undefined && (
<span className="text-foreground font-mono font-medium tabular-nums">
{itemObj.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
payload?: unknown[];
verticalAlign?: "top" | "bottom" | "middle";
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}>
{payload.map((item) => {
const itemObj = item as { dataKey?: string; value?: unknown; color?: string };
const key = `${nameKey || itemObj.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={String(itemObj.value)}
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: itemObj.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof (payload as { payload?: unknown }).payload === "object" && payload !== null
? (payload as { payload: Record<string, unknown> }).payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof (payload as Record<string, unknown>)[key] === "string") {
configLabelKey = (payload as Record<string, unknown>)[key] as string;
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key] === "string") {
configLabelKey = payloadPayload[key];
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
const ChartStyle = ({ id, config }: Readonly<{ id: string; config: ChartConfig }>) => {
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

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