mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 19:39:00 -05:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61518b2c0e | |||
| fb8ee60228 | |||
| 3a802810e3 | |||
| c5722c05ae | |||
| b65feca0a5 | |||
| fbbf917093 | |||
| f451658eaf | |||
| b42c3ff97c | |||
| aa9ccd70a0 | |||
| fcfb31a1d3 | |||
| 1c2cf0390b | |||
| 8aae875e55 | |||
| 058fca5cef | |||
| 74e39b83fd | |||
| 4e44841e63 | |||
| 1b625f2bb1 | |||
| 781be1c71a | |||
| 27ac42a81f | |||
| ef73a1df85 | |||
| d670d5de31 | |||
| dc454b80ec | |||
| 0340384384 | |||
| 74c47ed840 | |||
| b61ce96fa6 | |||
| f62ceaa7ae | |||
| 5ccb4af249 | |||
| 62aa186a81 | |||
| f35e54f21d | |||
| f49f40610b | |||
| 9e754bad9c | |||
| 4dcf6fda40 | |||
| 1b8ccd7199 | |||
| 4f9088559f | |||
| 18550f1d11 | |||
| 881cd31f74 | |||
| e00405dca2 |
@@ -229,5 +229,24 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
|
||||
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
|
||||
# CUBEJS_DB_HOST=formbricks_hub_postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=hub
|
||||
# CUBEJS_DB_USER=formbricks
|
||||
# CUBEJS_DB_PASS=formbricks_dev
|
||||
#
|
||||
# Alternative (when not on same Docker network): host.docker.internal and port 5433
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -101,9 +101,6 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <ChartsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
const DashboardDetailPage = async (props: Readonly<{ params: Promise<{ dashboardId: string }> }>) => {
|
||||
const { dashboardId } = await props.params;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||
Dashboard detail for {dashboardId} will appear here.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardDetailPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <DashboardsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||
};
|
||||
|
||||
export default AnalysisPage;
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChartBar,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
@@ -114,6 +115,13 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/environments/${environment.id}/analysis`,
|
||||
icon: ChartBar,
|
||||
isActive: pathname?.includes("/analysis"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
@@ -188,7 +196,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
+1
-5
@@ -9,7 +9,6 @@ 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";
|
||||
@@ -82,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
@@ -56,7 +56,6 @@ 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) {
|
||||
|
||||
+38
-3
@@ -106,6 +106,7 @@ checksums:
|
||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||
@@ -122,6 +123,8 @@ checksums:
|
||||
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
|
||||
common/cancel: 2e2a849c2223911717de8caa2c71bade
|
||||
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
|
||||
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
|
||||
common/charts: 1da4564d89264c89de4ed28d7451b43e
|
||||
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
|
||||
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
|
||||
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
|
||||
@@ -151,6 +154,7 @@ checksums:
|
||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
||||
common/create: 757ccd28dd533ff3a933355273c1e32a
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -160,11 +164,12 @@ 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
|
||||
@@ -282,6 +287,7 @@ checksums:
|
||||
common/on: 1929bcf2fba8003c043b446a851bcb4f
|
||||
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
|
||||
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
|
||||
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
|
||||
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
|
||||
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
|
||||
common/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
@@ -581,6 +587,35 @@ checksums:
|
||||
environments/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
|
||||
environments/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
|
||||
environments/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
|
||||
environments/analysis/charts/action_coming_soon: ee2b0671e00972773210c5be5a9ccb89
|
||||
environments/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
|
||||
environments/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
|
||||
environments/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
|
||||
environments/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
|
||||
environments/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
|
||||
environments/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
|
||||
environments/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
|
||||
environments/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
|
||||
environments/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
|
||||
environments/analysis/charts/create_chart: ca7fdcc964e01f42ea9709924221edba
|
||||
environments/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
|
||||
environments/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
|
||||
environments/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
|
||||
environments/analysis/dashboards/create_dashboard: 9396aec1ea4a9b05ada94483655d1373
|
||||
environments/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
|
||||
environments/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
|
||||
environments/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
|
||||
environments/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
|
||||
environments/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
|
||||
environments/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
|
||||
environments/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
|
||||
environments/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
|
||||
environments/analysis/dashboards/description_optional: d5519551a79f18fc414dc127b773485f
|
||||
environments/analysis/dashboards/description_placeholder: 90a599e6b1695e2b026fb1300d1d5903
|
||||
environments/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
|
||||
environments/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
|
||||
environments/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
|
||||
environments/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
|
||||
environments/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
|
||||
environments/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
|
||||
environments/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
|
||||
@@ -1021,7 +1056,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: 9ebd6dcd79f7bfad3fea46ed2e3133d2
|
||||
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
|
||||
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
|
||||
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
|
||||
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
||||
@@ -1374,6 +1409,7 @@ 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
|
||||
@@ -1590,7 +1626,6 @@ 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
|
||||
|
||||
@@ -63,8 +63,7 @@ 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_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 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 GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
|
||||
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
|
||||
@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
};
|
||||
|
||||
export type ActionClientCtx = {
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
|
||||
"analysis": "Analyse",
|
||||
"and": "und",
|
||||
"and_response_limit_of": "und Antwortlimit von",
|
||||
"anonymous": "Anonym",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Unten rechts",
|
||||
"cancel": "Abbrechen",
|
||||
"centered_modal": "Zentriertes Modalfenster",
|
||||
"chart": "Diagramm",
|
||||
"charts": "Diagramme",
|
||||
"choices": "Entscheidungen",
|
||||
"choose_environment": "Umgebung auswählen",
|
||||
"choose_organization": "Organisation auswählen",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
|
||||
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
|
||||
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
|
||||
"create": "Erstellen",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
"create_survey": "Umfrage erstellen",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "An",
|
||||
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
"open_options": "Optionen öffnen",
|
||||
"option_id": "Option-ID",
|
||||
"option_ids": "Option-IDs",
|
||||
"optional": "Optional",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
|
||||
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Kommt bald",
|
||||
"chart_deleted_successfully": "Diagramm erfolgreich gelöscht",
|
||||
"chart_deletion_error": "Diagramm konnte nicht gelöscht werden",
|
||||
"chart_duplicated_successfully": "Diagramm erfolgreich dupliziert",
|
||||
"chart_duplication_error": "Diagramm konnte nicht dupliziert werden",
|
||||
"chart_type_area": "Flächendiagramm",
|
||||
"chart_type_bar": "Balkendiagramm",
|
||||
"chart_type_big_number": "Große Zahl",
|
||||
"chart_type_line": "Liniendiagramm",
|
||||
"chart_type_pie": "Kreisdiagramm",
|
||||
"create_chart": "Diagramm erstellen",
|
||||
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
|
||||
"no_charts_found": "Keine Diagramme gefunden.",
|
||||
"open_options": "Diagrammoptionen öffnen"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Dashboard erstellen",
|
||||
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
|
||||
"create_failed": "Dashboard konnte nicht erstellt werden",
|
||||
"create_success": "Dashboard erfolgreich erstellt!",
|
||||
"dashboard_name": "Dashboard-Name",
|
||||
"dashboard_name_placeholder": "Mein Dashboard",
|
||||
"delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_failed": "Dashboard konnte nicht gelöscht werden",
|
||||
"delete_success": "Dashboard erfolgreich gelöscht",
|
||||
"description_optional": "Beschreibung (optional)",
|
||||
"description_placeholder": "Dashboard-Beschreibung",
|
||||
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
|
||||
"duplicate_success": "Dashboard erfolgreich dupliziert!",
|
||||
"no_dashboards_found": "Keine Dashboards gefunden.",
|
||||
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Glückwunsch!",
|
||||
"connection_successful_message": "Gut gemacht! Wir sind verbunden.",
|
||||
@@ -1082,7 +1123,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": "{memberName} aus Ihrer Organisation",
|
||||
"from_your_organization": "von deiner Organisation",
|
||||
"invitation_sent_once_more": "Einladung nochmal gesendet.",
|
||||
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
|
||||
"invite_expires_on": "Einladung läuft ab am {date}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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",
|
||||
|
||||
+164
-3
@@ -133,6 +133,7 @@
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||
"analysis": "Analysis",
|
||||
"and": "And",
|
||||
"and_response_limit_of": "and response limit of",
|
||||
"anonymous": "Anonymous",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Bottom Right",
|
||||
"cancel": "Cancel",
|
||||
"centered_modal": "Centered Modal",
|
||||
"chart": "Chart",
|
||||
"charts": "Charts",
|
||||
"choices": "Choices",
|
||||
"choose_environment": "Choose environment",
|
||||
"choose_organization": "Choose organization",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||
"create": "Create",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_segment": "Create segment",
|
||||
"create_survey": "Create survey",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -240,6 +245,7 @@
|
||||
"hidden": "Hidden",
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide": "Hide",
|
||||
"hide_column": "Hide column",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
@@ -309,6 +315,7 @@
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
"open_options": "Open options",
|
||||
"option_id": "Option ID",
|
||||
"option_ids": "Option IDs",
|
||||
"optional": "Optional",
|
||||
@@ -451,6 +458,7 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Verified Email",
|
||||
"video": "Video",
|
||||
"view": "View",
|
||||
"warning": "Warning",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
|
||||
"webhook": "Webhook",
|
||||
@@ -614,6 +622,159 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
|
||||
"your_survey_would_not_be_shown": "Your survey would not be shown."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"AND": "AND",
|
||||
"OR": "OR",
|
||||
"add_chart_to_dashboard": "Add Chart to Dashboard",
|
||||
"add_chart_to_dashboard_description": "Select a dashboard to add this chart to. The chart will be saved automatically.",
|
||||
"add_custom_measure": "Add Custom Measure",
|
||||
"add_filter": "Add filter",
|
||||
"add_to_dashboard": "Add to Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
|
||||
"ai_query_placeholder": "e.g. How many users signed up last week?",
|
||||
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
|
||||
"ai_query_section_title": "Ask your data",
|
||||
"alias_optional": "Alias (optional)",
|
||||
"apply_changes": "Apply Changes",
|
||||
"chart": "Chart",
|
||||
"chart_added_to_dashboard": "Chart added to dashboard!",
|
||||
"chart_builder_choose_chart_type": "Choose chart type",
|
||||
"chart_data": "Chart Data",
|
||||
"chart_data_tab": "Data",
|
||||
"chart_deleted_successfully": "Chart deleted successfully",
|
||||
"chart_duplicated_successfully": "Chart duplicated successfully",
|
||||
"chart_duplication_error": "Failed to duplicate chart",
|
||||
"chart_name": "Chart Name",
|
||||
"chart_name_placeholder": "Chart name",
|
||||
"chart_preview": "Chart Preview",
|
||||
"chart_saved_successfully": "Chart saved successfully!",
|
||||
"chart_type_area": "Area Chart",
|
||||
"chart_type_bar": "Bar Chart",
|
||||
"chart_type_big_number": "Big Number",
|
||||
"chart_type_donut": "Donut Chart",
|
||||
"chart_type_line": "Line Chart",
|
||||
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
|
||||
"chart_type_pie": "Pie Chart",
|
||||
"chart_updated_successfully": "Chart updated successfully!",
|
||||
"configure_description": "Modify the chart type and other settings for this visualization.",
|
||||
"configure_title": "Configure Chart",
|
||||
"configure_type_label": "Chart Type",
|
||||
"contains": "contains",
|
||||
"create_chart": "Create Chart",
|
||||
"create_chart_description": "Use AI to generate a chart or build one manually.",
|
||||
"cube_js_query": "Cube.js Query",
|
||||
"custom_aggregations": "Custom Aggregations",
|
||||
"custom_aggregations_toggle_description": "Define custom metrics using aggregations (avg, sum, min, max, etc.) on numeric dimension fields.",
|
||||
"custom_range": "Custom Range",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard_select_placeholder": "Select a dashboard",
|
||||
"data_label": "Data",
|
||||
"date_range": "Date Range",
|
||||
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
|
||||
"dimensions": "Dimensions",
|
||||
"dimensions_toggle_description": "Group data by categories. Order matters for multi-dimensional charts.",
|
||||
"edit_chart_description": "View and edit your chart configuration.",
|
||||
"edit_chart_title": "Edit Chart",
|
||||
"enable_time_dimension": "Enable Time Dimension",
|
||||
"end_date": "End date",
|
||||
"enter_a_name_for_your_chart": "Enter a name for your chart to save it.",
|
||||
"enter_value": "Enter value",
|
||||
"equals": "equals",
|
||||
"failed_to_add_chart_to_dashboard": "Failed to add chart to dashboard",
|
||||
"failed_to_execute_query": "Failed to execute query",
|
||||
"failed_to_load_chart": "Failed to load chart",
|
||||
"failed_to_load_chart_data": "Failed to load chart data",
|
||||
"failed_to_save_chart": "Failed to save chart",
|
||||
"field": "Field",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Only include data that meets the following conditions.",
|
||||
"generating_chart": "Generating chart...",
|
||||
"granularity": "Granularity",
|
||||
"greater_than": "greater than",
|
||||
"greater_than_or_equal": "greater than or equal",
|
||||
"group_by": "Group By",
|
||||
"group_by_description": "Select dimensions to break down your data. The order matters for multi-dimensional charts.",
|
||||
"guide_button": "View field guide",
|
||||
"guide_chart_type": "Chart type",
|
||||
"guide_chart_type_desc": "How the data is visualized: Area, Bar, Line, Pie, or Big Number. Choose based on what you want to show (trends, comparisons, parts of a whole, etc.).",
|
||||
"guide_dimensions": "Dimensions (Group By)",
|
||||
"guide_dimensions_desc": "How you split or group the data. Each dimension becomes a category on the chart (e.g. Sentiment, Source Type, Survey Name, Channel, Topic). Order matters for multi-dimensional charts.",
|
||||
"guide_filters": "Filters",
|
||||
"guide_filters_desc": "Conditions that limit which data is included. Each filter has a field, operator (equals, contains, greater than, etc.), and values. And = all must match; Or = any can match.",
|
||||
"guide_measures": "Measures (what you count or aggregate)",
|
||||
"guide_measures_custom": "Custom aggregations let you define your own metrics: pick a numeric field (e.g. Rating, NPS Value) and an aggregation (count, countDistinct, sum, avg, min, max). Alias is an optional label for the chart.",
|
||||
"guide_measures_predefined": "Predefined measures are pre-built metrics from your feedback data: Count (total responses), Promoter/Detractor/Passive Count (NPS segments), NPS Score, Average Score, Completion Rate.",
|
||||
"guide_quick_ref": "Quick reference",
|
||||
"guide_term_custom": "Measure you define: field + aggregation (avg, sum, etc.)",
|
||||
"guide_term_dimension": "Categorical field used to group or split data",
|
||||
"guide_term_filter": "Condition that limits which rows are included",
|
||||
"guide_term_measure": "Numeric value you aggregate (count, sum, avg, etc.)",
|
||||
"guide_term_time": "Time-based grouping with granularity and date range",
|
||||
"guide_time_dimension": "Time dimension",
|
||||
"guide_time_dimension_desc": "Time-based grouping: pick a time field (usually Collected At), granularity (Hour, Day, Week, Month, etc.), and date range (preset or custom). Use for trends over time.",
|
||||
"guide_title": "Chart Builder Field Guide",
|
||||
"is_not_set": "is not set",
|
||||
"is_set": "is set",
|
||||
"less_than": "less than",
|
||||
"less_than_or_equal": "less than or equal",
|
||||
"measures": "Measures",
|
||||
"measures_toggle_description": "Select predefined or custom metrics to display in the chart.",
|
||||
"no_charts_found": "No charts found.",
|
||||
"no_dashboards_available": "No dashboards available",
|
||||
"no_dashboards_create_first": "Create a dashboard first to add charts to it.",
|
||||
"no_data_available": "No data available",
|
||||
"no_data_returned": "No data returned from query",
|
||||
"no_data_returned_for_chart": "No data returned for chart",
|
||||
"no_grouping": "None (filter only)",
|
||||
"no_valid_data_to_display": "No valid data to display",
|
||||
"not_contains": "not contains",
|
||||
"not_equals": "not equals",
|
||||
"open_chart": "Open chart {{name}}",
|
||||
"open_options": "Open chart options",
|
||||
"or_filter_logic": "OR",
|
||||
"original": "Original",
|
||||
"please_enter_chart_name": "Please enter a chart name",
|
||||
"please_run_query_first": "Please run a query first",
|
||||
"please_select_at_least_one_measure": "Please select at least one measure",
|
||||
"please_select_chart_type": "Please select a chart type",
|
||||
"please_select_dashboard": "Please select a dashboard",
|
||||
"predefined_measures": "Predefined Measures",
|
||||
"preset": "Preset",
|
||||
"query_executed_successfully": "Query executed successfully",
|
||||
"query_label": "Query",
|
||||
"reset_to_ai_suggestion": "Reset to AI suggestion",
|
||||
"run_query": "Run Query",
|
||||
"save_chart": "Save Chart",
|
||||
"save_chart_dialog_title": "Save Chart",
|
||||
"select_field": "Select field",
|
||||
"select_measures": "Select measures...",
|
||||
"select_preset": "Select preset",
|
||||
"showing_first_10_of": "Showing first 10 of {count} rows",
|
||||
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
|
||||
"start_date": "Start date",
|
||||
"time_dimension": "Time Dimension",
|
||||
"time_dimension_toggle_description": "Add time-based grouping for trends over time.",
|
||||
"unable_to_determine_chart_data_structure": "Unable to determine chart data structure"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Create Dashboard",
|
||||
"create_dashboard_description": "Enter a name for your new dashboard.",
|
||||
"create_failed": "Failed to create dashboard",
|
||||
"create_success": "Dashboard created successfully!",
|
||||
"dashboard_name": "Dashboard Name",
|
||||
"dashboard_name_placeholder": "My dashboard",
|
||||
"delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
|
||||
"delete_failed": "Failed to delete dashboard",
|
||||
"delete_success": "Dashboard deleted successfully",
|
||||
"description_optional": "Description (Optional)",
|
||||
"description_placeholder": "Dashboard description",
|
||||
"duplicate_failed": "Failed to duplicate dashboard",
|
||||
"duplicate_success": "Dashboard duplicated successfully!",
|
||||
"no_dashboards_found": "No dashboards found.",
|
||||
"please_enter_name": "Please enter a dashboard name"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Congrats!",
|
||||
"connection_successful_message": "Well done! We are connected.",
|
||||
@@ -1082,7 +1243,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": "{memberName} from your organization",
|
||||
"from_your_organization": "from your organization",
|
||||
"invitation_sent_once_more": "Invitation sent once more.",
|
||||
"invite_deleted_successfully": "Invite deleted successfully",
|
||||
"invite_expires_on": "Invite expires on {date}",
|
||||
@@ -1445,6 +1606,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1825,6 @@
|
||||
"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",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||
"analysis": "Análisis",
|
||||
"and": "Y",
|
||||
"and_response_limit_of": "y límite de respuesta de",
|
||||
"anonymous": "Anónimo",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Inferior derecha",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal centrado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Opciones",
|
||||
"choose_environment": "Elegir entorno",
|
||||
"choose_organization": "Elegir organización",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
|
||||
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
|
||||
"create": "Crear",
|
||||
"create_new_organization": "Crear organización nueva",
|
||||
"create_segment": "Crear segmento",
|
||||
"create_survey": "Crear encuesta",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "Activado",
|
||||
"only_one_file_allowed": "Solo se permite un archivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
|
||||
"open_options": "Abrir opciones",
|
||||
"option_id": "ID de opción",
|
||||
"option_ids": "IDs de opciones",
|
||||
"optional": "Opcional",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
|
||||
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Próximamente",
|
||||
"chart_deleted_successfully": "Gráfico eliminado correctamente",
|
||||
"chart_deletion_error": "Error al eliminar el gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
|
||||
"chart_duplication_error": "Error al duplicar el gráfico",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de líneas",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"create_chart": "Crear gráfico",
|
||||
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
|
||||
"no_charts_found": "No se encontraron gráficos.",
|
||||
"open_options": "Abrir opciones del gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Crear panel de control",
|
||||
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
|
||||
"create_failed": "Error al crear el panel de control",
|
||||
"create_success": "Panel de control creado correctamente",
|
||||
"dashboard_name": "Nombre del panel de control",
|
||||
"dashboard_name_placeholder": "Mi panel de control",
|
||||
"delete_confirmation": "¿Estás seguro de que quieres eliminar este panel de control? Esta acción no se puede deshacer.",
|
||||
"delete_failed": "Error al eliminar el panel de control",
|
||||
"delete_success": "Panel de control eliminado correctamente",
|
||||
"description_optional": "Descripción (opcional)",
|
||||
"description_placeholder": "Descripción del panel de control",
|
||||
"duplicate_failed": "Error al duplicar el panel de control",
|
||||
"duplicate_success": "Panel de control duplicado correctamente",
|
||||
"no_dashboards_found": "No se han encontrado paneles de control.",
|
||||
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "¡Enhorabuena!",
|
||||
"connection_successful_message": "¡Bien hecho! Estamos conectados.",
|
||||
@@ -1082,7 +1123,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": "{memberName} de tu organización",
|
||||
"from_your_organization": "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}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Autoriser",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "Et",
|
||||
"and_response_limit_of": "et limite de réponse de",
|
||||
"anonymous": "Anonyme",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "En bas à droite",
|
||||
"cancel": "Annuler",
|
||||
"centered_modal": "Au centre",
|
||||
"chart": "Graphique",
|
||||
"charts": "Graphiques",
|
||||
"choices": "Choix",
|
||||
"choose_environment": "Choisir l'environnement",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
|
||||
"count_responses": "{value, plural, other {# réponses}}",
|
||||
"create": "Créer",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_segment": "Créer un segment",
|
||||
"create_survey": "Créer un sondage",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "Sur",
|
||||
"only_one_file_allowed": "Un seul fichier est autorisé",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"open_options": "Ouvrir les options",
|
||||
"option_id": "Identifiant de l'option",
|
||||
"option_ids": "Identifiants des options",
|
||||
"optional": "Facultatif",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
|
||||
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "À venir bientôt",
|
||||
"chart_deleted_successfully": "Graphique supprimé avec succès",
|
||||
"chart_deletion_error": "Échec de la suppression du graphique",
|
||||
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
|
||||
"chart_duplication_error": "Échec de la duplication du graphique",
|
||||
"chart_type_area": "Graphique en aires",
|
||||
"chart_type_bar": "Graphique à barres",
|
||||
"chart_type_big_number": "Grand nombre",
|
||||
"chart_type_line": "Graphique linéaire",
|
||||
"chart_type_pie": "Graphique circulaire",
|
||||
"create_chart": "Créer un graphique",
|
||||
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique ?",
|
||||
"no_charts_found": "Aucun graphique trouvé.",
|
||||
"open_options": "Ouvrir les options du graphique"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Créer un tableau de bord",
|
||||
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
|
||||
"create_failed": "Échec de la création du tableau de bord",
|
||||
"create_success": "Tableau de bord créé avec succès !",
|
||||
"dashboard_name": "Nom du tableau de bord",
|
||||
"dashboard_name_placeholder": "Mon tableau de bord",
|
||||
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce tableau de bord ? Cette action est irréversible.",
|
||||
"delete_failed": "Échec de la suppression du tableau de bord",
|
||||
"delete_success": "Tableau de bord supprimé avec succès",
|
||||
"description_optional": "Description (facultatif)",
|
||||
"description_placeholder": "Description du tableau de bord",
|
||||
"duplicate_failed": "Échec de la duplication du tableau de bord",
|
||||
"duplicate_success": "Tableau de bord dupliqué avec succès !",
|
||||
"no_dashboards_found": "Aucun tableau de bord trouvé.",
|
||||
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Félicitations !",
|
||||
"connection_successful_message": "Bien joué ! Nous sommes connectés.",
|
||||
@@ -1082,7 +1123,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": "{memberName} de votre organisation",
|
||||
"from_your_organization": "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}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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é",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Engedélyezés",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
|
||||
"analysis": "Elemzés",
|
||||
"and": "És",
|
||||
"and_response_limit_of": "és kérdéskorlátja ennek:",
|
||||
"anonymous": "Névtelen",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Jobbra lent",
|
||||
"cancel": "Mégse",
|
||||
"centered_modal": "Középre helyezett kizárólagos",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagramok",
|
||||
"choices": "Választási lehetőségek",
|
||||
"choose_environment": "Környezet kiválasztása",
|
||||
"choose_organization": "Szervezet kiválasztása",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
|
||||
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
|
||||
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
|
||||
"create": "Létrehozás",
|
||||
"create_new_organization": "Új szervezet létrehozása",
|
||||
"create_segment": "Szakasz létrehozása",
|
||||
"create_survey": "Kérdőív létrehozása",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "Be",
|
||||
"only_one_file_allowed": "Csak egy fájl engedélyezett",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
|
||||
"open_options": "Beállítások megnyitása",
|
||||
"option_id": "Választásazonosító",
|
||||
"option_ids": "Választásazonosítók",
|
||||
"optional": "Elhagyható",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
|
||||
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Hamarosan",
|
||||
"chart_deleted_successfully": "A diagram sikeresen törölve",
|
||||
"chart_deletion_error": "A diagram törlése sikertelen",
|
||||
"chart_duplicated_successfully": "A diagram sikeresen duplikálva",
|
||||
"chart_duplication_error": "A diagram duplikálása sikertelen",
|
||||
"chart_type_area": "Területdiagram",
|
||||
"chart_type_bar": "Oszlopdiagram",
|
||||
"chart_type_big_number": "Nagy szám",
|
||||
"chart_type_line": "Vonaldiagram",
|
||||
"chart_type_pie": "Kördiagram",
|
||||
"create_chart": "Diagram létrehozása",
|
||||
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
|
||||
"no_charts_found": "Nem található diagram.",
|
||||
"open_options": "Diagram beállításainak megnyitása"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Vezérlőpult létrehozása",
|
||||
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
|
||||
"create_failed": "A vezérlőpult létrehozása sikertelen",
|
||||
"create_success": "A vezérlőpult sikeresen létrehozva!",
|
||||
"dashboard_name": "Vezérlőpult neve",
|
||||
"dashboard_name_placeholder": "Saját vezérlőpult",
|
||||
"delete_confirmation": "Biztosan törölni szeretné ezt a vezérlőpultot? Ez a művelet nem vonható vissza.",
|
||||
"delete_failed": "A vezérlőpult törlése sikertelen",
|
||||
"delete_success": "A vezérlőpult sikeresen törölve",
|
||||
"description_optional": "Leírás (opcionális)",
|
||||
"description_placeholder": "Vezérlőpult leírása",
|
||||
"duplicate_failed": "A vezérlőpult másolása sikertelen",
|
||||
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
|
||||
"no_dashboards_found": "Nem található vezérlőpult.",
|
||||
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Gratulálunk!",
|
||||
"connection_successful_message": "Szép munka! Kapcsolódtunk.",
|
||||
@@ -1082,7 +1123,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": "{memberName} a szervezetből",
|
||||
"from_your_organization": "a szervezetétő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}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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ó",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "許可",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
|
||||
"analysis": "分析",
|
||||
"and": "および",
|
||||
"and_response_limit_of": "と回答数の上限",
|
||||
"anonymous": "匿名",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "キャンセル",
|
||||
"centered_modal": "中央モーダル",
|
||||
"chart": "チャート",
|
||||
"charts": "チャート",
|
||||
"choices": "選択肢",
|
||||
"choose_environment": "環境を選択",
|
||||
"choose_organization": "組織を選択",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, other {{value}個の属性}}",
|
||||
"count_contacts": "{count, plural, other {# 件の連絡先}}",
|
||||
"count_responses": "{count, plural, other {# 件の回答}}",
|
||||
"create": "作成",
|
||||
"create_new_organization": "新しい組織を作成",
|
||||
"create_segment": "セグメントを作成",
|
||||
"create_survey": "フォームを作成",
|
||||
@@ -187,11 +191,12 @@
|
||||
"created_by": "作成者",
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboards": "ダッシュボード",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
"delete": "削除",
|
||||
"delete_what": "{deleteWhat}を削除",
|
||||
"description": "説明",
|
||||
"dev_env": "開発環境",
|
||||
"development": "開発",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "オン",
|
||||
"only_one_file_allowed": "ファイルは1つのみ許可されています",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
|
||||
"open_options": "オプションを開く",
|
||||
"option_id": "オプションID",
|
||||
"option_ids": "オプションID",
|
||||
"optional": "任意",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
|
||||
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "近日公開",
|
||||
"chart_deleted_successfully": "チャートを削除しました",
|
||||
"chart_deletion_error": "チャートの削除に失敗しました",
|
||||
"chart_duplicated_successfully": "チャートを複製しました",
|
||||
"chart_duplication_error": "チャートの複製に失敗しました",
|
||||
"chart_type_area": "エリアチャート",
|
||||
"chart_type_bar": "棒グラフ",
|
||||
"chart_type_big_number": "大きな数値",
|
||||
"chart_type_line": "折れ線グラフ",
|
||||
"chart_type_pie": "円グラフ",
|
||||
"create_chart": "チャートを作成",
|
||||
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
|
||||
"no_charts_found": "チャートが見つかりません。",
|
||||
"open_options": "チャートオプションを開く"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "ダッシュボードを作成",
|
||||
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
|
||||
"create_failed": "ダッシュボードの作成に失敗しました",
|
||||
"create_success": "ダッシュボードを正常に作成しました!",
|
||||
"dashboard_name": "ダッシュボード名",
|
||||
"dashboard_name_placeholder": "マイダッシュボード",
|
||||
"delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"delete_failed": "ダッシュボードの削除に失敗しました",
|
||||
"delete_success": "ダッシュボードを正常に削除しました",
|
||||
"description_optional": "説明(任意)",
|
||||
"description_placeholder": "ダッシュボードの説明",
|
||||
"duplicate_failed": "ダッシュボードの複製に失敗しました",
|
||||
"duplicate_success": "ダッシュボードを正常に複製しました!",
|
||||
"no_dashboards_found": "ダッシュボードが見つかりません。",
|
||||
"please_enter_name": "ダッシュボード名を入力してください"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "おめでとうございます!",
|
||||
"connection_successful_message": "うまくいきました!接続されました。",
|
||||
@@ -1082,7 +1123,7 @@
|
||||
"email_customization_preview_email_heading": "こんにちは、{userName}さん",
|
||||
"email_customization_preview_email_text": "これは、メールに表示されるロゴを確認するためのプレビューメールです。",
|
||||
"error_deleting_organization_please_try_again": "組織の削除中にエラーが発生しました。もう一度お試しください。",
|
||||
"from_your_organization": "組織から{memberName}を削除",
|
||||
"from_your_organization": "あなたの組織から",
|
||||
"invitation_sent_once_more": "招待状を再度送信しました。",
|
||||
"invite_deleted_successfully": "招待を正常に削除しました",
|
||||
"invite_expires_on": "招待は{date}に期限切れ",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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": "見出し",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
|
||||
"survey_display_settings": "フォーム表示設定",
|
||||
"survey_placement": "フォームの配置",
|
||||
"survey_styling": "フォームのスタイル",
|
||||
"survey_trigger": "フォームのトリガー",
|
||||
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
|
||||
"target_block_not_found": "対象ブロックが見つかりません",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Toestaan",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "En",
|
||||
"and_response_limit_of": "en responslimiet van",
|
||||
"anonymous": "Anoniem",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Rechtsonder",
|
||||
"cancel": "Annuleren",
|
||||
"centered_modal": "Gecentreerd modaal",
|
||||
"chart": "Grafiek",
|
||||
"charts": "Grafieken",
|
||||
"choices": "Keuzes",
|
||||
"choose_environment": "Kies omgeving",
|
||||
"choose_organization": "Kies organisatie",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
|
||||
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
|
||||
"create": "Creëren",
|
||||
"create_new_organization": "Creëer een nieuwe organisatie",
|
||||
"create_segment": "Segment maken",
|
||||
"create_survey": "Enquête maken",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "Op",
|
||||
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
|
||||
"open_options": "Opties openen",
|
||||
"option_id": "Optie-ID",
|
||||
"option_ids": "Optie-ID's",
|
||||
"optional": "Optioneel",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
|
||||
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Binnenkort beschikbaar",
|
||||
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
|
||||
"chart_deletion_error": "Verwijderen van grafiek mislukt",
|
||||
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
|
||||
"chart_duplication_error": "Dupliceren van grafiek mislukt",
|
||||
"chart_type_area": "Vlakdiagram",
|
||||
"chart_type_bar": "Staafdiagram",
|
||||
"chart_type_big_number": "Groot getal",
|
||||
"chart_type_line": "Lijndiagram",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"create_chart": "Diagram maken",
|
||||
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
|
||||
"no_charts_found": "Geen diagrammen gevonden.",
|
||||
"open_options": "Open diagramopties"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Dashboard creëren",
|
||||
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
|
||||
"create_failed": "Dashboard creëren mislukt",
|
||||
"create_success": "Dashboard succesvol aangemaakt!",
|
||||
"dashboard_name": "Dashboardnaam",
|
||||
"dashboard_name_placeholder": "Mijn dashboard",
|
||||
"delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_failed": "Dashboard verwijderen mislukt",
|
||||
"delete_success": "Dashboard succesvol verwijderd",
|
||||
"description_optional": "Beschrijving (optioneel)",
|
||||
"description_placeholder": "Dashboardbeschrijving",
|
||||
"duplicate_failed": "Dashboard dupliceren mislukt",
|
||||
"duplicate_success": "Dashboard succesvol gedupliceerd!",
|
||||
"no_dashboards_found": "Geen dashboards gevonden.",
|
||||
"please_enter_name": "Voer een dashboardnaam in"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Gefeliciteerd!",
|
||||
"connection_successful_message": "Goed gedaan! We zijn verbonden.",
|
||||
@@ -1082,7 +1123,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": "{memberName} uit je organisatie",
|
||||
"from_your_organization": "vanuit uw organisatie",
|
||||
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
|
||||
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
|
||||
"invite_expires_on": "Uitnodiging verloopt op {date}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anônimo",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Canto Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolher ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar pesquisa",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "ligado",
|
||||
"only_one_file_allowed": "É permitido apenas um arquivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID da opção",
|
||||
"option_ids": "IDs da Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
|
||||
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Em breve",
|
||||
"chart_deleted_successfully": "Gráfico excluído com sucesso",
|
||||
"chart_deletion_error": "Falha ao excluir gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_pie": "Gráfico de pizza",
|
||||
"create_chart": "Criar gráfico",
|
||||
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"open_options": "Abrir opções do gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Digite um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "Meu painel",
|
||||
"delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
|
||||
"delete_failed": "Falha ao excluir painel",
|
||||
"delete_success": "Painel excluído com sucesso",
|
||||
"description_optional": "Descrição (opcional)",
|
||||
"description_placeholder": "Descrição do painel",
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"please_enter_name": "Por favor, digite um nome para o painel"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Parabéns!",
|
||||
"connection_successful_message": "Mandou bem! Estamos conectados.",
|
||||
@@ -1082,7 +1123,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": "{memberName} da sua organização",
|
||||
"from_your_organization": "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}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anónimo",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolha o ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar inquérito",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "Ligado",
|
||||
"only_one_file_allowed": "Apenas um ficheiro é permitido",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID de Opção",
|
||||
"option_ids": "IDs de Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
|
||||
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Em breve",
|
||||
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
|
||||
"chart_deletion_error": "Falha ao eliminar gráfico",
|
||||
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
|
||||
"chart_duplication_error": "Falha ao duplicar gráfico",
|
||||
"chart_type_area": "Gráfico de área",
|
||||
"chart_type_bar": "Gráfico de barras",
|
||||
"chart_type_big_number": "Número grande",
|
||||
"chart_type_line": "Gráfico de linhas",
|
||||
"chart_type_pie": "Gráfico circular",
|
||||
"create_chart": "Criar gráfico",
|
||||
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
|
||||
"no_charts_found": "Nenhum gráfico encontrado.",
|
||||
"open_options": "Abrir opções do gráfico"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Criar painel",
|
||||
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
|
||||
"create_failed": "Falha ao criar painel",
|
||||
"create_success": "Painel criado com sucesso!",
|
||||
"dashboard_name": "Nome do painel",
|
||||
"dashboard_name_placeholder": "O meu painel",
|
||||
"delete_confirmation": "Tem a certeza de que pretende eliminar este painel? Esta ação não pode ser revertida.",
|
||||
"delete_failed": "Falha ao eliminar painel",
|
||||
"delete_success": "Painel eliminado com sucesso",
|
||||
"description_optional": "Descrição (opcional)",
|
||||
"description_placeholder": "Descrição do painel",
|
||||
"duplicate_failed": "Falha ao duplicar painel",
|
||||
"duplicate_success": "Painel duplicado com sucesso!",
|
||||
"no_dashboards_found": "Nenhum painel encontrado.",
|
||||
"please_enter_name": "Por favor, introduza um nome para o painel"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Parabéns!",
|
||||
"connection_successful_message": "Muito bem! Estamos ligados.",
|
||||
@@ -1082,7 +1123,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": "{memberName} da sua organização",
|
||||
"from_your_organization": "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}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Permite",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
|
||||
"analysis": "Analiză",
|
||||
"and": "Și",
|
||||
"and_response_limit_of": "și limită răspuns",
|
||||
"anonymous": "Anonim",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Dreapta Jos",
|
||||
"cancel": "Anulare",
|
||||
"centered_modal": "Modală centralizată",
|
||||
"chart": "Grafic",
|
||||
"charts": "Grafice",
|
||||
"choices": "Alegeri",
|
||||
"choose_environment": "Alege mediul",
|
||||
"choose_organization": "Alege organizația",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
|
||||
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
|
||||
"create": "Creează",
|
||||
"create_new_organization": "Creează organizație nouă",
|
||||
"create_segment": "Creați segment",
|
||||
"create_survey": "Creează sondaj",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "Pe",
|
||||
"only_one_file_allowed": "Este permis doar un fișier",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
|
||||
"open_options": "Deschide opțiunile",
|
||||
"option_id": "ID opțiune",
|
||||
"option_ids": "ID-uri opțiuni",
|
||||
"optional": "Opțional",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
|
||||
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "În curând",
|
||||
"chart_deleted_successfully": "Graficul a fost șters cu succes",
|
||||
"chart_deletion_error": "Nu s-a putut șterge graficul",
|
||||
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
|
||||
"chart_duplication_error": "Nu s-a putut duplica graficul",
|
||||
"chart_type_area": "Grafic de tip arie",
|
||||
"chart_type_bar": "Grafic de tip bară",
|
||||
"chart_type_big_number": "Număr mare",
|
||||
"chart_type_line": "Grafic de tip linie",
|
||||
"chart_type_pie": "Grafic de tip plăcintă",
|
||||
"create_chart": "Creează grafic",
|
||||
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
|
||||
"no_charts_found": "Nu s-au găsit grafice.",
|
||||
"open_options": "Deschide opțiunile graficului"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Creează tablou de bord",
|
||||
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
|
||||
"create_failed": "Crearea tabloului de bord a eșuat",
|
||||
"create_success": "Tablou de bord creat cu succes!",
|
||||
"dashboard_name": "Nume tablou de bord",
|
||||
"dashboard_name_placeholder": "Tabloul meu de bord",
|
||||
"delete_confirmation": "Ești sigur că vrei să ștergi acest tablou de bord? Această acțiune nu poate fi anulată.",
|
||||
"delete_failed": "Ștergerea tabloului de bord a eșuat",
|
||||
"delete_success": "Tablou de bord șters cu succes",
|
||||
"description_optional": "Descriere (opțional)",
|
||||
"description_placeholder": "Descriere tablou de bord",
|
||||
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
|
||||
"duplicate_success": "Tablou de bord duplicat cu succes!",
|
||||
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
|
||||
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Felicitări!",
|
||||
"connection_successful_message": "Bravo! Suntem conectați.",
|
||||
@@ -1082,7 +1123,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": "{memberName} din organizația ta",
|
||||
"from_your_organization": "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}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Разрешить",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
|
||||
"analysis": "Аналитика",
|
||||
"and": "и",
|
||||
"and_response_limit_of": "и лимит ответов",
|
||||
"anonymous": "Аноним",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Внизу справа",
|
||||
"cancel": "Отмена",
|
||||
"centered_modal": "Центрированное модальное окно",
|
||||
"chart": "График",
|
||||
"charts": "Графики",
|
||||
"choices": "Варианты",
|
||||
"choose_environment": "Выберите среду",
|
||||
"choose_organization": "Выберите организацию",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} атрибут} 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": "Создать опрос",
|
||||
@@ -187,11 +191,12 @@
|
||||
"created_by": "Создано пользователем",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboards": "Дашборды",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
"delete": "Удалить",
|
||||
"delete_what": "Удалить {deleteWhat}",
|
||||
"description": "Описание",
|
||||
"dev_env": "Dev Environment",
|
||||
"development": "Разработка",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "Вкл.",
|
||||
"only_one_file_allowed": "Разрешён только один файл",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
|
||||
"open_options": "Открыть параметры",
|
||||
"option_id": "ID опции",
|
||||
"option_ids": "ID опций",
|
||||
"optional": "Необязательно",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
|
||||
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Скоро будет",
|
||||
"chart_deleted_successfully": "График успешно удалён",
|
||||
"chart_deletion_error": "Не удалось удалить график",
|
||||
"chart_duplicated_successfully": "График успешно дублирован",
|
||||
"chart_duplication_error": "Не удалось дублировать график",
|
||||
"chart_type_area": "График областью",
|
||||
"chart_type_bar": "Столбчатая диаграмма",
|
||||
"chart_type_big_number": "Большое число",
|
||||
"chart_type_line": "Линейный график",
|
||||
"chart_type_pie": "Круговая диаграмма",
|
||||
"create_chart": "Создать график",
|
||||
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
|
||||
"no_charts_found": "Графики не найдены.",
|
||||
"open_options": "Открыть настройки графика"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Создать панель управления",
|
||||
"create_dashboard_description": "Введите название для новой панели управления.",
|
||||
"create_failed": "Не удалось создать панель управления",
|
||||
"create_success": "Панель управления успешно создана!",
|
||||
"dashboard_name": "Название панели управления",
|
||||
"dashboard_name_placeholder": "Моя панель управления",
|
||||
"delete_confirmation": "Ты уверен, что хочешь удалить эту панель управления? Это действие нельзя отменить.",
|
||||
"delete_failed": "Не удалось удалить панель управления",
|
||||
"delete_success": "Панель управления успешно удалена",
|
||||
"description_optional": "Описание (необязательно)",
|
||||
"description_placeholder": "Описание панели управления",
|
||||
"duplicate_failed": "Не удалось дублировать панель управления",
|
||||
"duplicate_success": "Панель управления успешно продублирована!",
|
||||
"no_dashboards_found": "Панели управления не найдены.",
|
||||
"please_enter_name": "Пожалуйста, введите название панели управления"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Поздравляем!",
|
||||
"connection_successful_message": "Отлично! Мы подключены.",
|
||||
@@ -1082,7 +1123,7 @@
|
||||
"email_customization_preview_email_heading": "Привет, {userName}",
|
||||
"email_customization_preview_email_text": "Это предварительный просмотр письма, чтобы показать, какой логотип будет отображаться в письмах.",
|
||||
"error_deleting_organization_please_try_again": "Ошибка при удалении организации. Пожалуйста, попробуйте ещё раз.",
|
||||
"from_your_organization": "{memberName} из вашей организации",
|
||||
"from_your_organization": "из вашей организации",
|
||||
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
|
||||
"invite_deleted_successfully": "Приглашение успешно удалено",
|
||||
"invite_expires_on": "Приглашение истекает {date}",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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": "Заголовок",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
|
||||
"survey_display_settings": "Настройки отображения опроса",
|
||||
"survey_placement": "Размещение опроса",
|
||||
"survey_styling": "Оформление формы",
|
||||
"survey_trigger": "Триггер опроса",
|
||||
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
|
||||
"target_block_not_found": "Целевой блок не найден",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Tillåt",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
|
||||
"analysis": "Analys",
|
||||
"and": "Och",
|
||||
"and_response_limit_of": "och svarsgräns på",
|
||||
"anonymous": "Anonym",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "Nedre höger",
|
||||
"cancel": "Avbryt",
|
||||
"centered_modal": "Centrerad modal",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagram",
|
||||
"choices": "Val",
|
||||
"choose_environment": "Välj miljö",
|
||||
"choose_organization": "Välj organisation",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
|
||||
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
|
||||
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
|
||||
"create": "Skapa",
|
||||
"create_new_organization": "Skapa ny organisation",
|
||||
"create_segment": "Skapa segment",
|
||||
"create_survey": "Skapa enkät",
|
||||
@@ -187,11 +191,12 @@
|
||||
"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",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "På",
|
||||
"only_one_file_allowed": "Endast en fil är tillåten",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
|
||||
"open_options": "Öppna alternativ",
|
||||
"option_id": "Alternativ-ID",
|
||||
"option_ids": "Alternativ-ID:n",
|
||||
"optional": "Valfritt",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
|
||||
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "Kommer snart",
|
||||
"chart_deleted_successfully": "Diagrammet har tagits bort",
|
||||
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
|
||||
"chart_duplicated_successfully": "Diagrammet har duplicerats",
|
||||
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
|
||||
"chart_type_area": "Ytdiagram",
|
||||
"chart_type_bar": "Stapeldiagram",
|
||||
"chart_type_big_number": "Stort tal",
|
||||
"chart_type_line": "Linjediagram",
|
||||
"chart_type_pie": "Cirkeldiagram",
|
||||
"create_chart": "Skapa diagram",
|
||||
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
|
||||
"no_charts_found": "Inga diagram hittades.",
|
||||
"open_options": "Öppna diagramalternativ"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "Skapa instrumentpanel",
|
||||
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
|
||||
"create_failed": "Det gick inte att skapa instrumentpanelen",
|
||||
"create_success": "Instrumentpanelen har skapats!",
|
||||
"dashboard_name": "Instrumentpanelens namn",
|
||||
"dashboard_name_placeholder": "Min instrumentpanel",
|
||||
"delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
|
||||
"delete_failed": "Det gick inte att ta bort instrumentpanelen",
|
||||
"delete_success": "Instrumentpanelen har tagits bort",
|
||||
"description_optional": "Beskrivning (valfritt)",
|
||||
"description_placeholder": "Beskrivning av instrumentpanelen",
|
||||
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
|
||||
"duplicate_success": "Instrumentpanelen har duplicerats!",
|
||||
"no_dashboards_found": "Inga instrumentpaneler hittades.",
|
||||
"please_enter_name": "Ange ett namn på instrumentpanelen"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Grattis!",
|
||||
"connection_successful_message": "Bra gjort! Vi är anslutna.",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"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",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "允许",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
|
||||
"analysis": "分析",
|
||||
"and": "和",
|
||||
"and_response_limit_of": "和 响应限制",
|
||||
"anonymous": "匿名",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "居中 模态",
|
||||
"chart": "图表",
|
||||
"charts": "图表",
|
||||
"choices": "选项",
|
||||
"choose_environment": "选择 环境",
|
||||
"choose_organization": "选择 组织",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 联系人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回复} }",
|
||||
"create": "创建",
|
||||
"create_new_organization": "创建 新的 组织",
|
||||
"create_segment": "创建 细分",
|
||||
"create_survey": "创建 调查",
|
||||
@@ -187,11 +191,12 @@
|
||||
"created_by": "由 创建",
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "仪表盘",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
"delete": "删除",
|
||||
"delete_what": "删除{deleteWhat}",
|
||||
"description": "描述",
|
||||
"dev_env": "开发 环境",
|
||||
"development": "开发环境",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "开启",
|
||||
"only_one_file_allowed": "只 允许 一个 文件",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
|
||||
"open_options": "打开选项",
|
||||
"option_id": "选项 ID",
|
||||
"option_ids": "选项 ID",
|
||||
"optional": "可选",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
|
||||
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "即将推出",
|
||||
"chart_deleted_successfully": "图表删除成功",
|
||||
"chart_deletion_error": "图表删除失败",
|
||||
"chart_duplicated_successfully": "图表复制成功",
|
||||
"chart_duplication_error": "图表复制失败",
|
||||
"chart_type_area": "面积图",
|
||||
"chart_type_bar": "柱状图",
|
||||
"chart_type_big_number": "大数字",
|
||||
"chart_type_line": "折线图",
|
||||
"chart_type_pie": "饼图",
|
||||
"create_chart": "创建图表",
|
||||
"delete_chart_confirmation": "你确定要删除这个图表吗?",
|
||||
"no_charts_found": "未找到图表。",
|
||||
"open_options": "打开图表选项"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "创建 Dashboard",
|
||||
"create_dashboard_description": "请输入新 Dashboard 的名称。",
|
||||
"create_failed": "创建 Dashboard 失败",
|
||||
"create_success": "Dashboard 创建成功!",
|
||||
"dashboard_name": "Dashboard 名称",
|
||||
"dashboard_name_placeholder": "我的 Dashboard",
|
||||
"delete_confirmation": "确定要删除此 Dashboard 吗?此操作无法撤销。",
|
||||
"delete_failed": "删除 Dashboard 失败",
|
||||
"delete_success": "Dashboard 删除成功",
|
||||
"description_optional": "描述(可选)",
|
||||
"description_placeholder": "Dashboard 描述",
|
||||
"duplicate_failed": "复制 Dashboard 失败",
|
||||
"duplicate_success": "Dashboard 复制成功!",
|
||||
"no_dashboards_found": "未找到 Dashboard。",
|
||||
"please_enter_name": "请输入 Dashboard 名称"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "恭喜!",
|
||||
"connection_successful_message": "做得好 !我们 已经 连接。",
|
||||
@@ -1082,7 +1123,7 @@
|
||||
"email_customization_preview_email_heading": "嘿 {userName}",
|
||||
"email_customization_preview_email_text": "这 是 一封 电子邮件 预览,展示 哪个 徽标 将在 电子邮件 中 渲染。",
|
||||
"error_deleting_organization_please_try_again": "删除 组织时 出错 。 请重试 。",
|
||||
"from_your_organization": "来自您组织的{memberName}",
|
||||
"from_your_organization": "来自你的组织",
|
||||
"invitation_sent_once_more": "再次发送邀请。",
|
||||
"invite_deleted_successfully": "邀请 删除 成功",
|
||||
"invite_expires_on": "邀请将于 {date} 过期",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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": "标题",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
|
||||
"survey_display_settings": "调查显示设置",
|
||||
"survey_placement": "调查 放置",
|
||||
"survey_styling": "表单 样式",
|
||||
"survey_trigger": "调查 触发",
|
||||
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
|
||||
"target_block_not_found": "未找到目标区块",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "允許",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
|
||||
"analysis": "分析",
|
||||
"and": "且",
|
||||
"and_response_limit_of": "且回應上限為",
|
||||
"anonymous": "匿名",
|
||||
@@ -149,6 +150,8 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "置中彈窗",
|
||||
"chart": "圖表",
|
||||
"charts": "圖表",
|
||||
"choices": "選項",
|
||||
"choose_environment": "選擇環境",
|
||||
"choose_organization": "選擇 組織",
|
||||
@@ -178,6 +181,7 @@
|
||||
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回應} }",
|
||||
"create": "建立",
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_segment": "建立區隔",
|
||||
"create_survey": "建立問卷",
|
||||
@@ -187,11 +191,12 @@
|
||||
"created_by": "建立者",
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"dashboard": "儀表板",
|
||||
"dashboards": "儀表板",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
"delete": "刪除",
|
||||
"delete_what": "刪除{deleteWhat}",
|
||||
"description": "描述",
|
||||
"dev_env": "開發環境",
|
||||
"development": "開發",
|
||||
@@ -309,6 +314,7 @@
|
||||
"on": "開啟",
|
||||
"only_one_file_allowed": "僅允許一個檔案",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"open_options": "開啟選項",
|
||||
"option_id": "選項 ID",
|
||||
"option_ids": "選項 IDs",
|
||||
"optional": "選填",
|
||||
@@ -614,6 +620,41 @@
|
||||
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
|
||||
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"action_coming_soon": "即將推出",
|
||||
"chart_deleted_successfully": "圖表已成功刪除",
|
||||
"chart_deletion_error": "刪除圖表失敗",
|
||||
"chart_duplicated_successfully": "圖表已成功複製",
|
||||
"chart_duplication_error": "圖表複製失敗",
|
||||
"chart_type_area": "區域圖",
|
||||
"chart_type_bar": "長條圖",
|
||||
"chart_type_big_number": "大數字",
|
||||
"chart_type_line": "折線圖",
|
||||
"chart_type_pie": "圓餅圖",
|
||||
"create_chart": "建立圖表",
|
||||
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
|
||||
"no_charts_found": "找不到圖表。",
|
||||
"open_options": "開啟圖表選項"
|
||||
},
|
||||
"dashboards": {
|
||||
"create_dashboard": "建立儀表板",
|
||||
"create_dashboard_description": "請輸入新儀表板的名稱。",
|
||||
"create_failed": "建立儀表板失敗",
|
||||
"create_success": "儀表板建立成功!",
|
||||
"dashboard_name": "儀表板名稱",
|
||||
"dashboard_name_placeholder": "我的儀表板",
|
||||
"delete_confirmation": "你確定要刪除此儀表板嗎?此操作無法復原。",
|
||||
"delete_failed": "刪除儀表板失敗",
|
||||
"delete_success": "儀表板刪除成功",
|
||||
"description_optional": "描述(選填)",
|
||||
"description_placeholder": "儀表板描述",
|
||||
"duplicate_failed": "複製儀表板失敗",
|
||||
"duplicate_success": "儀表板複製成功!",
|
||||
"no_dashboards_found": "找不到儀表板。",
|
||||
"please_enter_name": "請輸入儀表板名稱"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "恭喜!",
|
||||
"connection_successful_message": "做得好!我們已連線。",
|
||||
@@ -1445,6 +1486,7 @@
|
||||
"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": "標題",
|
||||
@@ -1663,7 +1705,6 @@
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
"survey_placement": "問卷位置",
|
||||
"survey_styling": "表單樣式設定",
|
||||
"survey_trigger": "問卷觸發器",
|
||||
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
|
||||
"target_block_not_found": "找不到目標區塊",
|
||||
|
||||
@@ -12,7 +12,9 @@ type HasFindMany =
|
||||
| Prisma.TeamFindManyArgs
|
||||
| Prisma.ProjectTeamFindManyArgs
|
||||
| Prisma.UserFindManyArgs
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
| Prisma.ContactAttributeKeyFindManyArgs
|
||||
| Prisma.ChartFindManyArgs
|
||||
| Prisma.DashboardFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockLoad = vi.fn();
|
||||
const mockTablePivot = vi.fn();
|
||||
|
||||
vi.mock("@cubejs-client/core", () => ({
|
||||
default: vi.fn(() => ({
|
||||
load: mockLoad,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
const resultSet = { tablePivot: mockTablePivot };
|
||||
mockLoad.mockResolvedValue(resultSet);
|
||||
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("loads query and returns tablePivot result", async () => {
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
const query = { measures: ["FeedbackRecords.count"] };
|
||||
const result = await executeQuery(query);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledWith(query);
|
||||
expect(mockTablePivot).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
|
||||
const fullUrl = "https://cube.example.com/cubejs-api/v1";
|
||||
vi.stubEnv("CUBEJS_API_URL", fullUrl);
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
|
||||
await executeQuery({ measures: ["FeedbackRecords.count"] });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
|
||||
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
|
||||
|
||||
const getApiUrl = (): string => {
|
||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
let cubeClient: CubeApi | null = null;
|
||||
|
||||
function getCubeClient(): CubeApi {
|
||||
if (!cubeClient) {
|
||||
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
|
||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
||||
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
|
||||
}
|
||||
return cubeClient;
|
||||
}
|
||||
|
||||
export async function executeQuery(query: Query) {
|
||||
const client = getCubeClient();
|
||||
const resultSet = await client.load(query);
|
||||
return resultSet.tablePivot();
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use server";
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
import { validateQueryMembers } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import {
|
||||
createChart,
|
||||
deleteChart,
|
||||
duplicateChart,
|
||||
getChart,
|
||||
getCharts,
|
||||
updateChart,
|
||||
} from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
|
||||
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
|
||||
/** Client-facing chart input (projectId and createdBy are resolved server-side) */
|
||||
const ZChartCreateInputClient = ZChartCreateInput.omit({ projectId: true, createdBy: true });
|
||||
|
||||
const ZCreateChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartInput: ZChartCreateInputClient,
|
||||
});
|
||||
|
||||
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await createChart({
|
||||
...parsedInput.chartInput,
|
||||
projectId,
|
||||
createdBy: ctx.user.id,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = chart.id;
|
||||
ctx.auditLoggingCtx.newObject = chart;
|
||||
return chart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
chartUpdateInput: ZChartUpdateInput,
|
||||
});
|
||||
|
||||
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const { chart, updatedChart } = await updateChart(
|
||||
parsedInput.chartId,
|
||||
projectId,
|
||||
parsedInput.chartUpdateInput
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
ctx.auditLoggingCtx.newObject = updatedChart;
|
||||
return updatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDuplicateChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const duplicatedChart = await duplicateChart(parsedInput.chartId, projectId, ctx.user.id);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
|
||||
ctx.auditLoggingCtx.newObject = duplicatedChart;
|
||||
return duplicatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteChartAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const chart = await deleteChart(parsedInput.chartId, projectId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
|
||||
ctx.auditLoggingCtx.oldObject = chart;
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const getChartAction = authenticatedActionClient
|
||||
.schema(ZGetChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getChart(parsedInput.chartId, projectId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetChartsAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getChartsAction = authenticatedActionClient
|
||||
.schema(ZGetChartsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetChartsAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getCharts(projectId);
|
||||
}
|
||||
);
|
||||
|
||||
// ── Charts UI specific actions (query execution & AI generation) ─────────────
|
||||
|
||||
const ZExecuteQueryAction = z.object({
|
||||
environmentId: ZId,
|
||||
query: ZChartQuery,
|
||||
});
|
||||
|
||||
export const executeQueryAction = authenticatedActionClient
|
||||
.schema(ZExecuteQueryAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZExecuteQueryAction>;
|
||||
}) => {
|
||||
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
validateQueryMembers(parsedInput.query);
|
||||
|
||||
try {
|
||||
return await executeQuery(parsedInput.query as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error("Failed to execute query");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const ZGenerateAIQueryResponse = z.object({
|
||||
measures: z.array(z.string()),
|
||||
dimensions: z.array(z.string()).nullable(),
|
||||
timeDimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
dimension: z.string(),
|
||||
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
|
||||
dateRange: z.string().nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
chartType: ZChartType,
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
member: z.string(),
|
||||
operator: z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
]),
|
||||
values: z.array(z.string()).nullable(),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const AI_QUERY_JSON_SCHEMA = {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
measures: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
description: "List of measures to query",
|
||||
},
|
||||
dimensions: {
|
||||
anyOf: [{ type: "array" as const, items: { type: "string" as const } }, { type: "null" as const }],
|
||||
description: "List of dimensions to query",
|
||||
},
|
||||
timeDimensions: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
dimension: { type: "string" as const },
|
||||
granularity: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string" as const,
|
||||
enum: ["hour", "day", "week", "month", "quarter", "year"],
|
||||
},
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
dateRange: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||
},
|
||||
required: ["dimension", "granularity", "dateRange"],
|
||||
},
|
||||
},
|
||||
{ type: "null" as const },
|
||||
],
|
||||
description: "Time dimensions with granularity and date range",
|
||||
},
|
||||
chartType: {
|
||||
type: "string" as const,
|
||||
enum: [...ZChartType.options],
|
||||
description: "Suggested chart type for visualization",
|
||||
},
|
||||
filters: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
member: { type: "string" as const },
|
||||
operator: {
|
||||
type: "string" as const,
|
||||
enum: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
],
|
||||
},
|
||||
values: {
|
||||
anyOf: [
|
||||
{ type: "array" as const, items: { type: "string" as const } },
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ["member", "operator", "values"],
|
||||
},
|
||||
},
|
||||
{ type: "null" as const },
|
||||
],
|
||||
description: "Filters to apply to the query",
|
||||
},
|
||||
},
|
||||
required: ["measures", "dimensions", "timeDimensions", "chartType", "filters"],
|
||||
};
|
||||
|
||||
const ZGenerateAIChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
prompt: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export const generateAIChartAction = authenticatedActionClient
|
||||
.schema(ZGenerateAIChartAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGenerateAIChartAction>;
|
||||
}) => {
|
||||
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const schemaContext = generateSchemaContext();
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: schemaContext },
|
||||
{ role: "user", content: `User request: "${parsedInput.prompt}"` },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_cube_query",
|
||||
description: "Generate a Cube.js query based on the user request",
|
||||
parameters: AI_QUERY_JSON_SCHEMA,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function", function: { name: "generate_cube_query" } },
|
||||
});
|
||||
|
||||
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
|
||||
if (toolCall?.function?.name !== "generate_cube_query") {
|
||||
throw new Error("Failed to generate structured output from OpenAI");
|
||||
}
|
||||
|
||||
const rawQuery = JSON.parse(toolCall.function.arguments);
|
||||
const validated = ZGenerateAIQueryResponse.parse(rawQuery);
|
||||
|
||||
if (!validated.measures || validated.measures.length === 0) {
|
||||
validated.measures = [`${CUBE_NAME}.count`];
|
||||
}
|
||||
|
||||
const { chartType, ...cubeQuery } = validated;
|
||||
|
||||
const cleanQuery: Record<string, unknown> = {
|
||||
measures: cubeQuery.measures,
|
||||
};
|
||||
|
||||
if (Array.isArray(cubeQuery.dimensions) && cubeQuery.dimensions.length > 0) {
|
||||
cleanQuery.dimensions = cubeQuery.dimensions;
|
||||
}
|
||||
|
||||
if (Array.isArray(cubeQuery.filters) && cubeQuery.filters.length > 0) {
|
||||
cleanQuery.filters = cubeQuery.filters.map(
|
||||
(f: { member: string; operator: string; values?: string[] | null }) => {
|
||||
const cleaned: Record<string, unknown> = { member: f.member, operator: f.operator };
|
||||
if (f.values !== null && f.values !== undefined) cleaned.values = f.values;
|
||||
return cleaned;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(cubeQuery.timeDimensions) && cubeQuery.timeDimensions.length > 0) {
|
||||
cleanQuery.timeDimensions = cubeQuery.timeDimensions.map(
|
||||
(td: { dimension: string; granularity?: string | null; dateRange?: string | null }) => {
|
||||
const cleaned: Record<string, unknown> = { dimension: td.dimension };
|
||||
if (td.granularity !== null && td.granularity !== undefined) cleaned.granularity = td.granularity;
|
||||
if (td.dateRange !== null && td.dateRange !== undefined) cleaned.dateRange = td.dateRange;
|
||||
return cleaned;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const data = await executeQuery(cleanQuery);
|
||||
|
||||
return {
|
||||
query: cleanQuery,
|
||||
chartType,
|
||||
data: Array.isArray(data) ? data : [],
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AddToDashboardDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function AddToDashboardDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
}: Readonly<AddToDashboardDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && onOpenChange(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.add_chart_to_dashboard")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.analysis.charts.add_chart_to_dashboard_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("environments.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dashboard-select">{t("environments.analysis.charts.dashboard")}</Label>
|
||||
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||
<SelectTrigger
|
||||
id="dashboard-select"
|
||||
className="mt-2 w-full bg-white"
|
||||
disabled={dashboards.length === 0}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
dashboards.length === 0
|
||||
? t("environments.analysis.charts.no_dashboards_available")
|
||||
: t("environments.analysis.charts.dashboard_select_placeholder")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="max-h-[200px]">
|
||||
{dashboards.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.no_dashboards_available")}
|
||||
</div>
|
||||
) : (
|
||||
dashboards.map((dashboard) => (
|
||||
<SelectItem key={dashboard.id} value={dashboard.id}>
|
||||
{dashboard.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dashboards.length === 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t("environments.analysis.charts.no_dashboards_create_first")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
|
||||
{t("environments.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { executeQueryAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartPreview } from "@/modules/ee/analysis/charts/components/advanced-chart-preview";
|
||||
import { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
|
||||
import { ChartTypeSelector } from "@/modules/ee/analysis/charts/components/chart-type-selector";
|
||||
import { DimensionsPanel } from "@/modules/ee/analysis/charts/components/dimensions-panel";
|
||||
import { FiltersPanel } from "@/modules/ee/analysis/charts/components/filters-panel";
|
||||
import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures-panel";
|
||||
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
|
||||
import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel";
|
||||
import { useSaveDashboardDialogs } from "@/modules/ee/analysis/charts/hooks/use-save-dashboard-dialogs";
|
||||
import {
|
||||
ChartBuilderState,
|
||||
type CustomMeasure,
|
||||
type FilterRow,
|
||||
type TimeDimensionConfig,
|
||||
buildCubeQuery,
|
||||
parseQueryToState,
|
||||
} from "@/modules/ee/analysis/lib/query-builder";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { AnalyticsResponse, TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
interface AdvancedChartBuilderProps {
|
||||
environmentId: string;
|
||||
initialChartType?: TChartType;
|
||||
initialQuery?: TChartQuery;
|
||||
hidePreview?: boolean;
|
||||
/** Must be stable (memoized) to avoid effect re-runs on every parent render */
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
onSave?: (chartId: string) => void;
|
||||
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_CHART_TYPE"; payload: TChartType }
|
||||
| { type: "SET_MEASURES"; payload: string[] }
|
||||
| { type: "SET_CUSTOM_MEASURES"; payload: CustomMeasure[] }
|
||||
| { type: "SET_DIMENSIONS"; payload: string[] }
|
||||
| { type: "SET_FILTERS"; payload: FilterRow[] }
|
||||
| { type: "SET_FILTER_LOGIC"; payload: "and" | "or" }
|
||||
| { type: "SET_TIME_DIMENSION"; payload: TimeDimensionConfig | null }
|
||||
| { type: "QUERY_START" }
|
||||
| { type: "QUERY_SUCCESS"; payload: { data: TChartDataRow[]; query: TChartQuery } }
|
||||
| { type: "QUERY_ERROR"; payload: string };
|
||||
|
||||
interface QueryState {
|
||||
chartData: TChartDataRow[] | null;
|
||||
query: TChartQuery | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialQueryState: QueryState = {
|
||||
chartData: null,
|
||||
query: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const initialState: ChartBuilderState = {
|
||||
chartType: "",
|
||||
selectedMeasures: [],
|
||||
customMeasures: [],
|
||||
selectedDimensions: [],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
|
||||
const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBuilderState => {
|
||||
switch (action.type) {
|
||||
case "SET_CHART_TYPE":
|
||||
return { ...state, chartType: action.payload };
|
||||
case "SET_MEASURES":
|
||||
return { ...state, selectedMeasures: action.payload };
|
||||
case "SET_CUSTOM_MEASURES":
|
||||
return { ...state, customMeasures: action.payload };
|
||||
case "SET_DIMENSIONS":
|
||||
return { ...state, selectedDimensions: action.payload };
|
||||
case "SET_FILTERS":
|
||||
return { ...state, filters: action.payload };
|
||||
case "SET_FILTER_LOGIC":
|
||||
return { ...state, filterLogic: action.payload };
|
||||
case "SET_TIME_DIMENSION":
|
||||
return { ...state, timeDimension: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const queryReducer = (state: QueryState, action: Action): QueryState => {
|
||||
switch (action.type) {
|
||||
case "QUERY_START":
|
||||
return { ...state, isLoading: true, error: null };
|
||||
case "QUERY_SUCCESS":
|
||||
return {
|
||||
chartData: action.payload.data,
|
||||
query: action.payload.query,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
case "QUERY_ERROR":
|
||||
return { ...state, isLoading: false, error: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export function AdvancedChartBuilder({
|
||||
environmentId,
|
||||
initialChartType,
|
||||
initialQuery,
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
onSave,
|
||||
onAddToDashboard,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const onChartGeneratedRef = useRef(onChartGenerated);
|
||||
onChartGeneratedRef.current = onChartGenerated;
|
||||
const prevInitialChartTypeRef = useRef(initialChartType);
|
||||
|
||||
const getInitialState = useCallback((): ChartBuilderState => {
|
||||
if (initialQuery) {
|
||||
const parsedState = parseQueryToState(initialQuery, initialChartType);
|
||||
return {
|
||||
...initialState,
|
||||
...parsedState,
|
||||
chartType: parsedState.chartType || initialChartType || "",
|
||||
};
|
||||
}
|
||||
return { ...initialState, chartType: initialChartType || "" };
|
||||
}, [initialQuery, initialChartType]);
|
||||
|
||||
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
|
||||
const [queryState, dispatchQuery] = useReducer(queryReducer, {
|
||||
...initialQueryState,
|
||||
query: initialQuery || null,
|
||||
});
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showQuery, setShowQuery] = useState(false);
|
||||
const [showData, setShowData] = useState(false);
|
||||
const [dimensionsOpen, setDimensionsOpen] = useState(false);
|
||||
const [timeDimensionOpen, setTimeDimensionOpen] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [customAggregationsOpen, setCustomAggregationsOpen] = useState(false);
|
||||
const lastStateRef = useRef<string>("");
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const saveDashboard = useSaveDashboardDialogs({
|
||||
environmentId,
|
||||
getChartInput: () => {
|
||||
if (!queryState.chartData || !queryState.query || !state.chartType) return null;
|
||||
return { query: queryState.query, chartType: state.chartType };
|
||||
},
|
||||
onSave,
|
||||
onAddToDashboard,
|
||||
});
|
||||
|
||||
// Sync initialChartType only when the prop changes (not when state diverges)
|
||||
useEffect(() => {
|
||||
if (initialChartType && initialChartType !== prevInitialChartTypeRef.current) {
|
||||
prevInitialChartTypeRef.current = initialChartType;
|
||||
dispatch({ type: "SET_CHART_TYPE", payload: initialChartType });
|
||||
if (!initialQuery && !isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
}, [initialChartType, initialQuery, isInitialized]);
|
||||
|
||||
// Sync section open states when loading from initialQuery
|
||||
useEffect(() => {
|
||||
if (!initialQuery) return;
|
||||
const parsed = parseQueryToState(initialQuery, initialChartType);
|
||||
setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0);
|
||||
setTimeDimensionOpen(parsed.timeDimension != null);
|
||||
setFiltersOpen((parsed.filters?.length ?? 0) > 0);
|
||||
// Only set customAggregationsOpen to true when parsed has custom measures.
|
||||
// Never set to false here: parseQueryToState always returns customMeasures: [] because
|
||||
// Cube.js query format doesn't store custom measure definitions, so we'd incorrectly
|
||||
// turn off the toggle after running a query that uses custom measures.
|
||||
if ((parsed.customMeasures?.length ?? 0) > 0) {
|
||||
setCustomAggregationsOpen(true);
|
||||
}
|
||||
}, [initialQuery, initialChartType]);
|
||||
|
||||
// Keep time dimension toggle in sync when panel's disable clears the config
|
||||
useEffect(() => {
|
||||
if (state.timeDimension == null) setTimeDimensionOpen(false);
|
||||
}, [state.timeDimension]);
|
||||
|
||||
// Turn off filter toggle when the last filter is deleted
|
||||
useEffect(() => {
|
||||
if (state.filters.length === 0 && filtersOpen) setFiltersOpen(false);
|
||||
}, [state.filters.length, filtersOpen]);
|
||||
|
||||
// Turn off custom aggregations toggle when the last custom measure is removed
|
||||
useEffect(() => {
|
||||
if (state.customMeasures.length === 0 && customAggregationsOpen) setCustomAggregationsOpen(false);
|
||||
}, [state.customMeasures.length, customAggregationsOpen]);
|
||||
|
||||
// Initialize: execute initialQuery once (deps intentionally minimal to avoid redundant runs).
|
||||
// Skip when hidePreview is true because the parent component handles data loading.
|
||||
useEffect(() => {
|
||||
if (!initialQuery || isInitialized) return;
|
||||
setIsInitialized(true);
|
||||
|
||||
if (hidePreview) {
|
||||
// Sync lastStateRef so the reactive effect does not re-run the same query on mount.
|
||||
lastStateRef.current = JSON.stringify({
|
||||
chartType: state.chartType,
|
||||
measures: state.selectedMeasures,
|
||||
dimensions: state.selectedDimensions,
|
||||
filters: state.filters,
|
||||
timeDimension: state.timeDimension,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chartType = state.chartType;
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
executeQueryAction({ environmentId, query: initialQuery })
|
||||
.then((result) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
if (result?.serverError) {
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: getFormattedErrorMessage(result) });
|
||||
return;
|
||||
}
|
||||
const data = Array.isArray(result?.data) ? result.data : [];
|
||||
if (data.length > 0) {
|
||||
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: initialQuery } });
|
||||
lastStateRef.current = JSON.stringify({
|
||||
chartType,
|
||||
measures: state.selectedMeasures,
|
||||
dimensions: state.selectedDimensions,
|
||||
filters: state.filters,
|
||||
timeDimension: state.timeDimension,
|
||||
});
|
||||
if (onChartGeneratedRef.current && chartType) {
|
||||
onChartGeneratedRef.current({ query: initialQuery, chartType, data });
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: message });
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- init runs once; state/onChartGenerated via ref
|
||||
}, [initialQuery, environmentId, isInitialized]);
|
||||
|
||||
// Reactive query with debounce and cancellation
|
||||
useEffect(() => {
|
||||
if (!isInitialized || !state.chartType) return;
|
||||
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) return;
|
||||
|
||||
const stateHash = JSON.stringify({
|
||||
chartType: state.chartType,
|
||||
measures: state.selectedMeasures,
|
||||
dimensions: state.selectedDimensions,
|
||||
filters: state.filters,
|
||||
timeDimension: state.timeDimension,
|
||||
});
|
||||
if (stateHash === lastStateRef.current) return;
|
||||
lastStateRef.current = stateHash;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
const chartType = state.chartType;
|
||||
const updatedQuery = buildCubeQuery(state);
|
||||
const requestId = ++requestIdRef.current;
|
||||
dispatchQuery({ type: "QUERY_START" });
|
||||
|
||||
executeQueryAction({ environmentId, query: updatedQuery })
|
||||
.then((result) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
if (result?.serverError) {
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: getFormattedErrorMessage(result) });
|
||||
return;
|
||||
}
|
||||
const data = Array.isArray(result?.data) ? result.data : [];
|
||||
if (data.length > 0) {
|
||||
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: updatedQuery } });
|
||||
if (onChartGeneratedRef.current && chartType) {
|
||||
onChartGeneratedRef.current({ query: updatedQuery, chartType, data });
|
||||
}
|
||||
} else {
|
||||
dispatchQuery({
|
||||
type: "QUERY_ERROR",
|
||||
payload: t("environments.analysis.charts.no_data_returned"),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: message });
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced; onChartGenerated via ref
|
||||
}, [
|
||||
state.chartType,
|
||||
state.selectedMeasures,
|
||||
state.selectedDimensions,
|
||||
state.filters,
|
||||
state.filterLogic,
|
||||
state.customMeasures,
|
||||
state.timeDimension,
|
||||
isInitialized,
|
||||
environmentId,
|
||||
]);
|
||||
|
||||
const processQueryResult = useCallback(
|
||||
(
|
||||
result: Awaited<ReturnType<typeof executeQueryAction>>,
|
||||
cubeQuery: TChartQuery,
|
||||
chartType: TChartType,
|
||||
requestId: number
|
||||
) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
if (result?.serverError) {
|
||||
const errorMsg = getFormattedErrorMessage(result);
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: errorMsg });
|
||||
toast.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
const data = Array.isArray(result?.data) ? result.data : [];
|
||||
if (data.length === 0) {
|
||||
dispatchQuery({
|
||||
type: "QUERY_ERROR",
|
||||
payload: t("environments.analysis.charts.no_data_returned"),
|
||||
});
|
||||
toast.error(t("environments.analysis.charts.no_data_returned"));
|
||||
return;
|
||||
}
|
||||
dispatchQuery({ type: "QUERY_SUCCESS", payload: { data, query: cubeQuery } });
|
||||
toast.success(t("environments.analysis.charts.query_executed_successfully"));
|
||||
if (onChartGeneratedRef.current && chartType) {
|
||||
onChartGeneratedRef.current({ query: cubeQuery, chartType, data });
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleRunQuery = async () => {
|
||||
if (!state.chartType) {
|
||||
toast.error(t("environments.analysis.charts.please_select_chart_type"));
|
||||
return;
|
||||
}
|
||||
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
|
||||
toast.error(t("environments.analysis.charts.please_select_at_least_one_measure"));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchQuery({ type: "QUERY_START" });
|
||||
const cubeQuery = buildCubeQuery(state);
|
||||
const chartType = state.chartType;
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
try {
|
||||
const result = await executeQueryAction({ environmentId, query: cubeQuery });
|
||||
processQueryResult(result, cubeQuery, chartType, requestId);
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
|
||||
dispatchQuery({ type: "QUERY_ERROR", payload: message });
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const { chartData, query, isLoading, error } = queryState;
|
||||
const showSaveDashboard = !onSave || !onAddToDashboard;
|
||||
|
||||
return (
|
||||
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
|
||||
<div className="mx-1 space-y-2">
|
||||
{!hidePreview && (
|
||||
<>
|
||||
<ChartBuilderGuide />
|
||||
<ChartTypeSelector
|
||||
selectedChartType={state.chartType}
|
||||
onChartTypeSelect={(chartType) => dispatch({ type: "SET_CHART_TYPE", payload: chartType })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
|
||||
<MeasuresPanel
|
||||
selectedMeasures={state.selectedMeasures}
|
||||
customMeasures={state.customMeasures}
|
||||
customAggregationsOpen={customAggregationsOpen}
|
||||
onCustomAggregationsOpenChange={(open) => {
|
||||
setCustomAggregationsOpen(open);
|
||||
if (!open) {
|
||||
dispatch({ type: "SET_CUSTOM_MEASURES", payload: [] });
|
||||
} else if (state.customMeasures.length === 0) {
|
||||
const dimensionOptions = FEEDBACK_FIELDS.dimensions
|
||||
.filter((d) => d.type === "number")
|
||||
.map((d) => d.id);
|
||||
dispatch({
|
||||
type: "SET_CUSTOM_MEASURES",
|
||||
payload: [
|
||||
{
|
||||
id: `measure-${crypto.randomUUID()}`,
|
||||
field: dimensionOptions[0] ?? "",
|
||||
aggregation: "avg",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMeasuresChange={(measures) => dispatch({ type: "SET_MEASURES", payload: measures })}
|
||||
onCustomMeasuresChange={(measures) =>
|
||||
dispatch({ type: "SET_CUSTOM_MEASURES", payload: measures })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={dimensionsOpen}
|
||||
onToggle={(checked) => {
|
||||
setDimensionsOpen(checked);
|
||||
if (!checked) dispatch({ type: "SET_DIMENSIONS", payload: [] });
|
||||
}}
|
||||
htmlId="chart-dimensions-toggle"
|
||||
title={t("environments.analysis.charts.dimensions")}
|
||||
description={t("environments.analysis.charts.dimensions_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<DimensionsPanel
|
||||
hideTitle
|
||||
selectedDimensions={state.selectedDimensions}
|
||||
onDimensionsChange={(dimensions) => dispatch({ type: "SET_DIMENSIONS", payload: dimensions })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={timeDimensionOpen}
|
||||
onToggle={(checked) => {
|
||||
setTimeDimensionOpen(checked);
|
||||
if (!checked) dispatch({ type: "SET_TIME_DIMENSION", payload: null });
|
||||
else if (!state.timeDimension) {
|
||||
dispatch({
|
||||
type: "SET_TIME_DIMENSION",
|
||||
payload: {
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: "last 30 days",
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-time-dimension-toggle"
|
||||
title={t("environments.analysis.charts.time_dimension")}
|
||||
description={t("environments.analysis.charts.time_dimension_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<TimeDimensionPanel
|
||||
hideTitle
|
||||
timeDimension={state.timeDimension}
|
||||
onTimeDimensionChange={(config) => dispatch({ type: "SET_TIME_DIMENSION", payload: config })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={filtersOpen}
|
||||
onToggle={(checked) => {
|
||||
setFiltersOpen(checked);
|
||||
if (!checked) {
|
||||
dispatch({ type: "SET_FILTERS", payload: [] });
|
||||
} else if (state.filters.length === 0) {
|
||||
const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0];
|
||||
dispatch({
|
||||
type: "SET_FILTERS",
|
||||
payload: [
|
||||
{
|
||||
field: firstField?.id ?? "",
|
||||
operator: "equals" as const,
|
||||
values: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-filters-toggle"
|
||||
title={t("environments.analysis.charts.filters")}
|
||||
description={t("environments.analysis.charts.filters_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<FiltersPanel
|
||||
hideTitle
|
||||
filters={state.filters}
|
||||
filterLogic={state.filterLogic}
|
||||
onFiltersChange={(filters) => dispatch({ type: "SET_FILTERS", payload: filters })}
|
||||
onFilterLogicChange={(logic) => dispatch({ type: "SET_FILTER_LOGIC", payload: logic })}
|
||||
/>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !state.chartType}>
|
||||
{isLoading ? <LoadingSpinner /> : t("environments.analysis.charts.run_query")}
|
||||
</Button>
|
||||
{chartData && showSaveDashboard && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => saveDashboard.setIsSaveDialogOpen(true)}>
|
||||
{t("environments.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => saveDashboard.setIsAddToDashboardDialogOpen(true)}>
|
||||
{t("environments.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hidePreview && (
|
||||
<AdvancedChartPreview
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
chartData={chartData}
|
||||
chartType={state.chartType}
|
||||
query={query}
|
||||
showQuery={showQuery}
|
||||
onShowQueryChange={setShowQuery}
|
||||
showData={showData}
|
||||
onShowDataChange={setShowData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!onSave && (
|
||||
<SaveChartDialog
|
||||
open={saveDashboard.isSaveDialogOpen}
|
||||
onOpenChange={saveDashboard.setIsSaveDialogOpen}
|
||||
chartName={saveDashboard.chartName}
|
||||
onChartNameChange={saveDashboard.setChartName}
|
||||
onSave={saveDashboard.handleSaveChart}
|
||||
isSaving={saveDashboard.isSaving}
|
||||
/>
|
||||
)}
|
||||
{!onAddToDashboard && (
|
||||
<AddToDashboardDialog
|
||||
isOpen={saveDashboard.isAddToDashboardDialogOpen}
|
||||
onOpenChange={saveDashboard.setIsAddToDashboardDialogOpen}
|
||||
chartName={saveDashboard.chartName}
|
||||
onChartNameChange={saveDashboard.setChartName}
|
||||
dashboards={saveDashboard.dashboards}
|
||||
selectedDashboardId={saveDashboard.selectedDashboardId}
|
||||
onDashboardSelect={saveDashboard.setSelectedDashboardId}
|
||||
onConfirm={saveDashboard.handleAddToDashboard}
|
||||
isSaving={saveDashboard.isSaving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CodeIcon, DatabaseIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import { QueryViewer } from "@/modules/ee/analysis/charts/components/query-viewer";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface AdvancedChartPreviewProps {
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
chartData: TChartDataRow[] | null;
|
||||
chartType: TChartType | "";
|
||||
query: TChartQuery | null;
|
||||
showQuery: boolean;
|
||||
onShowQueryChange: (open: boolean) => void;
|
||||
showData: boolean;
|
||||
onShowDataChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AdvancedChartPreview({
|
||||
error,
|
||||
isLoading,
|
||||
chartData,
|
||||
chartType,
|
||||
query,
|
||||
showQuery,
|
||||
onShowQueryChange,
|
||||
showData,
|
||||
onShowDataChange,
|
||||
}: Readonly<AdvancedChartPreviewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const hasData = chartData && chartData.length > 0 && !isLoading && chartType && query;
|
||||
const isEmpty = !chartData && !isLoading && !error;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.chart_preview")}
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<ChartRenderer chartType={chartType} data={chartData} query={query} />
|
||||
</div>
|
||||
|
||||
<QueryViewer
|
||||
query={query}
|
||||
isOpen={showQuery}
|
||||
onOpenChange={onShowQueryChange}
|
||||
trigger={
|
||||
<Collapsible.CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<CodeIcon className="mr-2 h-4 w-4" />
|
||||
{showQuery ? t("common.hide") : t("common.view")}{" "}
|
||||
{t("environments.analysis.charts.query_label")}
|
||||
</Button>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
}
|
||||
/>
|
||||
|
||||
<Collapsible.Root open={showData} onOpenChange={onShowDataChange}>
|
||||
<Collapsible.CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<DatabaseIcon className="mr-2 h-4 w-4" />
|
||||
{showData ? t("common.hide") : t("common.view")}{" "}
|
||||
{t("environments.analysis.charts.data_label")}
|
||||
</Button>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<DataViewer data={chartData} />
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.advanced_chart_builder_config_prompt")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { ActivityIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface AIQuerySectionProps {
|
||||
environmentId: string;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
}
|
||||
|
||||
export function AIQuerySection({ environmentId, onChartGenerated }: Readonly<AIQuerySectionProps>) {
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!userQuery.trim()) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const result = await generateAIChartAction({
|
||||
environmentId,
|
||||
prompt: userQuery.trim(),
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
onChartGenerated(result.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<ActivityIcon className="text-brand-dark h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.ai_query_section_title")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.ai_query_section_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex gap-4" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.ai_query_placeholder")}
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
className="flex-1"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}
|
||||
className="bg-brand-dark hover:bg-brand-dark/90">
|
||||
{t("common.generate")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartBuilderGuideProps {
|
||||
/** Optional trigger; when not provided, caller renders their own */
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChartBuilderGuide({ trigger }: Readonly<ChartBuilderGuideProps>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger ?? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setIsOpen(true)}>
|
||||
<HelpCircle className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.guide_button")}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={isOpen} onOpenChange={(isOpen) => !isOpen && setIsOpen(false)}>
|
||||
<DialogContent width="wide" className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.guide_title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_chart_type")}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t("environments.analysis.charts.guide_chart_type_desc")}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_measures")}
|
||||
</h3>
|
||||
<p className="mb-2 text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_measures_predefined")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_measures_custom")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_dimensions")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_dimensions_desc")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_time_dimension")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_time_dimension_desc")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-1 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_filters")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("environments.analysis.charts.guide_filters_desc")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md mb-2 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.guide_quick_ref")}
|
||||
</h3>
|
||||
<dl className="space-y-1.5 text-sm text-gray-600">
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Measure: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_measure")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Dimension: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_dimension")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Custom aggregation: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_custom")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Time dimension: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_time")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-medium text-gray-900">Filter: </dt>
|
||||
<dd className="inline">{t("environments.analysis.charts.guide_term_filter")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, SaveIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onAddToDashboardClick: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
}: Readonly<ChartDialogFooterProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ChartDialogLoadingViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChartDialogLoadingView({ open, onClose }: Readonly<ChartDialogLoadingViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogTitle className="sr-only">{t("common.loading")}</DialogTitle>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ChartDropdownMenuProps {
|
||||
environmentId: string;
|
||||
chart: TChartWithCreator;
|
||||
onEdit?: (chartId: string) => void;
|
||||
onInteractionStart?: () => void;
|
||||
}
|
||||
|
||||
export function ChartDropdownMenu({
|
||||
environmentId,
|
||||
chart,
|
||||
onEdit,
|
||||
onInteractionStart,
|
||||
}: Readonly<ChartDropdownMenuProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
|
||||
const handleDeleteChart = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteChartAction({ environmentId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
|
||||
setDeleteDialogOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateChart = async () => {
|
||||
onInteractionStart?.();
|
||||
setIsDuplicating(true);
|
||||
try {
|
||||
const result = await duplicateChartAction({ environmentId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.analysis.charts.chart_duplicated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_duplication_error")
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.charts.chart_duplication_error"));
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
onInteractionStart?.();
|
||||
onEdit?.(chart.id);
|
||||
};
|
||||
|
||||
const handleOpenDeleteDialog = () => {
|
||||
onInteractionStart?.();
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<Button variant="outline" className="px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
|
||||
<MoreVertical className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem icon={<SquarePenIcon className="size-4" />} onClick={handleEdit}>
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<CopyIcon className="size-4" />}
|
||||
onClick={handleDuplicateChart}
|
||||
disabled={isDuplicating}>
|
||||
{t("common.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
onClick={handleOpenDeleteDialog}
|
||||
disabled={isDeleting}>
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("common.chart")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={handleDeleteChart}
|
||||
text={t("environments.analysis.charts.delete_chart_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, DatabaseIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
|
||||
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
|
||||
interface ChartPreviewProps {
|
||||
chartData: AnalyticsResponse | null;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function ChartPreview({ chartData, isLoading = false, error }: Readonly<ChartPreviewProps>) {
|
||||
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = chartData?.data ?? [];
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "chart" || value === "data") {
|
||||
setActiveTab(value);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || chartData?.error) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-red-600">
|
||||
{error || chartData?.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartData) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.analysis.charts.chart")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
|
||||
{t("environments.analysis.charts.chart_data_tab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<ChartRenderer chartType={chartData.chartType} data={data} query={chartData.query} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="data" className="mt-0">
|
||||
<DataViewer data={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 font-semibold text-gray-900">{t("environments.analysis.charts.chart_preview")}</h3>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { TChartQuery } from "@formbricks/types/analysis";
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_BRAND_LIGHT,
|
||||
formatCellValue,
|
||||
preparePieData,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import type { ChartConfig } from "@/modules/ui/components/chart";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
|
||||
|
||||
function formatXAxisTick(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
let str: string;
|
||||
if (typeof value === "string") str = value;
|
||||
else if (typeof value === "number") str = String(value);
|
||||
else return "";
|
||||
const date = parseISO(str);
|
||||
if (isValid(date)) return format(date, "MMM d, yyyy");
|
||||
return str;
|
||||
}
|
||||
|
||||
function ChartTooltipRow({ value, dataKey }: Readonly<{ value: unknown; dataKey: string }>) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border border-current"
|
||||
style={{
|
||||
backgroundColor: CHART_BRAND_DARK,
|
||||
borderColor: CHART_BRAND_DARK,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between leading-none">
|
||||
<span className="text-muted-foreground">{formatCubeColumnHeader(dataKey)}</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">{formatCellValue(value)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates a tooltip formatter bound to dataKey for Cartesian charts. Defined at module level to avoid Sonar "component in parent" warnings. */
|
||||
function createTooltipFormatter(dataKey: string) {
|
||||
const Formatter = (value: unknown) => <ChartTooltipRow value={value} dataKey={dataKey} />;
|
||||
Formatter.displayName = "ChartTooltipFormatter";
|
||||
return Formatter;
|
||||
}
|
||||
|
||||
/** Tooltip content for bar/line/area charts with formatted label and value. Extracted to avoid inline component definitions. */
|
||||
function CartesianChartTooltip({ dataKey }: Readonly<{ dataKey: string }>) {
|
||||
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={createTooltipFormatter(dataKey)} />;
|
||||
}
|
||||
|
||||
/** Shared layout for bar, line, and area charts to avoid duplicating grid/axis/tooltip boilerplate. */
|
||||
function CartesianChart({
|
||||
data,
|
||||
xAxisKey,
|
||||
dataKey,
|
||||
chartConfig,
|
||||
chart: Chart,
|
||||
children,
|
||||
}: Readonly<{
|
||||
data: TChartDataRow[];
|
||||
xAxisKey: string;
|
||||
dataKey: string;
|
||||
chartConfig: ChartConfig;
|
||||
chart: ElementType;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="h-64 w-full">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||
<Chart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xAxisKey}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<CartesianChartTooltip dataKey={dataKey} />} />
|
||||
{children}
|
||||
</Chart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartRendererProps {
|
||||
chartType: TChartType;
|
||||
data: TChartDataRow[];
|
||||
query: TChartQuery;
|
||||
}
|
||||
|
||||
export function ChartRenderer({ chartType, data, query }: Readonly<ChartRendererProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
{t("environments.analysis.charts.no_data_available")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dataKey = query.measures?.[0] ?? Object.keys(data[0])[0] ?? "value";
|
||||
const xAxisKey =
|
||||
query.dimensions?.[0] ??
|
||||
query.timeDimensions?.[0]?.dimension ??
|
||||
Object.keys(data[0]).find((k) => k !== dataKey) ??
|
||||
"key";
|
||||
const chartConfig: ChartConfig = {
|
||||
[dataKey]: {
|
||||
label: formatCubeColumnHeader(dataKey),
|
||||
color: CHART_BRAND_DARK,
|
||||
},
|
||||
};
|
||||
|
||||
switch (chartType) {
|
||||
case "bar":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={BarChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKey={dataKey}
|
||||
chartConfig={chartConfig}>
|
||||
<Bar dataKey={dataKey} fill={CHART_BRAND_DARK} radius={4} />
|
||||
</CartesianChart>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={LineChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKey={dataKey}
|
||||
chartConfig={chartConfig}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={CHART_BRAND_DARK}
|
||||
strokeWidth={3}
|
||||
dot={{ fill: CHART_BRAND_DARK, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</CartesianChart>
|
||||
);
|
||||
case "area":
|
||||
return (
|
||||
<CartesianChart
|
||||
chart={AreaChart}
|
||||
data={data}
|
||||
xAxisKey={xAxisKey}
|
||||
dataKey={dataKey}
|
||||
chartConfig={chartConfig}>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={CHART_BRAND_DARK}
|
||||
fill={CHART_BRAND_LIGHT}
|
||||
fillOpacity={0.4}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</CartesianChart>
|
||||
);
|
||||
case "pie": {
|
||||
const pieResult = preparePieData(data, dataKey);
|
||||
if (!pieResult) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
{t("environments.analysis.charts.no_valid_data_to_display")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { processedData, colors } = pieResult;
|
||||
|
||||
return (
|
||||
<div className="h-64 w-full min-w-0">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full min-w-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={processedData}
|
||||
dataKey={dataKey}
|
||||
nameKey={xAxisKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={({ name, percent }) => {
|
||||
if (!percent) return "";
|
||||
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
|
||||
}}>
|
||||
{processedData.map((row, index) => {
|
||||
const rowKey = row[xAxisKey] ?? `row-${index}`;
|
||||
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
|
||||
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
|
||||
})}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent />}
|
||||
formatter={(value: number | string, name: string) => [
|
||||
formatCellValue(value),
|
||||
formatCubeColumnHeader(name),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "big_number": {
|
||||
const total = data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-gray-900">{total.toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-gray-500">{formatCubeColumnHeader(dataKey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
{t("environments.analysis.charts.chart_type_not_supported", { chartType })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { convertDateString, timeSinceDate } from "@/lib/time";
|
||||
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
|
||||
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartRowProps {
|
||||
chart: TChartWithCreator;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ChartRow({ chart, environmentId, isReadOnly }: Readonly<ChartRowProps>) {
|
||||
const IconComponent = CHART_TYPE_ICONS[chart.type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
|
||||
|
||||
return (
|
||||
<div className="grid h-12 w-full grid-cols-7 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-6 grid grid-cols-6 content-center p-2">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
{convertDateString(chart.createdAt.toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{timeSinceDate(new Date(chart.updatedAt))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
|
||||
{!isReadOnly && <ChartDropdownMenu environmentId={environmentId} chart={chart} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartTypeSelectorProps {
|
||||
selectedChartType: TChartType | "";
|
||||
onChartTypeSelect: (chartType: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ChartTypeSelector({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ChartTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.chart_builder_choose_chart_type")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Delay } from "@suspensive/react";
|
||||
import { Suspense, use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
|
||||
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
interface ChartsListContentProps {
|
||||
chartsPromise: Promise<TChartWithCreator[]>;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const ChartsListContent = ({
|
||||
chartsPromise,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<ChartsListContentProps>) => {
|
||||
const charts = use(chartsPromise);
|
||||
|
||||
return <ChartsList charts={charts} environmentId={environmentId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
interface ChartsListPageProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export async function ChartsListPage({ environmentId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
const chartsPromise = getChartsWithCreator(project.id);
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
environmentId={environmentId}
|
||||
cta={isReadOnly ? undefined : <CreateChartButton environmentId={environmentId} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Delay ms={200}>
|
||||
<ChartsListSkeleton
|
||||
columnHeaders={[
|
||||
t("common.title"),
|
||||
t("common.created_by"),
|
||||
t("common.created_at"),
|
||||
t("common.updated_at"),
|
||||
]}
|
||||
/>
|
||||
</Delay>
|
||||
}>
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const SKELETON_ROWS = 5;
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="grid h-12 w-full animate-pulse grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center gap-4 pl-6">
|
||||
<div className="h-5 w-5 rounded bg-gray-200" />
|
||||
<div className="h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-16 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartsListSkeletonProps {
|
||||
columnHeaders: string[];
|
||||
}
|
||||
|
||||
export function ChartsListSkeleton({ columnHeaders }: Readonly<ChartsListSkeletonProps>) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||
<SkeletonRow key={`skeleton-row-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartsListProps {
|
||||
charts: TChartWithCreator[];
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ChartsList({ charts, environmentId, isReadOnly }: Readonly<ChartsListProps>) {
|
||||
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const filteredCharts = charts;
|
||||
|
||||
const getChartIcon = (type: string) => {
|
||||
const IconComponent = CHART_TYPE_ICONS[type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
|
||||
return <IconComponent className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const handleChartClick = (chartId: string) => {
|
||||
setEditingChartId(chartId);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setIsEditDialogOpen(false);
|
||||
setEditingChartId(undefined);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: React.KeyboardEvent, chartId: string) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleChartClick(chartId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{filteredCharts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.analysis.charts.no_charts_found")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{filteredCharts.map((chart) => (
|
||||
// Cannot use native <button>; row contains dropdown trigger (nested interactive invalid)
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
key={chart.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleChartClick(chart.id)}
|
||||
onKeyDown={(e) => handleRowKeyDown(e, chart.id)}
|
||||
aria-label={t("environments.analysis.charts.open_chart", { name: chart.name })}
|
||||
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||
{getChartIcon(chart.type)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
{format(new Date(chart.createdAt), "do 'of' MMMM, yyyy")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
{formatDistanceToNow(new Date(chart.updatedAt), {
|
||||
addSuffix: true,
|
||||
}).replace("about", "")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="col-span-1 my-auto flex items-center justify-end pr-6"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{!isReadOnly && (
|
||||
<ChartDropdownMenu
|
||||
environmentId={environmentId}
|
||||
chart={chart}
|
||||
onEdit={(chartId) => {
|
||||
setEditingChartId(chartId);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<CreateChartDialog
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
environmentId={environmentId}
|
||||
chartId={editingChartId}
|
||||
initialChart={editingChartId ? filteredCharts.find((c) => c.id === editingChartId) : undefined}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ConfigureChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentChartType: string;
|
||||
configuredChartType: string | null;
|
||||
onChartTypeSelect: (type: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ConfigureChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentChartType,
|
||||
configuredChartType,
|
||||
onChartTypeSelect,
|
||||
onReset,
|
||||
}: Readonly<ConfigureChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.configure_title")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.analysis.charts.configure_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-md mb-3 font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.configure_type_label")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = (configuredChartType || currentChartType) === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
|
||||
isSelected
|
||||
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
|
||||
: "border-gray-200"
|
||||
)}
|
||||
aria-label={chart.label}>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onReset} className="text-sm">
|
||||
{t("environments.analysis.charts.reset_to_ai_suggestion")}
|
||||
</Button>
|
||||
{configuredChartType && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.original")}:{" "}
|
||||
{chartTypes.find((c) => c.id === currentChartType)?.label ?? currentChartType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t("environments.analysis.charts.apply_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateChartButtonProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function CreateChartButton({ environmentId }: Readonly<CreateChartButtonProps>) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.create_chart")}
|
||||
</Button>
|
||||
<CreateChartDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} environmentId={environmentId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
|
||||
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
|
||||
import { useCreateChartDialog } from "@/modules/ee/analysis/charts/hooks/use-create-chart-dialog";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CreateChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
environmentId: string;
|
||||
chartId?: string;
|
||||
/** Pre-loaded chart metadata from list; skips getChartAction when provided */
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function CreateChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
chartId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
}: Readonly<CreateChartDialogProps>) {
|
||||
const hook = useCreateChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
chartId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const {
|
||||
chartData,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
initialQuery,
|
||||
setSelectedChartType,
|
||||
handleChartTypeChange,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
isSaving,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
shouldShowAdvancedBuilder,
|
||||
handleChartGenerated,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
handleClose,
|
||||
handleAdvancedBuilderSave,
|
||||
handleAdvancedBuilderAddToDashboard,
|
||||
} = hook;
|
||||
|
||||
if (chartId && isLoadingChart && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (chartId && (chartData || initialChart)) {
|
||||
return (
|
||||
<EditChartView
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
environmentId={environmentId}
|
||||
chartData={chartData ?? null}
|
||||
initialQuery={initialQuery}
|
||||
isLoadingChart={isLoadingChart}
|
||||
chartLoadError={chartLoadError}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeChange={handleChartTypeChange}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
onAdvancedBuilderSave={handleAdvancedBuilderSave}
|
||||
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onAddToDashboard={handleAddToDashboard}
|
||||
onSave={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
|
||||
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateChartView
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
environmentId={environmentId}
|
||||
chartId={chartId}
|
||||
chartData={chartData}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
selectedChartType={selectedChartType}
|
||||
onSelectedChartTypeChange={setSelectedChartType}
|
||||
shouldShowAdvancedBuilder={shouldShowAdvancedBuilder}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
onAdvancedBuilderSave={handleAdvancedBuilderSave}
|
||||
onAdvancedBuilderAddToDashboard={handleAdvancedBuilderAddToDashboard}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onAddToDashboard={handleAddToDashboard}
|
||||
onSave={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
isSaveDialogOpen={isSaveDialogOpen}
|
||||
onSaveDialogOpenChange={setIsSaveDialogOpen}
|
||||
isAddToDashboardDialogOpen={isAddToDashboardDialogOpen}
|
||||
onAddToDashboardDialogOpenChange={setIsAddToDashboardDialogOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
|
||||
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
|
||||
interface CreateChartViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
environmentId: string;
|
||||
chartId?: string;
|
||||
chartData: AnalyticsResponse | null;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
selectedChartType: TChartType | "";
|
||||
onSelectedChartTypeChange: (type: TChartType) => void;
|
||||
shouldShowAdvancedBuilder: boolean;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
onAdvancedBuilderSave: (savedChartId: string) => void;
|
||||
onAdvancedBuilderAddToDashboard: (savedChartId: string, _dashboardId?: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onAddToDashboard: () => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
isSaveDialogOpen: boolean;
|
||||
onSaveDialogOpenChange: (open: boolean) => void;
|
||||
isAddToDashboardDialogOpen: boolean;
|
||||
onAddToDashboardDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateChartView({
|
||||
open,
|
||||
onClose,
|
||||
environmentId,
|
||||
chartId,
|
||||
chartData,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
selectedChartType,
|
||||
onSelectedChartTypeChange,
|
||||
shouldShowAdvancedBuilder,
|
||||
onChartGenerated,
|
||||
onAdvancedBuilderSave,
|
||||
onAdvancedBuilderAddToDashboard,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onAddToDashboard,
|
||||
onSave,
|
||||
isSaving,
|
||||
isSaveDialogOpen,
|
||||
onSaveDialogOpenChange,
|
||||
isAddToDashboardDialogOpen,
|
||||
onAddToDashboardDialogOpenChange,
|
||||
}: Readonly<CreateChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartData) {
|
||||
chartPreviewRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [chartData]);
|
||||
|
||||
const handleAdvancedChartGenerated = (data: AnalyticsResponse) => {
|
||||
onChartGenerated(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{chartId
|
||||
? t("environments.analysis.charts.edit_chart_title")
|
||||
: t("environments.analysis.charts.create_chart")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{chartId
|
||||
? t("environments.analysis.charts.edit_chart_description")
|
||||
: t("environments.analysis.charts.create_chart_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4">
|
||||
<AIQuerySection environmentId={environmentId} onChartGenerated={onChartGenerated} />
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-gray-50 px-2 text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ChartBuilderGuide />
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={onSelectedChartTypeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shouldShowAdvancedBuilder && (
|
||||
<AdvancedChartBuilder
|
||||
environmentId={environmentId}
|
||||
initialChartType={selectedChartType || chartData?.chartType}
|
||||
initialQuery={chartData?.query}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleAdvancedChartGenerated}
|
||||
onSave={onAdvancedBuilderSave}
|
||||
onAddToDashboard={onAdvancedBuilderAddToDashboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartData && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
{chartData && (
|
||||
<>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={() => onSaveDialogOpenChange(true)}
|
||||
onAddToDashboardClick={() => onAddToDashboardDialogOpenChange(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SaveChartDialog
|
||||
open={isSaveDialogOpen}
|
||||
onOpenChange={onSaveDialogOpenChange}
|
||||
chartName={chartName}
|
||||
onChartNameChange={onChartNameChange}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={onAddToDashboardDialogOpenChange}
|
||||
chartName={chartName}
|
||||
onChartNameChange={onChartNameChange}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={onDashboardSelect}
|
||||
onConfirm={onAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { DatabaseIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatCellValue } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface DataViewerProps {
|
||||
data: TChartDataRow[];
|
||||
}
|
||||
|
||||
export function DataViewer({ data }: Readonly<DataViewerProps>) {
|
||||
const { t } = useTranslation();
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-500">{t("environments.analysis.charts.no_data_available")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = Object.keys(data[0]);
|
||||
const displayData = data.slice(0, 50);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.chart_data")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto rounded bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
{columns.map((key) => (
|
||||
<th key={key} className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
|
||||
{formatCubeColumnHeader(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayData.map((row, index) => {
|
||||
const rowKey = Object.values(row)[0] ? String(Object.values(row)[0]) : `row-${index}`;
|
||||
return (
|
||||
<tr key={`data-row-${rowKey}-${index}`} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
{Object.entries(row).map(([key, value]) => (
|
||||
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
|
||||
{formatCellValue(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.length > 50 && (
|
||||
<div className="px-3 py-2 text-xs text-gray-500">
|
||||
{t("environments.analysis.charts.showing_first_n_of", { n: 50, count: data.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
|
||||
interface DimensionsPanelProps {
|
||||
selectedDimensions: string[];
|
||||
onDimensionsChange: (dimensions: string[]) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function DimensionsPanel({
|
||||
selectedDimensions,
|
||||
onDimensionsChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<DimensionsPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||
value: d.id,
|
||||
label: [d.label, d.description].filter(Boolean).join(" - "),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.dimensions")}
|
||||
</h3>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.group_by")}</label>
|
||||
<MultiSelect
|
||||
options={dimensionOptions}
|
||||
value={selectedDimensions}
|
||||
onChange={onDimensionsChange}
|
||||
placeholder={t("environments.analysis.charts.select_measures")}
|
||||
/>
|
||||
<p className="text-sm text-gray-500">{t("environments.analysis.charts.group_by_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { ChartBuilderGuide } from "@/modules/ee/analysis/charts/components/chart-builder-guide";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface EditChartViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
environmentId: string;
|
||||
chartData: AnalyticsResponse | null;
|
||||
/** Query from initialChart when chartData is still loading */
|
||||
initialQuery?: AnalyticsResponse["query"];
|
||||
isLoadingChart?: boolean;
|
||||
chartLoadError?: string | null;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
selectedChartType: TChartType | "";
|
||||
onChartTypeChange: (type: TChartType) => void;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
onAdvancedBuilderSave: (savedChartId: string) => void;
|
||||
onAdvancedBuilderAddToDashboard: (savedChartId: string, dashboardId?: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onAddToDashboard: () => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
isAddToDashboardDialogOpen: boolean;
|
||||
onAddToDashboardDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditChartView({
|
||||
open,
|
||||
onClose,
|
||||
environmentId,
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart = false,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
selectedChartType,
|
||||
onChartTypeChange,
|
||||
onChartGenerated,
|
||||
onAdvancedBuilderSave,
|
||||
onAdvancedBuilderAddToDashboard,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
onDashboardSelect,
|
||||
onAddToDashboard,
|
||||
onSave,
|
||||
isSaving,
|
||||
isAddToDashboardDialogOpen,
|
||||
onAddToDashboardDialogOpenChange,
|
||||
}: Readonly<EditChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.edit_chart_title")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.analysis.charts.edit_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 px-1">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="edit-chart-name" className="text-sm">
|
||||
{t("environments.analysis.charts.chart_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-chart-name"
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<ChartBuilderGuide />
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={onChartTypeChange}
|
||||
/>
|
||||
</div>
|
||||
<AdvancedChartBuilder
|
||||
environmentId={environmentId}
|
||||
initialChartType={selectedChartType || chartData?.chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={onChartGenerated}
|
||||
onSave={onAdvancedBuilderSave}
|
||||
onAddToDashboard={onAdvancedBuilderAddToDashboard}
|
||||
/>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
</DialogBody>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={onSave}
|
||||
onAddToDashboardClick={() => onAddToDashboardDialogOpenChange(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={onAddToDashboardDialogOpenChange}
|
||||
chartName={chartName}
|
||||
onChartNameChange={onChartNameChange}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={onDashboardSelect}
|
||||
onConfirm={onAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
getFieldById,
|
||||
getFilterOperatorsForType,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface FiltersPanelProps {
|
||||
filters: FilterRow[];
|
||||
filterLogic: "and" | "or";
|
||||
onFiltersChange: (filters: FilterRow[]) => void;
|
||||
onFilterLogicChange: (logic: "and" | "or") => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function FiltersPanel({
|
||||
filters,
|
||||
filterLogic,
|
||||
onFiltersChange,
|
||||
onFilterLogicChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<FiltersPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const fieldOptions = [
|
||||
...FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.label,
|
||||
type: d.type,
|
||||
})),
|
||||
...FEEDBACK_FIELDS.measures.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.label,
|
||||
type: "number" as TFilterFieldType,
|
||||
})),
|
||||
];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const firstField = fieldOptions[0];
|
||||
onFiltersChange([
|
||||
...filters,
|
||||
{
|
||||
field: firstField?.value || "",
|
||||
operator: "equals",
|
||||
values: null,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (index: number) => {
|
||||
onFiltersChange(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
|
||||
const updated = [...filters];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
// Reset values if operator changed to set/notSet
|
||||
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
|
||||
updated[index].values = null;
|
||||
}
|
||||
onFiltersChange(updated);
|
||||
};
|
||||
|
||||
const getValueInput = (filter: FilterRow, index: number) => {
|
||||
const field = getFieldById(filter.field);
|
||||
const fieldType = (field?.type || "string") as TFilterFieldType;
|
||||
|
||||
// For set/notSet operators, no value input needed
|
||||
if (filter.operator === "set" || filter.operator === "notSet") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For number fields with comparison operators, use number input
|
||||
if (
|
||||
fieldType === "number" &&
|
||||
(filter.operator === "gt" ||
|
||||
filter.operator === "gte" ||
|
||||
filter.operator === "lt" ||
|
||||
filter.operator === "lte")
|
||||
) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [Number(e.target.value)] : null,
|
||||
})
|
||||
}
|
||||
className="w-[150px] bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For equals/notEquals with string fields, allow single value
|
||||
if ((filter.operator === "equals" || filter.operator === "notEquals") && fieldType === "string") {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [e.target.value] : null,
|
||||
})
|
||||
}
|
||||
className="w-[200px] bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For contains/notContains, allow multiple values (multi-select)
|
||||
if (filter.operator === "contains" || filter.operator === "notContains") {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [e.target.value] : null,
|
||||
})
|
||||
}
|
||||
className="w-[200px] bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: single value input
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.enter_value")}
|
||||
value={filter.values?.[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateFilter(index, {
|
||||
values: e.target.value ? [e.target.value] : null,
|
||||
})
|
||||
}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const hasFilters = filters.length > 0;
|
||||
const hasMultipleFilters = filters.length > 1;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{hasMultipleFilters && (
|
||||
<div className={`flex items-center ${hideTitle ? "justify-end" : "justify-between"}`}>
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.filters")}
|
||||
</h3>
|
||||
)}
|
||||
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
|
||||
<SelectTrigger className="w-[100px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="and">{t("common.and")}</SelectItem>
|
||||
<SelectItem value="or">{t("environments.analysis.charts.or_filter_logic")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filters.map((filter, index) => {
|
||||
const field = getFieldById(filter.field);
|
||||
const fieldType = (field?.type || "string") as "string" | "number" | "time";
|
||||
const operators = getFilterOperatorsForType(fieldType);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filter.operator + index}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||
<Select
|
||||
value={filter.field}
|
||||
onValueChange={(value) => {
|
||||
const newField = getFieldById(value);
|
||||
const newType = (newField?.type || "string") as TFilterFieldType;
|
||||
const newOperators = getFilterOperatorsForType(newType);
|
||||
handleUpdateFilter(index, {
|
||||
field: value,
|
||||
operator: newOperators[0] || "equals",
|
||||
values: null,
|
||||
});
|
||||
}}>
|
||||
<SelectTrigger className="w-[200px] bg-white">
|
||||
<SelectValue placeholder={t("environments.analysis.charts.select_field")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateFilter(index, {
|
||||
operator: value,
|
||||
})
|
||||
}>
|
||||
<SelectTrigger className="w-[150px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op} value={op}>
|
||||
{op === "equals" && t("environments.analysis.charts.equals")}
|
||||
{op === "notEquals" && t("environments.analysis.charts.not_equals")}
|
||||
{op === "contains" && t("environments.analysis.charts.contains")}
|
||||
{op === "notContains" && t("environments.analysis.charts.not_contains")}
|
||||
{op === "set" && t("environments.analysis.charts.is_set")}
|
||||
{op === "notSet" && t("environments.analysis.charts.is_not_set")}
|
||||
{op === "gt" && t("environments.analysis.charts.greater_than")}
|
||||
{op === "gte" && t("environments.analysis.charts.greater_than_or_equal")}
|
||||
{op === "lt" && t("environments.analysis.charts.less_than")}
|
||||
{op === "lte" && t("environments.analysis.charts.less_than_or_equal")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{getValueInput(filter, index)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveFilter(index)}
|
||||
className="h-8 w-8">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasFilters && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("environments.analysis.charts.add_filter")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ManualChartBuilderProps {
|
||||
selectedChartType: TChartType | "";
|
||||
onChartTypeSelect: (type: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ManualChartBuilder({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ManualChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={cn(
|
||||
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CustomMeasure } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface MeasuresPanelProps {
|
||||
selectedMeasures: string[];
|
||||
customMeasures: CustomMeasure[];
|
||||
customAggregationsOpen: boolean;
|
||||
onCustomAggregationsOpenChange: (open: boolean) => void;
|
||||
onMeasuresChange: (measures: string[]) => void;
|
||||
onCustomMeasuresChange: (measures: CustomMeasure[]) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function MeasuresPanel({
|
||||
selectedMeasures,
|
||||
customMeasures,
|
||||
customAggregationsOpen,
|
||||
onCustomAggregationsOpenChange,
|
||||
onMeasuresChange,
|
||||
onCustomMeasuresChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<MeasuresPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
|
||||
value: m.id,
|
||||
label: [m.label, m.description].filter(Boolean).join(" - "),
|
||||
}));
|
||||
|
||||
const dimensionOptions = FEEDBACK_FIELDS.dimensions
|
||||
.filter((d) => d.type === "number")
|
||||
.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.label,
|
||||
}));
|
||||
|
||||
const aggregationOptions = FEEDBACK_FIELDS.customAggregations.map((agg) => ({
|
||||
value: agg,
|
||||
label: agg.charAt(0).toUpperCase() + agg.slice(1),
|
||||
}));
|
||||
|
||||
const handleAddCustomMeasure = () => {
|
||||
onCustomMeasuresChange([
|
||||
...customMeasures,
|
||||
{
|
||||
id: `measure-${crypto.randomUUID()}`,
|
||||
field: dimensionOptions[0]?.value || "",
|
||||
aggregation: "avg",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCustomMeasure = (index: number) => {
|
||||
onCustomMeasuresChange(customMeasures.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpdateCustomMeasure = (index: number, updates: Partial<CustomMeasure>) => {
|
||||
const updated = [...customMeasures];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
onCustomMeasuresChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">{t("environments.analysis.charts.measures")}</h3>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{/* Predefined Measures */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.predefined_measures")}</label>
|
||||
<MultiSelect
|
||||
options={measureOptions}
|
||||
value={selectedMeasures}
|
||||
onChange={(selected) => onMeasuresChange(selected)}
|
||||
placeholder={t("environments.analysis.charts.select_measures")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Aggregations */}
|
||||
<AdvancedOptionToggle
|
||||
isChecked={customAggregationsOpen}
|
||||
onToggle={onCustomAggregationsOpenChange}
|
||||
htmlId="chart-custom-aggregations-toggle"
|
||||
title={t("environments.analysis.charts.custom_aggregations")}
|
||||
description={t("environments.analysis.charts.custom_aggregations_toggle_description")}
|
||||
customContainerClass="mt-2 px-0"
|
||||
childrenContainerClass="flex-col gap-3 p-4"
|
||||
childBorder>
|
||||
<div className="w-full space-y-2">
|
||||
{customMeasures.map((measure, index) => (
|
||||
<div
|
||||
key={measure.id ?? `custom-measure-${index}`}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||
<Select
|
||||
value={measure.field}
|
||||
onValueChange={(value) => handleUpdateCustomMeasure(index, { field: value })}>
|
||||
<SelectTrigger className="w-[200px] bg-white">
|
||||
<SelectValue placeholder={t("environments.analysis.charts.select_field")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dimensionOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={measure.aggregation}
|
||||
onValueChange={(value) => handleUpdateCustomMeasure(index, { aggregation: value })}>
|
||||
<SelectTrigger className="w-[150px] bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aggregationOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.alias_optional")}
|
||||
value={measure.alias || ""}
|
||||
onChange={(e) => handleUpdateCustomMeasure(index, { alias: e.target.value })}
|
||||
className="flex-1 bg-white"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveCustomMeasure(index)}
|
||||
className="h-8 w-8">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{customMeasures.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddCustomMeasure}
|
||||
className="h-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("environments.analysis.charts.add_custom_measure")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CodeIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface QueryViewerProps {
|
||||
query: Record<string, unknown>;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Optional trigger; when provided, renders as CollapsibleTrigger for collapsible UX */
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function QueryViewer({ query, isOpen, onOpenChange, trigger }: Readonly<QueryViewerProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||
{trigger}
|
||||
<Collapsible.CollapsibleContent className="mt-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<CodeIcon className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.cube_js_query")}
|
||||
</h4>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto rounded bg-white p-3 text-xs">
|
||||
{JSON.stringify(query, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface SaveChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function SaveChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: Readonly<SaveChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.save_chart_dialog_title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.analysis.charts.enter_a_name_for_your_chart")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Input
|
||||
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && chartName.trim() && !isSaving) {
|
||||
onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Calendar from "react-calendar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TimeDimensionConfig } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
FEEDBACK_FIELDS,
|
||||
TIME_GRANULARITIES,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import "@/modules/ui/components/date-picker/styles.css";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface TimeDimensionPanelProps {
|
||||
timeDimension: TimeDimensionConfig | null;
|
||||
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
|
||||
hideTitle?: boolean;
|
||||
}
|
||||
|
||||
export function TimeDimensionPanel({
|
||||
timeDimension,
|
||||
onTimeDimensionChange,
|
||||
hideTitle = false,
|
||||
}: Readonly<TimeDimensionPanelProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
|
||||
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
|
||||
);
|
||||
const [customStartDate, setCustomStartDate] = useState<Date | null>(
|
||||
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
|
||||
);
|
||||
const [customEndDate, setCustomEndDate] = useState<Date | null>(
|
||||
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
|
||||
);
|
||||
const [presetValue, setPresetValue] = useState<string>(
|
||||
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
|
||||
);
|
||||
|
||||
const timeFieldOptions = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
|
||||
|
||||
const handleEnableTimeDimension = () => {
|
||||
if (!timeDimension) {
|
||||
onTimeDimensionChange({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: "last 30 days",
|
||||
});
|
||||
setPresetValue("last 30 days");
|
||||
setDateRangeType("preset");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionChange = (dimension: string) => {
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({ ...timeDimension, dimension });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGranularityChange = (value: string) => {
|
||||
if (timeDimension) {
|
||||
const granularity = value === "none" ? undefined : (value as TimeDimensionConfig["granularity"]);
|
||||
onTimeDimensionChange({ ...timeDimension, granularity });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetChange = (preset: string) => {
|
||||
setPresetValue(preset);
|
||||
if (timeDimension) {
|
||||
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
|
||||
}
|
||||
};
|
||||
|
||||
if (!timeDimension) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.time_dimension")}
|
||||
</h3>
|
||||
)}
|
||||
<div>
|
||||
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
|
||||
{t("environments.analysis.charts.enable_time_dimension")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{!hideTitle && (
|
||||
<h3 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.time_dimension")}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Field Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.field")}</label>
|
||||
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeFieldOptions.map((field) => (
|
||||
<SelectItem key={field.id} value={field.id}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Granularity Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.granularity")}</label>
|
||||
<Select value={timeDimension.granularity ?? "none"} onValueChange={handleGranularityChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("environments.analysis.charts.no_grouping")}</SelectItem>
|
||||
{TIME_GRANULARITIES.map((gran) => (
|
||||
<SelectItem key={gran} value={gran}>
|
||||
{gran.charAt(0).toUpperCase() + gran.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">{t("environments.analysis.charts.date_range")}</label>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={dateRangeType}
|
||||
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preset">{t("environments.analysis.charts.preset")}</SelectItem>
|
||||
<SelectItem value="custom">{t("environments.analysis.charts.custom_range")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateRangeType === "preset" ? (
|
||||
<Select value={presetValue} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue placeholder={t("environments.analysis.charts.select_preset")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{presetValue && !DATE_PRESETS.some((p) => p.value === presetValue) && (
|
||||
<SelectItem key={presetValue} value={presetValue}>
|
||||
{presetValue}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{customStartDate
|
||||
? format(customStartDate, "MMM dd, yyyy")
|
||||
: t("environments.analysis.charts.start_date")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
onChange={(date: Date) => {
|
||||
setCustomStartDate(date);
|
||||
if (timeDimension && date && customEndDate) {
|
||||
onTimeDimensionChange({
|
||||
...timeDimension,
|
||||
dateRange: [date, customEndDate],
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={customStartDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{customEndDate
|
||||
? format(customEndDate, "MMM dd, yyyy")
|
||||
: t("environments.analysis.charts.end_date")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
onChange={(date: Date) => {
|
||||
setCustomEndDate(date);
|
||||
if (timeDimension && customStartDate && date) {
|
||||
onTimeDimensionChange({
|
||||
...timeDimension,
|
||||
dateRange: [customStartDate, date],
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={customEndDate || undefined}
|
||||
minDate={customStartDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
createChartAction,
|
||||
executeQueryAction,
|
||||
getChartAction,
|
||||
updateChartAction,
|
||||
} from "@/modules/ee/analysis/charts/actions";
|
||||
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { AnalyticsResponse, TChartType, TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface UseCreateChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
environmentId: string;
|
||||
chartId?: string;
|
||||
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
|
||||
initialChart?: TChartWithCreator;
|
||||
defaultDashboardId?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function useCreateChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
chartId,
|
||||
initialChart,
|
||||
defaultDashboardId,
|
||||
onSuccess,
|
||||
}: Readonly<UseCreateChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedChartType, setSelectedChartType] = useState<TChartType | "">("");
|
||||
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [chartName, setChartName] = useState("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>(defaultDashboardId ?? "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
|
||||
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||
const router = useRouter();
|
||||
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddToDashboardDialogOpen) {
|
||||
getDashboardsAction({ environmentId }).then((result) => {
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
|
||||
} else if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAddToDashboardDialogOpen, environmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && chartId) {
|
||||
const chartMetadata = initialChart?.id === chartId ? initialChart : undefined;
|
||||
|
||||
if (chartMetadata) {
|
||||
setChartName(chartMetadata.name);
|
||||
setSelectedChartType(resolveChartType(chartMetadata.type));
|
||||
setCurrentChartId(chartMetadata.id);
|
||||
}
|
||||
|
||||
setIsLoadingChart(true);
|
||||
setChartLoadError(null);
|
||||
|
||||
const loadChartData = async (query: TChartWithCreator["query"], chartType: string) => {
|
||||
const queryResult = await executeQueryAction({
|
||||
environmentId,
|
||||
query,
|
||||
});
|
||||
|
||||
if (queryResult?.serverError) {
|
||||
const errorMsg =
|
||||
getFormattedErrorMessage(queryResult) ||
|
||||
t("environments.analysis.charts.failed_to_load_chart_data");
|
||||
toast.error(errorMsg);
|
||||
setChartLoadError(errorMsg);
|
||||
setIsLoadingChart(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = Array.isArray(queryResult?.data) ? queryResult.data : undefined;
|
||||
if (data) {
|
||||
setChartData({
|
||||
query,
|
||||
chartType: resolveChartType(chartType),
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
const errorMsg = t("environments.analysis.charts.no_data_returned_for_chart");
|
||||
toast.error(errorMsg);
|
||||
setChartLoadError(errorMsg);
|
||||
}
|
||||
setIsLoadingChart(false);
|
||||
};
|
||||
|
||||
if (chartMetadata) {
|
||||
loadChartData(chartMetadata.query, chartMetadata.type);
|
||||
} else {
|
||||
getChartAction({ environmentId, chartId })
|
||||
.then(async (result) => {
|
||||
if (result?.data) {
|
||||
const chart = result.data;
|
||||
setChartName(chart.name);
|
||||
setSelectedChartType(resolveChartType(chart.type));
|
||||
setCurrentChartId(chart.id);
|
||||
await loadChartData(chart.query, chart.type);
|
||||
} else if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsLoadingChart(false);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_load_chart");
|
||||
toast.error(message);
|
||||
setChartLoadError(message);
|
||||
setIsLoadingChart(false);
|
||||
});
|
||||
}
|
||||
} else if (open && !chartId) {
|
||||
setChartData(null);
|
||||
setChartName("");
|
||||
setSelectedChartType("");
|
||||
setCurrentChartId(undefined);
|
||||
}
|
||||
}, [open, chartId, environmentId, initialChart]);
|
||||
|
||||
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||
setChartData(data);
|
||||
if (!currentChartId) {
|
||||
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
|
||||
}
|
||||
setSelectedChartType(data.chartType);
|
||||
};
|
||||
|
||||
const handleSaveChart = async () => {
|
||||
if (!chartData || !chartName.trim()) {
|
||||
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (currentChartId) {
|
||||
const result = await updateChartAction({
|
||||
environmentId,
|
||||
chartId: currentChartId,
|
||||
chartUpdateInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(chartData.chartType),
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_updated_successfully"));
|
||||
setIsSaveDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const result = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(chartData.chartType),
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
setCurrentChartId(result.data.id);
|
||||
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
|
||||
setIsSaveDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_save_chart");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToDashboard = async () => {
|
||||
if (!chartData || !selectedDashboardId) {
|
||||
toast.error(t("environments.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let chartIdToUse = currentChartId;
|
||||
|
||||
if (!chartIdToUse) {
|
||||
if (!chartName.trim()) {
|
||||
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const chartResult = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(chartData.chartType),
|
||||
query: chartData.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chartResult?.data) {
|
||||
toast.error(
|
||||
(chartResult && getFormattedErrorMessage(chartResult)) ||
|
||||
t("environments.analysis.charts.failed_to_save_chart")
|
||||
);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
chartIdToUse = chartResult.data.id;
|
||||
setCurrentChartId(chartResult.data.id);
|
||||
}
|
||||
|
||||
const widgetResult = await addChartToDashboardAction({
|
||||
environmentId,
|
||||
chartId: chartIdToUse,
|
||||
dashboardId: selectedDashboardId,
|
||||
title: chartName.trim(),
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
});
|
||||
|
||||
if (!widgetResult?.data) {
|
||||
toast.error(
|
||||
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
|
||||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setChartData(null);
|
||||
setChartName("");
|
||||
setSelectedChartType("");
|
||||
setCurrentChartId(undefined);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvancedBuilderSave = (savedChartId: string) => {
|
||||
setCurrentChartId(savedChartId);
|
||||
setIsSaveDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const handleAdvancedBuilderAddToDashboard = (savedChartId: string) => {
|
||||
setCurrentChartId(savedChartId);
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const handleChartTypeChange = (type: TChartType) => {
|
||||
setSelectedChartType(type);
|
||||
setChartData((prev) => (prev ? { ...prev, chartType: type } : null));
|
||||
};
|
||||
|
||||
const initialQuery = initialChart && initialChart.id === chartId ? initialChart.query : undefined;
|
||||
|
||||
return {
|
||||
chartData,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
initialQuery,
|
||||
setSelectedChartType,
|
||||
currentChartId,
|
||||
setCurrentChartId,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
isSaving,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
shouldShowAdvancedBuilder,
|
||||
handleChartGenerated,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
handleClose,
|
||||
handleAdvancedBuilderSave,
|
||||
handleAdvancedBuilderAddToDashboard,
|
||||
handleChartTypeChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createChartAction, deleteChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface ChartInput {
|
||||
query: TChartQuery;
|
||||
chartType: TChartType;
|
||||
}
|
||||
|
||||
export interface UseSaveDashboardDialogsProps {
|
||||
environmentId: string;
|
||||
/** Returns current query and chart type when save/add is triggered; null if not ready */
|
||||
getChartInput: () => ChartInput | null;
|
||||
onSave?: (chartId: string) => void;
|
||||
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
|
||||
}
|
||||
|
||||
export function useSaveDashboardDialogs({
|
||||
environmentId,
|
||||
getChartInput,
|
||||
onSave,
|
||||
onAddToDashboard,
|
||||
}: Readonly<UseSaveDashboardDialogsProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [chartName, setChartName] = useState("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddToDashboardDialogOpen) {
|
||||
getDashboardsAction({ environmentId }).then((result) => {
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
|
||||
} else if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAddToDashboardDialogOpen, environmentId]);
|
||||
|
||||
const handleSaveChart = async () => {
|
||||
const input = getChartInput();
|
||||
if (!input) return;
|
||||
if (!chartName.trim()) {
|
||||
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name: chartName.trim(),
|
||||
type: resolveChartType(input.chartType),
|
||||
query: input.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(
|
||||
(result && getFormattedErrorMessage(result)) ||
|
||||
t("environments.analysis.charts.failed_to_save_chart")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
|
||||
setIsSaveDialogOpen(false);
|
||||
if (onSave) {
|
||||
onSave(result.data.id);
|
||||
} else {
|
||||
router.push(`/environments/${environmentId}/analysis/charts`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_save_chart");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToDashboard = async () => {
|
||||
const input = getChartInput();
|
||||
if (!input || !selectedDashboardId) {
|
||||
toast.error(t("environments.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
const name = chartName.trim() || `Chart ${new Date().toISOString().slice(0, 19)}`;
|
||||
|
||||
setIsSaving(true);
|
||||
let chartId: string | null = null;
|
||||
try {
|
||||
const chartResult = await createChartAction({
|
||||
environmentId,
|
||||
chartInput: {
|
||||
name,
|
||||
type: resolveChartType(input.chartType),
|
||||
query: input.query,
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chartResult?.data) {
|
||||
toast.error(
|
||||
(chartResult && getFormattedErrorMessage(chartResult)) ||
|
||||
t("environments.analysis.charts.failed_to_save_chart")
|
||||
);
|
||||
return;
|
||||
}
|
||||
chartId = chartResult.data.id;
|
||||
|
||||
const widgetResult = await addChartToDashboardAction({
|
||||
environmentId,
|
||||
chartId,
|
||||
dashboardId: selectedDashboardId,
|
||||
title: name,
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
});
|
||||
|
||||
if (!widgetResult?.data) {
|
||||
toast.error(
|
||||
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
|
||||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
await deleteChartAction({ environmentId, chartId }).catch(() => {
|
||||
/* best-effort cleanup of orphan chart */
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
if (onAddToDashboard) {
|
||||
onAddToDashboard(chartId, selectedDashboardId);
|
||||
} else {
|
||||
router.push(`/environments/${environmentId}/analysis/dashboards/${selectedDashboardId}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
|
||||
toast.error(message);
|
||||
if (chartId) {
|
||||
await deleteChartAction({ environmentId, chartId }).catch(() => {
|
||||
/* best-effort cleanup of orphan chart */
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
chartName,
|
||||
setChartName,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
isSaving,
|
||||
handleSaveChart,
|
||||
handleAddToDashboard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types";
|
||||
|
||||
describe("chart-types", () => {
|
||||
test("CHART_TYPE_ICONS has all chart types", () => {
|
||||
expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
});
|
||||
|
||||
test("getChartTypes returns chart types with translated labels", () => {
|
||||
const t = vi.fn((key: string) => key);
|
||||
const result = getChartTypes(t);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
expect(t).toHaveBeenCalledWith("environments.analysis.charts.chart_type_area");
|
||||
expect(result[0].label).toBe("environments.analysis.charts.chart_type_area");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const CHART_TYPE_ICONS: Record<
|
||||
TChartType,
|
||||
React.ComponentType<{ className?: string; strokeWidth?: number }>
|
||||
> = {
|
||||
area: AreaChartIcon,
|
||||
bar: BarChart3Icon,
|
||||
line: LineChartIcon,
|
||||
pie: PieChartIcon,
|
||||
big_number: ActivityIcon,
|
||||
};
|
||||
|
||||
export function getChartTypes(t: TFunction): readonly {
|
||||
id: TChartType;
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
label: string;
|
||||
}[] {
|
||||
return [
|
||||
{ id: "area", icon: CHART_TYPE_ICONS.area, label: t("environments.analysis.charts.chart_type_area") },
|
||||
{ id: "bar", icon: CHART_TYPE_ICONS.bar, label: t("environments.analysis.charts.chart_type_bar") },
|
||||
{ id: "line", icon: CHART_TYPE_ICONS.line, label: t("environments.analysis.charts.chart_type_line") },
|
||||
{ id: "pie", icon: CHART_TYPE_ICONS.pie, label: t("environments.analysis.charts.chart_type_pie") },
|
||||
{
|
||||
id: "big_number",
|
||||
icon: CHART_TYPE_ICONS.big_number,
|
||||
label: t("environments.analysis.charts.chart_type_big_number"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { ZChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const CHART_BRAND_DARK = "#00C4B8";
|
||||
export const CHART_BRAND_LIGHT = "#00E6CA";
|
||||
|
||||
/** Validate a chart type string, defaulting to "bar" if unrecognised. */
|
||||
export const resolveChartType = (raw: string): TChartType => {
|
||||
const parsed = ZChartType.safeParse(raw);
|
||||
return parsed.success ? parsed.data : "bar";
|
||||
};
|
||||
|
||||
const isNumericValue = (val: TChartDataRow[string]): boolean => {
|
||||
if (val === null || val === undefined || val === "") return false;
|
||||
const num = Number(val);
|
||||
return !Number.isNaN(num) && Number.isFinite(num);
|
||||
};
|
||||
|
||||
export const preparePieData = (
|
||||
data: TChartDataRow[],
|
||||
dataKey: string
|
||||
): { processedData: TChartDataRow[]; colors: string[] } | null => {
|
||||
const validData = data.filter((row) => isNumericValue(row[dataKey]));
|
||||
const processedData = validData.map((row) => ({ ...row, [dataKey]: Number(row[dataKey]) }));
|
||||
if (processedData.length === 0) return null;
|
||||
|
||||
const colors = processedData.map((_, i) => {
|
||||
const sat = 70 + (i % 3) * 10;
|
||||
const light = 45 + (i % 2) * 15;
|
||||
return `hsl(180, ${sat}%, ${light}%)`;
|
||||
});
|
||||
if (colors.length > 0) colors[0] = CHART_BRAND_DARK;
|
||||
if (colors.length > 1) colors[1] = CHART_BRAND_LIGHT;
|
||||
return { processedData, colors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a cell value for display in tables and tooltips.
|
||||
* ISO date strings become "MMM d, yyyy"; numbers stay as-is (formatted); objects are stringified.
|
||||
*/
|
||||
export function formatCellValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "number") return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
if (typeof value === "string") {
|
||||
const date = parseISO(value);
|
||||
if (isValid(date)) return format(date, "MMM d, yyyy");
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
if (typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
const ALLOWED_CUBE_PREFIX = "FeedbackRecords.";
|
||||
|
||||
function validateMember(member: string): boolean {
|
||||
return member.startsWith(ALLOWED_CUBE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all measures, dimensions, segments, timeDimensions, and filters
|
||||
* use only members starting with FeedbackRecords.
|
||||
* @throws Error if any member is invalid
|
||||
*/
|
||||
export function validateQueryMembers(query: TChartQuery): void {
|
||||
const invalid: string[] = [];
|
||||
for (const m of query.measures ?? []) {
|
||||
if (!validateMember(m)) invalid.push(m);
|
||||
}
|
||||
for (const d of query.dimensions ?? []) {
|
||||
if (!validateMember(d)) invalid.push(d);
|
||||
}
|
||||
for (const s of query.segments ?? []) {
|
||||
if (!validateMember(s)) invalid.push(s);
|
||||
}
|
||||
for (const td of query.timeDimensions ?? []) {
|
||||
if (!validateMember(td.dimension)) invalid.push(td.dimension);
|
||||
}
|
||||
const checkFilters = (f: TChartQuery["filters"]): void => {
|
||||
if (!f) return;
|
||||
for (const item of f) {
|
||||
if ("member" in item && typeof item.member === "string" && !validateMember(item.member)) {
|
||||
invalid.push(item.member);
|
||||
}
|
||||
if ("and" in item && Array.isArray(item.and)) checkFilters(item.and);
|
||||
if ("or" in item && Array.isArray(item.or)) checkFilters(item.or);
|
||||
}
|
||||
};
|
||||
checkFilters(query.filters);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(`Invalid query members (must start with ${ALLOWED_CUBE_PREFIX}): ${invalid.join(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
var mockTxChart: {
|
||||
// NOSONAR S1135 - var required for vi.mock hoisting
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/database", () => {
|
||||
const tx = {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
mockTxChart = tx;
|
||||
return {
|
||||
prisma: {
|
||||
chart: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((cb: any) => cb({ chart: tx })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: () => Promise.resolve({ project: { id: "project-abc-123" } }),
|
||||
}));
|
||||
|
||||
const mockChartId = "chart-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockUserId = "user-abc-123";
|
||||
|
||||
const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
const mockChart = {
|
||||
id: mockChartId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
};
|
||||
|
||||
const makePrismaError = (code: string) =>
|
||||
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
|
||||
|
||||
describe("Chart Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createChart", () => {
|
||||
test("creates a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue(mockChart as any);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
const result = await createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
projectId: mockProjectId,
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(
|
||||
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
|
||||
);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Duplicate",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Test",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChart", () => {
|
||||
test("updates a chart successfully", async () => {
|
||||
const updatedChart = { ...mockChart, name: "Updated Chart" };
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockResolvedValue(updatedChart);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
const result = await updateChart(mockChartId, mockProjectId, { name: "Updated Chart" });
|
||||
|
||||
expect(result).toEqual({ chart: mockChart, updatedChart });
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.update).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId },
|
||||
data: { name: "Updated Chart", type: undefined, query: undefined, config: undefined },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockProjectId, { name: "Updated" })).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockProjectId, { name: "Taken Name" })).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateChart", () => {
|
||||
test("duplicates a chart with '(copy)' suffix", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({ ...mockChart, name: "Test Chart (copy)" } as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("increments copy number when '(copy)' already exists", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 2)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("finds next available copy number", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([
|
||||
{ name: "Test Chart (copy)" },
|
||||
{ name: "Test Chart (copy 2)" },
|
||||
] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 3)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 3)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("strips existing copy suffix before generating new name", async () => {
|
||||
const chartWithCopy = { ...mockChart, name: "Test Chart (copy)" };
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(chartWithCopy as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId, name: { startsWith: "Test Chart (copy" } },
|
||||
select: { name: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when source chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await expect(duplicateChart(mockChartId, mockProjectId, mockUserId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteChart", () => {
|
||||
test("deletes a chart successfully", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.delete.mockResolvedValue(undefined);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
const result = await deleteChart(mockChartId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.delete).toHaveBeenCalledWith({ where: { id: mockChartId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxChart.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChart", () => {
|
||||
test("returns a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
const result = await getChart(mockChartId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCharts", () => {
|
||||
test("returns all charts for a project", async () => {
|
||||
const chartsFromDb = [
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsFromDb as any);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockProjectId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
]);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no charts exist", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockProjectId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
await expect(getCharts(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChartsWithCreator", () => {
|
||||
test("returns charts with creator info", async () => {
|
||||
const chartsWithCreator = [
|
||||
{ ...mockChart, creator: { name: "Alice" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: null },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsWithCreator as any);
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
const result = await getChartsWithCreator(mockProjectId);
|
||||
|
||||
expect(result).toEqual(chartsWithCreator);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: expect.objectContaining({
|
||||
creator: { select: { name: true } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
await expect(getChartsWithCreator(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZChartConfig, ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TChart,
|
||||
TChartCreateInput,
|
||||
TChartUpdateInput,
|
||||
TChartWithCreator,
|
||||
TChartWithWidgets,
|
||||
ZChartCreateInput,
|
||||
ZChartType,
|
||||
ZChartUpdateInput,
|
||||
} from "@/modules/ee/analysis/types/analysis";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
export const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
|
||||
validateInputs([data, ZChartCreateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
projectId: data.projectId,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateChart = async (
|
||||
chartId: string,
|
||||
projectId: string,
|
||||
data: TChartUpdateInput
|
||||
): Promise<{ chart: TChart; updatedChart: TChart }> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId], [data, ZChartUpdateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const updatedChart = await tx.chart.update({
|
||||
where: { id: chartId },
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
return { chart, updatedChart };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueCopyName = async (baseName: string, projectId: string): Promise<string> => {
|
||||
const stripped = baseName.replace(/ \(copy(?: \d+)?\)$/, "");
|
||||
|
||||
try {
|
||||
const existing = await prisma.chart.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: { startsWith: `${stripped} (copy` },
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const existingNames = new Set(existing.map((c) => c.name));
|
||||
|
||||
const firstCandidate = `${stripped} (copy)`;
|
||||
if (!existingNames.has(firstCandidate)) {
|
||||
return firstCandidate;
|
||||
}
|
||||
|
||||
let n = 2;
|
||||
while (existingNames.has(`${stripped} (copy ${n})`)) {
|
||||
n++;
|
||||
}
|
||||
return `${stripped} (copy ${n})`;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const duplicateChart = async (
|
||||
chartId: string,
|
||||
projectId: string,
|
||||
createdBy: string
|
||||
): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId], [createdBy, ZId]);
|
||||
|
||||
try {
|
||||
const sourceChart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!sourceChart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const uniqueName = await getUniqueCopyName(sourceChart.name, projectId);
|
||||
|
||||
return await createChart({
|
||||
projectId,
|
||||
name: uniqueName,
|
||||
type: ZChartType.parse(sourceChart.type),
|
||||
query: ZChartQuery.parse(sourceChart.query),
|
||||
config: ZChartConfig.parse(sourceChart.config ?? {}),
|
||||
createdBy,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteChart = async (chartId: string, projectId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
await tx.chart.delete({
|
||||
where: { id: chartId },
|
||||
});
|
||||
|
||||
return chart;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChart = async (chartId: string, projectId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
const chart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
return chart;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all charts for the given environment (for list/dashboard UI).
|
||||
* Uses getEnvironmentAuth for access check and enriches with creator names.
|
||||
*/
|
||||
export const getCharts = async (environmentId: string): Promise<TChartWithCreator[]> => {
|
||||
try {
|
||||
const { project } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const charts = await prisma.chart.findMany({
|
||||
where: { projectId: project.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return charts;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChartsWithCreator = async (projectId: string): Promise<TChartWithCreator[]> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ReactNode } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
|
||||
|
||||
interface AnalysisPageLayoutProps {
|
||||
pageTitle: string;
|
||||
environmentId: string;
|
||||
cta?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnalysisPageLayout({
|
||||
pageTitle,
|
||||
environmentId,
|
||||
cta,
|
||||
children,
|
||||
}: Readonly<AnalysisPageLayoutProps>) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} cta={cta}>
|
||||
<AnalysisSecondaryNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
{children}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface AnalysisSecondaryNavigationProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function AnalysisSecondaryNavigation({ environmentId }: Readonly<AnalysisSecondaryNavigationProps>) {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeId = pathname?.includes("/charts") ? "charts" : "dashboards";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: t("common.dashboards"),
|
||||
href: `/environments/${environmentId}/analysis/dashboards`,
|
||||
},
|
||||
{
|
||||
id: "charts",
|
||||
label: t("common.charts"),
|
||||
href: `/environments/${environmentId}/analysis/charts`,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use server";
|
||||
|
||||
// eslint-disable-next-line
|
||||
// TODO: remove revalidatePath and use revalidateTag instead once this has become stable: https://nextjs.org/docs/app/api-reference/directives/use-cache#usage
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZWidgetLayout } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ZDashboardUpdateInput } from "../types/analysis";
|
||||
import {
|
||||
addChartToDashboard,
|
||||
createDashboard,
|
||||
deleteDashboard,
|
||||
duplicateDashboard,
|
||||
getDashboard,
|
||||
getDashboards,
|
||||
updateDashboard,
|
||||
} from "./lib/dashboards";
|
||||
|
||||
const ZCreateDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const dashboard = await createDashboard({
|
||||
projectId,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
createdBy: ctx.user.id,
|
||||
});
|
||||
|
||||
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = dashboard.id;
|
||||
ctx.auditLoggingCtx.newObject = dashboard;
|
||||
return dashboard;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateDashboardAction = z
|
||||
.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
})
|
||||
.merge(ZDashboardUpdateInput);
|
||||
|
||||
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const { dashboard, updatedDashboard } = await updateDashboard(parsedInput.dashboardId, projectId, {
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
|
||||
ctx.auditLoggingCtx.oldObject = dashboard;
|
||||
ctx.auditLoggingCtx.newObject = updatedDashboard;
|
||||
return updatedDashboard;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
});
|
||||
|
||||
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const dashboard = await deleteDashboard(parsedInput.dashboardId, projectId);
|
||||
|
||||
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
|
||||
ctx.auditLoggingCtx.oldObject = dashboard;
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDuplicateDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateDashboardAction = authenticatedActionClient.schema(ZDuplicateDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const dashboard = await duplicateDashboard(parsedInput.dashboardId, projectId, ctx.user.id);
|
||||
|
||||
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = dashboard.id;
|
||||
ctx.auditLoggingCtx.newObject = dashboard;
|
||||
return dashboard;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetDashboardsAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getDashboardsAction = authenticatedActionClient
|
||||
.schema(ZGetDashboardsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetDashboardsAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getDashboards(projectId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
});
|
||||
|
||||
export const getDashboardAction = authenticatedActionClient
|
||||
.schema(ZGetDashboardAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetDashboardAction>;
|
||||
}) => {
|
||||
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
|
||||
return getDashboard(parsedInput.dashboardId, projectId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZAddChartToDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
chartId: ZId,
|
||||
title: z.string().optional(),
|
||||
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
|
||||
});
|
||||
|
||||
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"dashboardWidget",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZAddChartToDashboardAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const widget = await addChartToDashboard({
|
||||
dashboardId: parsedInput.dashboardId,
|
||||
chartId: parsedInput.chartId,
|
||||
projectId,
|
||||
title: parsedInput.title,
|
||||
layout: parsedInput.layout,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
ctx.auditLoggingCtx.newObject = widget;
|
||||
return widget;
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { createDashboardAction } from "../actions";
|
||||
import { CreateDashboardDialog } from "./create-dashboard-dialog";
|
||||
|
||||
interface CreateDashboardButtonProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const CreateDashboardButton = ({ environmentId }: Readonly<CreateDashboardButtonProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [dashboardName, setDashboardName] = useState("");
|
||||
const [dashboardDescription, setDashboardDescription] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsCreateDialogOpen(open);
|
||||
if (!open) {
|
||||
setDashboardName("");
|
||||
setDashboardDescription("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!dashboardName.trim()) {
|
||||
toast.error(t("environments.analysis.dashboards.please_enter_name"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await createDashboardAction({
|
||||
environmentId,
|
||||
name: dashboardName.trim(),
|
||||
description: dashboardDescription.trim() || undefined,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.dashboards.create_success"));
|
||||
handleOpenChange(false);
|
||||
router.push(`/environments/${environmentId}/analysis/dashboards/${result.data.id}`);
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.create_failed"));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => handleOpenChange(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.dashboards.create_dashboard")}
|
||||
</Button>
|
||||
<CreateDashboardDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
dashboardName={dashboardName}
|
||||
onDashboardNameChange={setDashboardName}
|
||||
dashboardDescription={dashboardDescription}
|
||||
onDashboardDescriptionChange={setDashboardDescription}
|
||||
onCreate={handleCreate}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CreateDashboardDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
dashboardName: string;
|
||||
onDashboardNameChange: (name: string) => void;
|
||||
dashboardDescription: string;
|
||||
onDashboardDescriptionChange: (description: string) => void;
|
||||
onCreate: () => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export const CreateDashboardDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
dashboardName,
|
||||
onDashboardNameChange,
|
||||
dashboardDescription,
|
||||
onDashboardDescriptionChange,
|
||||
onCreate,
|
||||
isCreating,
|
||||
}: Readonly<CreateDashboardDialogProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.dashboards.create_dashboard")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.analysis.dashboards.create_dashboard_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (dashboardName.trim() && !isCreating) {
|
||||
onCreate();
|
||||
}
|
||||
}}
|
||||
className="space-y-4">
|
||||
<DialogBody className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dashboard-name">{t("environments.analysis.dashboards.dashboard_name")}</Label>
|
||||
<Input
|
||||
id="dashboard-name"
|
||||
placeholder={t("environments.analysis.dashboards.dashboard_name_placeholder")}
|
||||
value={dashboardName}
|
||||
onChange={(e) => onDashboardNameChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dashboard-description">
|
||||
{t("environments.analysis.dashboards.description_optional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="dashboard-description"
|
||||
placeholder={t("environments.analysis.dashboards.description_placeholder")}
|
||||
value={dashboardDescription}
|
||||
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCreating}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isCreating} disabled={!dashboardName.trim()}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { deleteDashboardAction, duplicateDashboardAction } from "../actions";
|
||||
|
||||
interface DashboardDropdownMenuProps {
|
||||
environmentId: string;
|
||||
dashboardId: string;
|
||||
dashboardName: string;
|
||||
}
|
||||
|
||||
export const DashboardDropdownMenu = ({
|
||||
environmentId,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
}: Readonly<DashboardDropdownMenuProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
|
||||
const handleDuplicateDashboard = async () => {
|
||||
setIsDuplicating(true);
|
||||
try {
|
||||
const result = await duplicateDashboardAction({ environmentId, dashboardId });
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.analysis.dashboards.duplicate_success"));
|
||||
} else {
|
||||
toast.error(result?.serverError || t("environments.analysis.dashboards.duplicate_failed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.duplicate_failed"));
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDashboard = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteDashboardAction({ environmentId, dashboardId });
|
||||
if (result?.data) {
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success(t("environments.analysis.dashboards.delete_success"));
|
||||
} else {
|
||||
toast.error(result?.serverError || t("environments.analysis.dashboards.delete_failed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.delete_failed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={`${dashboardName.toLowerCase().split(" ").join("-")}-dashboard-actions`}>
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<button type="button" className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
|
||||
<span className="sr-only">{t("common.open_options")}</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/analysis/dashboards/${dashboardId}`}>
|
||||
<SquarePenIcon className="mr-2 size-4" />
|
||||
{t("common.edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={isDuplicating}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
handleDuplicateDashboard();
|
||||
}}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.duplicate")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("common.dashboard")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={handleDeleteDashboard}
|
||||
text={t("environments.analysis.dashboards.delete_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
const SKELETON_ROWS = 3;
|
||||
|
||||
const SkeletonRow = () => {
|
||||
return (
|
||||
<div className="grid h-12 w-full animate-pulse grid-cols-8 content-center">
|
||||
<div className="col-span-7 grid grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center gap-4 pl-6">
|
||||
<div className="h-5 w-5 rounded bg-gray-200" />
|
||||
<div className="h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-6 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-16 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DashboardsListSkeletonProps {
|
||||
columnHeaders: string[];
|
||||
}
|
||||
|
||||
export const DashboardsListSkeleton = ({ columnHeaders }: Readonly<DashboardsListSkeletonProps>) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[4]}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||
<SkeletonRow key={`skeleton-row-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { convertDateString, timeSinceDate } from "@/lib/time";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { DashboardDropdownMenu } from "./dashboard-dropdown-menu";
|
||||
|
||||
interface DashboardsTableProps {
|
||||
dashboards: TDashboardWithCount[];
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const DashboardsTable = async ({
|
||||
dashboards,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardsTableProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.charts")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.analysis.dashboards.no_dashboards_found")}
|
||||
</p>
|
||||
) : (
|
||||
dashboards.map((dashboard) => {
|
||||
return (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className="grid h-12 w-full grid-cols-8 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||
<Link
|
||||
href={`/environments/${environmentId}/analysis/dashboards/${dashboard.id}`}
|
||||
className="col-span-7 grid cursor-pointer grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-8 flex-shrink-0 text-slate-500">
|
||||
<BarChart3Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium text-slate-900">{dashboard.name}</div>
|
||||
{dashboard.description && (
|
||||
<div className="text-xs font-medium text-slate-500">{dashboard.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{dashboard._count.widgets}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{dashboard.creator?.name || "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{convertDateString(dashboard.createdAt.toISOString())}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{timeSinceDate(dashboard.updatedAt)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
|
||||
{!isReadOnly && (
|
||||
<DashboardDropdownMenu
|
||||
environmentId={environmentId}
|
||||
dashboardId={dashboard.id}
|
||||
dashboardName={dashboard.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,605 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
var mockTxDashboard: {
|
||||
// NOSONAR / test code
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
|
||||
|
||||
var mockTxWidget: {
|
||||
// NOSONAR / test code
|
||||
aggregate: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/database", () => {
|
||||
const txDash = { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() };
|
||||
const txChart = { findFirst: vi.fn() };
|
||||
const txWidget = { aggregate: vi.fn(), create: vi.fn() };
|
||||
mockTxDashboard = txDash;
|
||||
mockTxChart = txChart;
|
||||
mockTxWidget = txWidget;
|
||||
return {
|
||||
prisma: {
|
||||
dashboard: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
|
||||
selectChart: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockDashboardId = "dashboard-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockUserId = "user-abc-123";
|
||||
const mockChartId = "chart-abc-123";
|
||||
|
||||
const selectDashboard = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: true,
|
||||
};
|
||||
|
||||
const mockDashboard = {
|
||||
id: mockDashboardId,
|
||||
name: "Test Dashboard",
|
||||
description: "A test dashboard",
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
createdBy: mockUserId,
|
||||
};
|
||||
|
||||
const makePrismaError = (code: string) =>
|
||||
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
|
||||
|
||||
describe("Dashboard Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createDashboard", () => {
|
||||
test("creates a dashboard successfully", async () => {
|
||||
vi.mocked(prisma.dashboard.create).mockResolvedValue(mockDashboard as any);
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Test Dashboard",
|
||||
description: "A test dashboard",
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockDashboard);
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Dashboard",
|
||||
description: "A test dashboard",
|
||||
projectId: mockProjectId,
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a dashboard without description", async () => {
|
||||
const dashboardNoDesc = { ...mockDashboard, description: undefined };
|
||||
vi.mocked(prisma.dashboard.create).mockResolvedValue(dashboardNoDesc as any);
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Test Dashboard",
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(dashboardNoDesc);
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Dashboard",
|
||||
description: undefined,
|
||||
projectId: mockProjectId,
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
vi.mocked(prisma.dashboard.create).mockRejectedValue(
|
||||
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
|
||||
);
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Duplicate",
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.dashboard.create).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { createDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
createDashboard({
|
||||
projectId: mockProjectId,
|
||||
name: "Test",
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateDashboard", () => {
|
||||
test("updates a dashboard successfully", async () => {
|
||||
const updatedDashboard = { ...mockDashboard, name: "Updated Dashboard" };
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxDashboard.update.mockResolvedValue(updatedDashboard);
|
||||
const { updateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await updateDashboard(mockDashboardId, mockProjectId, { name: "Updated Dashboard" });
|
||||
|
||||
expect(result).toEqual({ dashboard: mockDashboard, updatedDashboard });
|
||||
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId, projectId: mockProjectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
expect(mockTxDashboard.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId },
|
||||
data: { name: "Updated Dashboard", description: undefined },
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { updateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
updateDashboard(mockDashboardId, mockProjectId, { name: "Updated" })
|
||||
).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxDashboard.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxDashboard.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { updateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
updateDashboard(mockDashboardId, mockProjectId, { name: "Taken Name" })
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteDashboard", () => {
|
||||
test("deletes a dashboard successfully", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxDashboard.delete.mockResolvedValue(undefined);
|
||||
const { deleteDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await deleteDashboard(mockDashboardId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockDashboard);
|
||||
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId, projectId: mockProjectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
expect(mockTxDashboard.delete).toHaveBeenCalledWith({ where: { id: mockDashboardId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { deleteDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxDashboard.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { deleteDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateDashboard", () => {
|
||||
const mockWidgets = [
|
||||
{
|
||||
id: "widget-1",
|
||||
chartId: mockChartId,
|
||||
title: "Widget 1",
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: "widget-2",
|
||||
chartId: "chart-2",
|
||||
title: null,
|
||||
layout: { x: 4, y: 0, w: 4, h: 3 },
|
||||
order: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const sourceDashboard = {
|
||||
...mockDashboard,
|
||||
widgets: mockWidgets,
|
||||
};
|
||||
|
||||
const duplicatedDashboard = {
|
||||
...mockDashboard,
|
||||
id: "dashboard-new-123",
|
||||
name: "Test Dashboard (copy)",
|
||||
};
|
||||
|
||||
test("duplicates a dashboard with all widgets", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceDashboard).mockResolvedValueOnce(null);
|
||||
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
|
||||
|
||||
expect(result).toEqual(duplicatedDashboard);
|
||||
expect(mockTxDashboard.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Dashboard (copy)",
|
||||
description: mockDashboard.description,
|
||||
projectId: mockProjectId,
|
||||
createdBy: mockUserId,
|
||||
widgets: {
|
||||
create: [
|
||||
{
|
||||
chartId: mockChartId,
|
||||
title: "Widget 1",
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
chartId: "chart-2",
|
||||
title: null,
|
||||
layout: { x: 4, y: 0, w: 4, h: 3 },
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("duplicates a dashboard with no widgets", async () => {
|
||||
const sourceNoWidgets = { ...mockDashboard, widgets: [] };
|
||||
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceNoWidgets).mockResolvedValueOnce(null);
|
||||
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
|
||||
|
||||
expect(result).toEqual(duplicatedDashboard);
|
||||
expect(mockTxDashboard.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
widgets: { create: [] },
|
||||
}),
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("increments copy suffix when name already exists", async () => {
|
||||
const existingCopy = { id: "existing", name: "Test Dashboard (copy)" };
|
||||
mockTxDashboard.findFirst
|
||||
.mockResolvedValueOnce(sourceDashboard)
|
||||
.mockResolvedValueOnce(existingCopy)
|
||||
.mockResolvedValueOnce(null);
|
||||
mockTxDashboard.create.mockResolvedValue({
|
||||
...duplicatedDashboard,
|
||||
name: "Test Dashboard (copy) 2",
|
||||
});
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
|
||||
|
||||
expect(result.name).toBe("Test Dashboard (copy) 2");
|
||||
expect(mockTxDashboard.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Dashboard (copy) 2" }),
|
||||
select: selectDashboard,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when source dashboard does not exist", async () => {
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxDashboard.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { duplicateDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDashboard", () => {
|
||||
test("returns a dashboard with widgets", async () => {
|
||||
const dashboardWithWidgets = {
|
||||
...mockDashboard,
|
||||
widgets: [
|
||||
{
|
||||
id: "widget-1",
|
||||
order: 0,
|
||||
chart: { id: mockChartId, name: "Chart 1", type: "bar" },
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(dashboardWithWidgets as any);
|
||||
const { getDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await getDashboard(mockDashboardId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(dashboardWithWidgets);
|
||||
expect(prisma.dashboard.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockDashboardId, projectId: mockProjectId },
|
||||
include: {
|
||||
widgets: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
chart: {
|
||||
select: expect.objectContaining({ id: true, name: true, type: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(null);
|
||||
const { getDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.dashboard.findFirst).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDashboards", () => {
|
||||
test("returns all dashboards for a project with creator", async () => {
|
||||
const dashboards = [
|
||||
{ ...mockDashboard, creator: { name: "Alice" }, _count: { widgets: 3 } },
|
||||
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", creator: null, _count: { widgets: 0 } },
|
||||
];
|
||||
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
|
||||
const { getDashboards } = await import("./dashboards");
|
||||
|
||||
const result = await getDashboards(mockProjectId);
|
||||
|
||||
expect(result).toEqual(dashboards);
|
||||
expect(prisma.dashboard.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
name: true,
|
||||
creator: { select: { name: true } },
|
||||
_count: { select: { widgets: true } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no dashboards exist", async () => {
|
||||
vi.mocked(prisma.dashboard.findMany).mockResolvedValue([]);
|
||||
const { getDashboards } = await import("./dashboards");
|
||||
|
||||
const result = await getDashboards(mockProjectId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.dashboard.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getDashboards } = await import("./dashboards");
|
||||
|
||||
await expect(getDashboards(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addChartToDashboard", () => {
|
||||
const mockLayout = { x: 0, y: 0, w: 4, h: 3 };
|
||||
const mockWidget = {
|
||||
id: "widget-abc-123",
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
title: "My Widget",
|
||||
layout: mockLayout,
|
||||
order: 0,
|
||||
};
|
||||
|
||||
test("adds a chart to a dashboard as the first widget", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
|
||||
mockTxWidget.create.mockResolvedValue(mockWidget);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
title: "My Widget",
|
||||
layout: mockLayout,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockWidget);
|
||||
expect(mockTxWidget.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
title: "My Widget",
|
||||
layout: mockLayout,
|
||||
order: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("appends widget after existing widgets", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: 2 } });
|
||||
mockTxWidget.create.mockResolvedValue({ ...mockWidget, order: 3 });
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
});
|
||||
|
||||
expect(mockTxWidget.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ order: 3 }),
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxWidget.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(null);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Dashboard",
|
||||
resourceId: mockDashboardId,
|
||||
});
|
||||
expect(mockTxWidget.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
|
||||
mockTxWidget.create.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { selectChart } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import {
|
||||
TAddWidgetInput,
|
||||
TDashboard,
|
||||
TDashboardCreateInput,
|
||||
TDashboardUpdateInput,
|
||||
TDashboardWithCount,
|
||||
ZAddWidgetInput,
|
||||
ZDashboardCreateInput,
|
||||
ZDashboardUpdateInput,
|
||||
} from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
const MAX_NAME_ATTEMPTS = 5;
|
||||
|
||||
const selectDashboard = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: true,
|
||||
} as const;
|
||||
|
||||
export const createDashboard = async (data: TDashboardCreateInput): Promise<TDashboard> => {
|
||||
validateInputs([data, ZDashboardCreateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.dashboard.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
projectId: data.projectId,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A dashboard with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDashboard = async (
|
||||
dashboardId: string,
|
||||
projectId: string,
|
||||
data: TDashboardUpdateInput
|
||||
): Promise<{ dashboard: TDashboard; updatedDashboard: TDashboard }> => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId], [data, ZDashboardUpdateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const dashboard = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
const updatedDashboard = await tx.dashboard.update({
|
||||
where: { id: dashboardId },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
return { dashboard, updatedDashboard };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A dashboard with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDashboard = async (dashboardId: string, projectId: string): Promise<TDashboard> => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const dashboard = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
await tx.dashboard.delete({
|
||||
where: { id: dashboardId },
|
||||
});
|
||||
|
||||
return dashboard;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDashboard = async (dashboardId: string, projectId: string) => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
const dashboard = await prisma.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
include: {
|
||||
widgets: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
chart: {
|
||||
select: selectChart,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
return dashboard;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDashboards = async (projectId: string): Promise<TDashboardWithCount[]> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.dashboard.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectDashboard,
|
||||
creator: { select: { name: true } },
|
||||
_count: { select: { widgets: true } },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const duplicateDashboard = async (
|
||||
dashboardId: string,
|
||||
projectId: string,
|
||||
createdBy: string
|
||||
): Promise<TDashboard> => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId], [createdBy, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const source = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
include: {
|
||||
widgets: { orderBy: { order: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
const baseName = `${source.name} (copy)`;
|
||||
let name = baseName;
|
||||
let suffix = 1;
|
||||
|
||||
while (await tx.dashboard.findFirst({ where: { projectId, name } })) {
|
||||
suffix++;
|
||||
if (suffix > MAX_NAME_ATTEMPTS) {
|
||||
name = `${baseName} ${suffix}`;
|
||||
break;
|
||||
}
|
||||
name = `${baseName} ${suffix}`;
|
||||
}
|
||||
|
||||
const newDashboard = await tx.dashboard.create({
|
||||
data: {
|
||||
name,
|
||||
description: source.description,
|
||||
projectId,
|
||||
createdBy,
|
||||
widgets: {
|
||||
create: source.widgets.map((widget) => ({
|
||||
chartId: widget.chartId,
|
||||
title: widget.title,
|
||||
layout: widget.layout ?? { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: widget.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: selectDashboard,
|
||||
});
|
||||
|
||||
return newDashboard;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addChartToDashboard = async (data: TAddWidgetInput) => {
|
||||
validateInputs([data, ZAddWidgetInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const [chart, dashboard] = await Promise.all([
|
||||
tx.chart.findFirst({ where: { id: data.chartId, projectId: data.projectId } }),
|
||||
tx.dashboard.findFirst({ where: { id: data.dashboardId, projectId: data.projectId } }),
|
||||
]);
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", data.chartId);
|
||||
}
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", data.dashboardId);
|
||||
}
|
||||
|
||||
const maxOrder = await tx.dashboardWidget.aggregate({
|
||||
where: { dashboardId: data.dashboardId },
|
||||
_max: { order: true },
|
||||
});
|
||||
|
||||
return tx.dashboardWidget.create({
|
||||
data: {
|
||||
dashboardId: data.dashboardId,
|
||||
chartId: data.chartId,
|
||||
title: data.title,
|
||||
layout: data.layout,
|
||||
order: (maxOrder._max.order ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("This chart is already on the dashboard");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Delay } from "@suspensive/react";
|
||||
import { Suspense, use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { CreateDashboardButton } from "../components/create-dashboard-button";
|
||||
import { DashboardsListSkeleton } from "../components/dashboards-list-skeleton";
|
||||
import { DashboardsTable } from "../components/dashboards-table";
|
||||
import { getDashboards } from "../lib/dashboards";
|
||||
|
||||
interface DashboardsListContentProps {
|
||||
dashboardsPromise: Promise<TDashboardWithCount[]>;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const DashboardsListContent = ({
|
||||
dashboardsPromise,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardsListContentProps>) => {
|
||||
const dashboards = use(dashboardsPromise);
|
||||
|
||||
return <DashboardsTable dashboards={dashboards} environmentId={environmentId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
interface DashboardsListPageProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const DashboardsListPage = async ({ environmentId }: Readonly<DashboardsListPageProps>) => {
|
||||
const t = await getTranslate();
|
||||
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const dashboardsPromise = getDashboards(project.id);
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
environmentId={environmentId}
|
||||
cta={isReadOnly ? undefined : <CreateDashboardButton environmentId={environmentId} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Delay ms={200}>
|
||||
<DashboardsListSkeleton
|
||||
columnHeaders={[
|
||||
t("common.title"),
|
||||
t("common.charts"),
|
||||
t("common.created_by"),
|
||||
t("common.created"),
|
||||
t("common.updated"),
|
||||
]}
|
||||
/>
|
||||
</Delay>
|
||||
}>
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockGetEnvironment = vi.fn();
|
||||
const mockGetOrganizationIdFromProjectId = vi.fn();
|
||||
const mockCheckAuthorizationUpdated = vi.fn();
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: (...args: any[]) => mockGetEnvironment(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromProjectId: (...args: any[]) => mockGetOrganizationIdFromProjectId(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: (...args: any[]) => mockCheckAuthorizationUpdated(...args),
|
||||
}));
|
||||
|
||||
const mockUserId = "user-abc-123";
|
||||
const mockEnvironmentId = "env-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockOrganizationId = "org-abc-123";
|
||||
|
||||
describe("checkProjectAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns organizationId and projectId on successful access check", async () => {
|
||||
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
|
||||
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
const result = await checkProjectAccess(mockUserId, mockEnvironmentId, "readWrite");
|
||||
|
||||
expect(result).toEqual({ organizationId: mockOrganizationId, projectId: mockProjectId });
|
||||
expect(mockGetEnvironment).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(mockGetOrganizationIdFromProjectId).toHaveBeenCalledWith(mockProjectId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", minPermission: "readWrite", projectId: mockProjectId },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when environment is not found", async () => {
|
||||
mockGetEnvironment.mockResolvedValue(null);
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "read")).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "environment",
|
||||
resourceId: mockEnvironmentId,
|
||||
});
|
||||
expect(mockGetOrganizationIdFromProjectId).not.toHaveBeenCalled();
|
||||
expect(mockCheckAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("propagates authorization errors from checkAuthorizationUpdated", async () => {
|
||||
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
|
||||
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
|
||||
mockCheckAuthorizationUpdated.mockRejectedValue(new Error("Unauthorized"));
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "manage")).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import "server-only";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
export const checkProjectAccess = async (
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
minPermission: TTeamPermission
|
||||
) => {
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", environmentId);
|
||||
}
|
||||
|
||||
const projectId = environment.projectId;
|
||||
const organizationId = await getOrganizationIdFromProjectId(projectId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", minPermission, projectId },
|
||||
],
|
||||
});
|
||||
|
||||
return { organizationId, projectId };
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Generates a system prompt for the AI chart query LLM.
|
||||
* Derived from FEEDBACK_FIELDS to keep schema and prompt in sync.
|
||||
*/
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
FEEDBACK_FIELDS,
|
||||
FILTER_OPERATORS,
|
||||
type FieldDefinition,
|
||||
type MeasureDefinition,
|
||||
} from "./schema-definition";
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
function formatMeasure(m: MeasureDefinition): string {
|
||||
const suffix = m.description ? ` (${m.description})` : "";
|
||||
return `- ${m.id}: ${m.label}${suffix}`;
|
||||
}
|
||||
|
||||
function formatDimension(d: FieldDefinition): string {
|
||||
const suffix = d.description ? ` (${d.description})` : "";
|
||||
return `- ${d.id}: ${d.label}${suffix}`;
|
||||
}
|
||||
|
||||
function formatOperators(): string {
|
||||
const lines = Object.entries(FILTER_OPERATORS).map(([type, ops]) => ` ${type}: ${ops.join(", ")}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateSchemaContext(): string {
|
||||
const measuresText = FEEDBACK_FIELDS.measures.map(formatMeasure).join("\n");
|
||||
const dimensionsText = FEEDBACK_FIELDS.dimensions.map(formatDimension).join("\n");
|
||||
const datePresetsText = DATE_PRESETS.map((p) => `"${p.value}"`).join(", ");
|
||||
const operatorsText = formatOperators();
|
||||
|
||||
return `You are an expert at converting natural language questions into Cube.js analytics queries.
|
||||
|
||||
## Available schema
|
||||
|
||||
### Measures (use these measure IDs in the query)
|
||||
${measuresText}
|
||||
|
||||
### Dimensions (use these dimension IDs in the query)
|
||||
${dimensionsText}
|
||||
|
||||
### Time dimension
|
||||
The time field is \`${CUBE_NAME}.collectedAt\`. Supported granularities: hour, day, week, month, quarter, year.
|
||||
Date range presets: ${datePresetsText}
|
||||
|
||||
### Filter operators by field type
|
||||
${operatorsText}
|
||||
|
||||
## Guidelines
|
||||
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
|
||||
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
|
||||
- For time-based questions, add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\`, an appropriate granularity, and a dateRange preset or custom range.
|
||||
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
|
||||
- Filters must use the exact operator strings from the schema.`;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Query builder utility to construct Cube.js queries from chart builder state.
|
||||
*/
|
||||
import { TChartQuery, TCubeFilter, TMemberFilter, TTimeDimension } from "@formbricks/types/analysis";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CustomMeasure {
|
||||
id?: string;
|
||||
field: string;
|
||||
aggregation: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
export type TFilterFieldType = "string" | "number" | "time";
|
||||
|
||||
export interface FilterRow {
|
||||
field: string;
|
||||
operator: TMemberFilter["operator"];
|
||||
values: string[] | number[] | null;
|
||||
}
|
||||
|
||||
export interface TimeDimensionConfig {
|
||||
dimension: string;
|
||||
granularity?: "second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||
dateRange?: string | [Date, Date];
|
||||
}
|
||||
|
||||
export interface ChartBuilderState {
|
||||
chartType: TChartType | "";
|
||||
selectedMeasures: string[];
|
||||
customMeasures: CustomMeasure[];
|
||||
selectedDimensions: string[];
|
||||
filters: FilterRow[];
|
||||
filterLogic: "and" | "or";
|
||||
timeDimension: TimeDimensionConfig | null;
|
||||
limit?: number;
|
||||
orderBy?: { field: string; direction: "asc" | "desc" };
|
||||
}
|
||||
|
||||
function buildMemberFilter(f: FilterRow): TMemberFilter {
|
||||
const filter: TMemberFilter = {
|
||||
member: f.field,
|
||||
operator: f.operator,
|
||||
};
|
||||
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
|
||||
filter.values = f.values.map(String);
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Cube.js query from chart builder state.
|
||||
*/
|
||||
export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
|
||||
const query: TChartQuery = {
|
||||
measures: [...config.selectedMeasures],
|
||||
};
|
||||
|
||||
if (config.selectedDimensions.length > 0) {
|
||||
query.dimensions = config.selectedDimensions;
|
||||
}
|
||||
|
||||
if (config.timeDimension) {
|
||||
const timeDim: TTimeDimension = {
|
||||
dimension: config.timeDimension.dimension,
|
||||
};
|
||||
|
||||
if (config.timeDimension.granularity) {
|
||||
timeDim.granularity = config.timeDimension.granularity;
|
||||
}
|
||||
|
||||
if (typeof config.timeDimension.dateRange === "string") {
|
||||
timeDim.dateRange = config.timeDimension.dateRange;
|
||||
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||
const [startDate, endDate] = config.timeDimension.dateRange;
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
|
||||
}
|
||||
|
||||
query.timeDimensions = [timeDim];
|
||||
}
|
||||
|
||||
if (config.filters.length > 0) {
|
||||
const memberFilters = config.filters.map(buildMemberFilter);
|
||||
|
||||
if (config.filterLogic === "or") {
|
||||
query.filters = [{ or: memberFilters } as TCubeFilter];
|
||||
} else {
|
||||
query.filters = memberFilters;
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function isMemberFilter(f: TCubeFilter): f is TMemberFilter {
|
||||
return "member" in f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cube.js query back into ChartBuilderState.
|
||||
* Preserves absent granularity / dateRange instead of injecting defaults.
|
||||
*/
|
||||
export function parseQueryToState(query: TChartQuery, chartType?: TChartType): Partial<ChartBuilderState> {
|
||||
const state: Partial<ChartBuilderState> = {
|
||||
chartType: chartType || "",
|
||||
selectedMeasures: query.measures || [],
|
||||
customMeasures: [],
|
||||
selectedDimensions: query.dimensions || [],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
|
||||
if (query.filters && query.filters.length > 0) {
|
||||
const first = query.filters[0];
|
||||
|
||||
if (!isMemberFilter(first) && "or" in first && query.filters.length === 1) {
|
||||
state.filterLogic = "or";
|
||||
state.filters = (first.or as TMemberFilter[]).map((f) => ({
|
||||
field: f.member,
|
||||
operator: f.operator,
|
||||
values: f.values || null,
|
||||
}));
|
||||
} else {
|
||||
state.filterLogic = "and";
|
||||
state.filters = query.filters.filter(isMemberFilter).map((f) => ({
|
||||
field: f.member,
|
||||
operator: f.operator,
|
||||
values: f.values || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
||||
const timeDim = query.timeDimensions[0];
|
||||
const config: TimeDimensionConfig = {
|
||||
dimension: timeDim.dimension,
|
||||
};
|
||||
if (timeDim.granularity) {
|
||||
config.granularity = timeDim.granularity;
|
||||
}
|
||||
if (timeDim.dateRange) {
|
||||
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
|
||||
}
|
||||
state.timeDimension = config;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Schema definitions for FeedbackRecords fields.
|
||||
* Used by the advanced chart builder to provide field metadata and operators.
|
||||
*/
|
||||
|
||||
export interface FieldDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "time";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MeasureDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "count" | "number";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const FEEDBACK_FIELDS = {
|
||||
dimensions: [
|
||||
{
|
||||
id: "FeedbackRecords.sentiment",
|
||||
label: "Sentiment",
|
||||
type: "string",
|
||||
description: "Sentiment extracted from feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.sourceType",
|
||||
label: "Source Type",
|
||||
type: "string",
|
||||
description: "Source type of the feedback (e.g., nps_campaign, survey)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.sourceName",
|
||||
label: "Source Name",
|
||||
type: "string",
|
||||
description: "Human-readable name of the source",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.fieldType",
|
||||
label: "Field Type",
|
||||
type: "string",
|
||||
description: "Type of feedback field (e.g., nps, text, rating)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.emotion",
|
||||
label: "Emotion",
|
||||
type: "string",
|
||||
description: "Emotion extracted from metadata JSONB field",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.userIdentifier",
|
||||
label: "User Identifier",
|
||||
type: "string",
|
||||
description: "Identifier of the user who provided feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.responseId",
|
||||
label: "Response ID",
|
||||
type: "string",
|
||||
description: "Unique identifier linking related feedback records",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsValue",
|
||||
label: "NPS Value",
|
||||
type: "number",
|
||||
description: "Raw NPS score value (0-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.collectedAt",
|
||||
label: "Collected At",
|
||||
type: "time",
|
||||
description: "Timestamp when the feedback was collected",
|
||||
},
|
||||
{
|
||||
id: "TopicsUnnested.topic",
|
||||
label: "Topic",
|
||||
type: "string",
|
||||
description: "Individual topic from the topics array",
|
||||
},
|
||||
] as FieldDefinition[],
|
||||
measures: [
|
||||
{
|
||||
id: "FeedbackRecords.count",
|
||||
label: "Count",
|
||||
type: "count",
|
||||
description: "Total number of feedback responses",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.promoterCount",
|
||||
label: "Promoter Count",
|
||||
type: "count",
|
||||
description: "Number of promoters (NPS score 9-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.detractorCount",
|
||||
label: "Detractor Count",
|
||||
type: "count",
|
||||
description: "Number of detractors (NPS score 0-6)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.passiveCount",
|
||||
label: "Passive Count",
|
||||
type: "count",
|
||||
description: "Number of passives (NPS score 7-8)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsScore",
|
||||
label: "NPS Score",
|
||||
type: "number",
|
||||
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.averageScore",
|
||||
label: "Average Score",
|
||||
type: "number",
|
||||
description: "Average NPS score",
|
||||
},
|
||||
] as MeasureDefinition[],
|
||||
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
|
||||
};
|
||||
|
||||
export type FilterOperator =
|
||||
| "equals"
|
||||
| "notEquals"
|
||||
| "contains"
|
||||
| "notContains"
|
||||
| "set"
|
||||
| "notSet"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte";
|
||||
|
||||
export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
|
||||
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
|
||||
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
};
|
||||
|
||||
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
|
||||
|
||||
export type TimeGranularity = (typeof TIME_GRANULARITIES)[number];
|
||||
|
||||
export const DATE_PRESETS = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Yesterday", value: "yesterday" },
|
||||
{ label: "Last 7 days", value: "last 7 days" },
|
||||
{ label: "Last 30 days", value: "last 30 days" },
|
||||
{ label: "This month", value: "this month" },
|
||||
{ label: "Last month", value: "last month" },
|
||||
{ label: "This quarter", value: "this quarter" },
|
||||
{ label: "This year", value: "this year" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get filter operators for a given field type.
|
||||
*/
|
||||
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
|
||||
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field definition by ID.
|
||||
*/
|
||||
export function getFieldById(id: string): FieldDefinition | MeasureDefinition | undefined {
|
||||
const dimension = FEEDBACK_FIELDS.dimensions.find((d) => d.id === id);
|
||||
if (dimension) return dimension;
|
||||
return FEEDBACK_FIELDS.measures.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
const TIME_GRANULARITY_LABELS: Record<string, string> = {
|
||||
hour: "Hour",
|
||||
day: "Day",
|
||||
week: "Week",
|
||||
month: "Month",
|
||||
quarter: "Quarter",
|
||||
year: "Year",
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a Cube.js column key for display (e.g. FeedbackRecords.collectedAt.day → "Day").
|
||||
*/
|
||||
export function formatCubeColumnHeader(key: string): string {
|
||||
const granularity = TIME_GRANULARITIES.find((g) => key.endsWith(`.${g}`));
|
||||
if (granularity && TIME_GRANULARITY_LABELS[granularity]) {
|
||||
return TIME_GRANULARITY_LABELS[granularity];
|
||||
}
|
||||
const field = getFieldById(key);
|
||||
if (field) return field.label;
|
||||
const lastSegment = key.split(".").pop() ?? key;
|
||||
return lastSegment
|
||||
.replaceAll(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (s) => s.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod";
|
||||
import { ZChartConfig, ZChartQuery, ZWidgetLayout } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
export const CHART_TYPE_IDS = ["area", "bar", "line", "pie", "big_number"] as const;
|
||||
export const ZChartType = z.enum(CHART_TYPE_IDS);
|
||||
export type TChartType = z.infer<typeof ZChartType>;
|
||||
|
||||
// ── Chart input schemas ─────────────────────────────────────────────────────
|
||||
|
||||
export const ZChartCreateInput = z.object({
|
||||
projectId: ZId,
|
||||
name: z.string().min(1),
|
||||
type: ZChartType,
|
||||
query: ZChartQuery,
|
||||
config: ZChartConfig,
|
||||
createdBy: ZId,
|
||||
});
|
||||
export type TChartCreateInput = z.infer<typeof ZChartCreateInput>;
|
||||
|
||||
export const ZChartUpdateInput = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
type: ZChartType.optional(),
|
||||
query: ZChartQuery.optional(),
|
||||
config: ZChartConfig.optional(),
|
||||
});
|
||||
export type TChartUpdateInput = z.infer<typeof ZChartUpdateInput>;
|
||||
|
||||
// ── Chart output type (matches selectChart) ─────────────────────────────────
|
||||
|
||||
export const ZChart = z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
query: ZChartQuery,
|
||||
config: ZChartConfig,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
export type TChart = z.infer<typeof ZChart>;
|
||||
|
||||
export const ZChartWithCreator = ZChart.extend({
|
||||
creator: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export type TChartWithCreator = z.infer<typeof ZChartWithCreator>;
|
||||
|
||||
export const ZChartWithWidgets = ZChart.extend({
|
||||
widgets: z.array(z.object({ dashboardId: ZId })),
|
||||
});
|
||||
export type TChartWithWidgets = z.infer<typeof ZChartWithWidgets>;
|
||||
|
||||
// ── Dashboard input schemas ─────────────────────────────────────────────────
|
||||
|
||||
export const ZDashboardCreateInput = z.object({
|
||||
projectId: ZId,
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
createdBy: ZId,
|
||||
});
|
||||
export type TDashboardCreateInput = z.infer<typeof ZDashboardCreateInput>;
|
||||
|
||||
export const ZDashboardUpdateInput = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
});
|
||||
export type TDashboardUpdateInput = z.infer<typeof ZDashboardUpdateInput>;
|
||||
|
||||
// ── Dashboard output type (matches selectDashboard) ─────────────────────────
|
||||
|
||||
export type TDashboard = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
export type TDashboardWithCount = TDashboard & {
|
||||
creator: { name: string } | null;
|
||||
_count: { widgets: number };
|
||||
};
|
||||
|
||||
// ── Widget input schema ─────────────────────────────────────────────────────
|
||||
|
||||
export const ZAddWidgetInput = z.object({
|
||||
dashboardId: ZId,
|
||||
chartId: ZId,
|
||||
projectId: ZId,
|
||||
title: z.string().optional(),
|
||||
layout: ZWidgetLayout,
|
||||
});
|
||||
export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;
|
||||
|
||||
// ── Charts UI (query execution, AI response) ─────────────────────────────────
|
||||
|
||||
/** Row from Cube.js tablePivot - keys are measure/dimension names, values are primitives */
|
||||
export type TChartDataRow = Record<string, string | number | null | boolean | undefined>;
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
query: z.infer<typeof ZChartQuery>;
|
||||
chartType: TChartType;
|
||||
data?: TChartDataRow[];
|
||||
error?: string;
|
||||
}
|
||||
@@ -229,4 +229,49 @@ describe("withAuditLogging", () => {
|
||||
// Reset for other tests; clearAllMockHandles will also do this in the next beforeEach
|
||||
if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = true;
|
||||
});
|
||||
|
||||
test("resolves targetId for chart target type", async () => {
|
||||
const chartCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, chartId: "chart-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "chart", handlerImpl);
|
||||
await wrapped({ ctx: chartCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("chart");
|
||||
expect(callArgs.target.id).toBe("chart-1");
|
||||
});
|
||||
|
||||
test("resolves targetId for dashboard target type", async () => {
|
||||
const dashCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardId: "dash-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "dashboard", handlerImpl);
|
||||
await wrapped({ ctx: dashCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("dashboard");
|
||||
expect(callArgs.target.id).toBe("dash-1");
|
||||
});
|
||||
|
||||
test("resolves targetId for dashboardWidget target type", async () => {
|
||||
const widgetCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardWidgetId: "widget-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "dashboardWidget", handlerImpl);
|
||||
await wrapped({ ctx: widgetCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("dashboardWidget");
|
||||
expect(callArgs.target.id).toBe("widget-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,6 +292,15 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
|
||||
case "quota":
|
||||
targetId = auditLoggingCtx.quotaId;
|
||||
break;
|
||||
case "chart":
|
||||
targetId = auditLoggingCtx.chartId;
|
||||
break;
|
||||
case "dashboard":
|
||||
targetId = auditLoggingCtx.dashboardId;
|
||||
break;
|
||||
case "dashboardWidget":
|
||||
targetId = auditLoggingCtx.dashboardWidgetId;
|
||||
break;
|
||||
default:
|
||||
targetId = UNKNOWN_DATA;
|
||||
break;
|
||||
|
||||
@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
|
||||
"integration",
|
||||
"file",
|
||||
"quota",
|
||||
"chart",
|
||||
"dashboard",
|
||||
"dashboardWidget",
|
||||
]);
|
||||
export const ZAuditAction = z.enum([
|
||||
"created",
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ContactControlBar = ({
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat={t("common.person")}
|
||||
deleteWhat="person"
|
||||
onDelete={handleDeletePerson}
|
||||
isDeleting={isDeletingPerson}
|
||||
text={
|
||||
|
||||
@@ -66,7 +66,7 @@ export const DeleteContactButton = ({
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat={t("common.person")}
|
||||
deleteWhat="person"
|
||||
onDelete={handleDeletePerson}
|
||||
isDeleting={isDeletingPerson}
|
||||
text={
|
||||
|
||||
@@ -52,41 +52,54 @@ export const getPersonSegmentIds = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
const alwaysMatchIds: string[] = [];
|
||||
const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const filters = segment.filters as TBaseFilters;
|
||||
|
||||
if (!filters?.length) {
|
||||
alwaysMatchIds.push(segment.id);
|
||||
for (const item of segmentWithClauses) {
|
||||
if (item.whereClause === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
if (Object.keys(item.whereClause).length === 0) {
|
||||
alwaysMatchIds.push(item.segmentId);
|
||||
} else {
|
||||
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
|
||||
}
|
||||
|
||||
dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause });
|
||||
}
|
||||
|
||||
if (dbChecks.length === 0) {
|
||||
if (toCheck.length === 0) {
|
||||
return alwaysMatchIds;
|
||||
}
|
||||
|
||||
// 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 }) =>
|
||||
// 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 }) =>
|
||||
prisma.contact.findFirst({
|
||||
where: { id: contactId, ...whereClause },
|
||||
select: { id: true },
|
||||
@@ -94,12 +107,17 @@ export const getPersonSegmentIds = async (
|
||||
)
|
||||
);
|
||||
|
||||
const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
// Phase 3: Collect matching segment IDs
|
||||
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
|
||||
return [...alwaysMatchIds, ...matchedIds];
|
||||
return [...alwaysMatchIds, ...dbMatchIds];
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ environmentId, contactId, error },
|
||||
{
|
||||
environmentId,
|
||||
contactId,
|
||||
error,
|
||||
},
|
||||
"Failed to get person segment IDs, returning empty array"
|
||||
);
|
||||
return [];
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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-${Math.random()}`), // Use filter ID or random for group
|
||||
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${crypto.randomUUID()}`), // Use filter ID or UUID for group
|
||||
connector,
|
||||
resource,
|
||||
});
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
<DeleteDialog
|
||||
open={isDeleteMemberModalOpen}
|
||||
setOpen={setDeleteMemberModalOpen}
|
||||
deleteWhat={t("environments.settings.general.from_your_organization", { memberName })}
|
||||
deleteWhat={`${memberName} ${t("environments.settings.general.from_your_organization")}`}
|
||||
onDelete={handleDeleteMember}
|
||||
isDeleting={isDeleting}
|
||||
text={t("environments.settings.general.delete_member_confirmation")}
|
||||
|
||||
@@ -89,7 +89,7 @@ export const DeleteProjectRender = ({
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("environments.settings.domain.workspace")}
|
||||
deleteWhat="Workspace"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDeleteProject}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { IS_DEVELOPMENT, 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,6 +7,7 @@ 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";
|
||||
@@ -58,6 +59,9 @@ 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.survey_styling")}
|
||||
{t("environments.surveys.edit.form_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 select-none bg-slate-200">
|
||||
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
{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 select-none items-center space-x-2">
|
||||
<div className="pointer-events-none flex cursor-not-allowed items-center space-x-2 select-none">
|
||||
<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.survey_styling")}
|
||||
{t("environments.surveys.edit.form_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={t("common.survey")}
|
||||
deleteWhat="Survey"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => handleDeleteSurvey(survey.id)}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import { ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Format: { THEME_NAME: CSS_VARIABLE }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof ResponsiveContainer>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replaceAll(":", "")}`;
|
||||
const contextValue = React.useMemo(() => ({ config }), [config]);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "ChartContainer";
|
||||
|
||||
const ChartTooltip = Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
active?: boolean;
|
||||
payload?: unknown[];
|
||||
label?: string;
|
||||
labelFormatter?: (value: unknown, payload: unknown[]) => React.ReactNode;
|
||||
labelClassName?: string;
|
||||
formatter?: (
|
||||
value: unknown,
|
||||
name: string,
|
||||
item: unknown,
|
||||
index: number,
|
||||
payload: unknown[]
|
||||
) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || (item as { dataKey?: string; name?: string }).dataKey || (item as { name?: string }).name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string" ? config[label]?.label || label : itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
const payloadArray: unknown[] = Array.isArray(payload) ? payload : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border/50 grid min-w-[8rem] items-start gap-1.5 rounded-lg border bg-white px-2.5 py-1.5 text-xs shadow-xl dark:bg-gray-950",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel && tooltipLabel}
|
||||
<div className="grid gap-1.5">
|
||||
{payloadArray.map((item, index) => {
|
||||
const itemObj = item as {
|
||||
dataKey?: string;
|
||||
name?: string;
|
||||
value?: number;
|
||||
payload?: { fill?: string };
|
||||
};
|
||||
const key = `${nameKey || itemObj.name || itemObj.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || itemObj.payload?.fill || (item as { color?: string }).color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemObj.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
{formatter && itemObj?.value !== undefined && itemObj.name ? (
|
||||
formatter(itemObj.value, itemObj.name, item, index, payloadArray)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label || itemObj.name}</span>
|
||||
</div>
|
||||
{itemObj.value !== undefined && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{itemObj.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
payload?: unknown[];
|
||||
verticalAlign?: "top" | "bottom" | "middle";
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload.map((item) => {
|
||||
const itemObj = item as { dataKey?: string; value?: unknown; color?: string };
|
||||
const key = `${nameKey || itemObj.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(itemObj.value)}
|
||||
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: itemObj.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof (payload as { payload?: unknown }).payload === "object" && payload !== null
|
||||
? (payload as { payload: Record<string, unknown> }).payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof (payload as Record<string, unknown>)[key] === "string") {
|
||||
configLabelKey = (payload as Record<string, unknown>)[key] as string;
|
||||
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key] === "string") {
|
||||
configLabelKey = payloadPayload[key];
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: Readonly<{ id: string; config: ChartConfig }>) => {
|
||||
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user